@oh-my-pi/omp-stats 18.14.1 → 18.15.0
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.
- package/CHANGELOG.md +10 -0
- package/dist/client/index.js +62 -62
- package/dist/types/client/api.d.ts +4 -0
- package/dist/types/client/app/CloseButton.d.ts +7 -0
- package/dist/types/client/app/keys.d.ts +9 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server.d.ts +7 -0
- package/package.json +4 -8
- package/src/client/api.ts +13 -0
- package/src/client/app/CloseButton.tsx +56 -0
- package/src/client/app/Shell.tsx +4 -11
- package/src/client/app/keys.ts +21 -0
- package/src/client/react-css.d.ts +8 -0
- package/src/client/traces/TranscriptList.tsx +1 -1
- package/src/client/ui/Card.tsx +2 -2
- package/src/client/ui/Drawer.tsx +2 -2
- package/src/client/ui/Stat.tsx +2 -5
- package/src/db.ts +2 -1
- package/src/index.ts +1 -1
- package/src/parser.ts +8 -1
- package/src/server.ts +24 -0
|
@@ -10,6 +10,10 @@ export declare function getCostDashboardStats(range?: TimeRange, signal?: AbortS
|
|
|
10
10
|
export declare function getRecentRequests(limit?: number, signal?: AbortSignal): Promise<MessageStats[]>;
|
|
11
11
|
export declare function getRecentErrors(range?: TimeRange, limit?: number, signal?: AbortSignal): Promise<MessageStats[]>;
|
|
12
12
|
export declare function getRequestDetails(id: number, signal?: AbortSignal): Promise<RequestDetails>;
|
|
13
|
+
/** Whether the page may close the dashboard (`omp stats` shown in place of a Tern block). */
|
|
14
|
+
export declare function getClosable(signal?: AbortSignal): Promise<boolean>;
|
|
15
|
+
/** Close the dashboard: its host takes the page away and stops serving it. */
|
|
16
|
+
export declare function closeDashboard(): Promise<void>;
|
|
13
17
|
/** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
|
|
14
18
|
export declare function requestSync(): Promise<LiveStatus>;
|
|
15
19
|
export declare function getFrustrationDashboardStats(range?: TimeRange, signal?: AbortSignal): Promise<FrustrationDashboardStats>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close, while the host shows the dashboard in place of its terminal
|
|
3
|
+
* (`omp stats` in Tern), where the page is the only way out: the button, Esc,
|
|
4
|
+
* or Ctrl+C with nothing selected, as in the terminal it covers. Renders
|
|
5
|
+
* nothing anywhere else.
|
|
6
|
+
*/
|
|
7
|
+
export declare function CloseButton(): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for the dashboard's page-wide keys (`Shell`'s shortcuts,
|
|
3
|
+
* `CloseButton`'s Esc and Ctrl+C): they stay out of fields being typed
|
|
4
|
+
* in and out of open dialogs, which own their keys.
|
|
5
|
+
*/
|
|
6
|
+
/** Whether `target` takes typing (a field, a select, editable content). */
|
|
7
|
+
export declare function isTypingTarget(target: EventTarget | null): boolean;
|
|
8
|
+
/** Whether a modal dialog or drawer is open. */
|
|
9
|
+
export declare function isDialogOpen(): boolean;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { closeDb } from "./db.js";
|
|
|
4
4
|
export { refreshRollups } from "./rollup.js";
|
|
5
5
|
export type { StatsJudge, StatsJudgeProvider } from "./frustration.js";
|
|
6
6
|
export { getGainDashboardStats } from "./gain-aggregator.js";
|
|
7
|
-
export { formatStatsDashboardUrl, type StartServerOptions, startServer } from "./server.js";
|
|
7
|
+
export { formatStatsDashboardUrl, type StartServerOptions, setStatsCloseHandler, startServer } from "./server.js";
|
|
8
8
|
export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types.js";
|
|
9
9
|
export type { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, ToolDashboardStats, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats, } from "./types.js";
|
|
10
10
|
/**
|
package/dist/types/server.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type StatsJudgeProvider } from "./frustration.js";
|
|
2
|
+
/**
|
|
3
|
+
* Let the page close the dashboard: while `close` is set, it shows a Close
|
|
4
|
+
* button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
|
|
5
|
+
* Tern shows the page in place of the block it runs in, where the page has no
|
|
6
|
+
* way out of its own; `undefined` takes Close away again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setStatsCloseHandler(close: (() => void) | undefined): void;
|
|
2
9
|
/**
|
|
3
10
|
* Handle API requests.
|
|
4
11
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.15.0",
|
|
5
5
|
"description": "Local observability dashboard for omp AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@oh-my-pi/pi-ai": "18.
|
|
46
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
47
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
+
"@oh-my-pi/pi-ai": "18.15.0",
|
|
46
|
+
"@oh-my-pi/pi-catalog": "18.15.0",
|
|
47
|
+
"@oh-my-pi/pi-utils": "18.15.0",
|
|
48
48
|
"lucide-react": "^1.24.0",
|
|
49
49
|
"react": "19.2.7",
|
|
50
50
|
"react-dom": "19.2.7"
|
|
@@ -89,10 +89,6 @@
|
|
|
89
89
|
"types": "./dist/types/client/*.d.ts",
|
|
90
90
|
"import": "./src/client/*.ts"
|
|
91
91
|
},
|
|
92
|
-
"./client/components/*": {
|
|
93
|
-
"types": "./dist/types/client/components/*.d.ts",
|
|
94
|
-
"import": "./src/client/components/*.ts"
|
|
95
|
-
},
|
|
96
92
|
"./*.js": "./src/*.ts"
|
|
97
93
|
}
|
|
98
94
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -121,6 +121,19 @@ export async function getRequestDetails(id: number, signal?: AbortSignal): Promi
|
|
|
121
121
|
return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** Whether the page may close the dashboard (`omp stats` shown in place of a Tern block). */
|
|
125
|
+
export async function getClosable(signal?: AbortSignal): Promise<boolean> {
|
|
126
|
+
const { closable } = await fetchJson<{ closable: boolean }>(`${API_BASE}/close`, { signal });
|
|
127
|
+
return closable;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Close the dashboard: its host takes the page away and stops serving it. */
|
|
131
|
+
export async function closeDashboard(): Promise<void> {
|
|
132
|
+
const endpoint = `${API_BASE}/close`;
|
|
133
|
+
const res = await fetch(endpoint, { method: "POST", headers: ACTION_HEADERS });
|
|
134
|
+
if (!res.ok) throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
/** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
|
|
125
138
|
export async function requestSync(): Promise<LiveStatus> {
|
|
126
139
|
return fetchJson<LiveStatus>(`${API_BASE}/sync`, { method: "POST" });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { closeDashboard, getClosable } from "../api";
|
|
4
|
+
import { isDialogOpen, isTypingTarget } from "./keys";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Close, while the host shows the dashboard in place of its terminal
|
|
8
|
+
* (`omp stats` in Tern), where the page is the only way out: the button, Esc,
|
|
9
|
+
* or Ctrl+C with nothing selected, as in the terminal it covers. Renders
|
|
10
|
+
* nothing anywhere else.
|
|
11
|
+
*/
|
|
12
|
+
export function CloseButton() {
|
|
13
|
+
const closable = useClosable();
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!closable) return;
|
|
17
|
+
const onKey = (e: KeyboardEvent) => {
|
|
18
|
+
if (!isCloseKey(e)) return;
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
close();
|
|
21
|
+
};
|
|
22
|
+
window.addEventListener("keydown", onKey);
|
|
23
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
24
|
+
}, [closable]);
|
|
25
|
+
|
|
26
|
+
if (!closable) return null;
|
|
27
|
+
return (
|
|
28
|
+
<button type="button" className="btn" data-variant="ghost" onClick={close} title="Close the dashboard (Esc)">
|
|
29
|
+
<X size={15} />
|
|
30
|
+
Close
|
|
31
|
+
<span className="kbd">Esc</span>
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function close(): void {
|
|
37
|
+
closeDashboard().catch(error => console.error("Closing the dashboard failed", error));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useClosable(): boolean {
|
|
41
|
+
const [closable, setClosable] = useState(false);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const abort = new AbortController();
|
|
44
|
+
getClosable(abort.signal).then(setClosable, () => {});
|
|
45
|
+
return () => abort.abort();
|
|
46
|
+
}, []);
|
|
47
|
+
return closable;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Esc, or Ctrl+C with no text selected (with a selection it copies), outside fields and dialogs. */
|
|
51
|
+
function isCloseKey(e: KeyboardEvent): boolean {
|
|
52
|
+
if (e.defaultPrevented || e.altKey || e.metaKey || e.shiftKey) return false;
|
|
53
|
+
if (isTypingTarget(e.target) || isDialogOpen()) return false;
|
|
54
|
+
if (e.key === "Escape") return !e.ctrlKey;
|
|
55
|
+
return e.ctrlKey && e.key.toLowerCase() === "c" && !window.getSelection()?.toString();
|
|
56
|
+
}
|
package/src/client/app/Shell.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import { rangeMeta, TIME_RANGES } from "../data/range";
|
|
|
4
4
|
import { useLiveStatus } from "../data/live";
|
|
5
5
|
import type { TimeRange } from "../types";
|
|
6
6
|
import { Segmented } from "../ui";
|
|
7
|
+
import { CloseButton } from "./CloseButton";
|
|
8
|
+
import { isDialogOpen, isTypingTarget } from "./keys";
|
|
7
9
|
import { LiveChip } from "./LiveChip";
|
|
8
10
|
import { type DashboardSection, NAV } from "./nav";
|
|
9
11
|
import { ThemeToggle } from "./ThemeToggle";
|
|
@@ -77,6 +79,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
|
|
|
77
79
|
</span>
|
|
78
80
|
<Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
|
|
79
81
|
<ThemeToggle />
|
|
82
|
+
<CloseButton />
|
|
80
83
|
</div>
|
|
81
84
|
{progress !== undefined && (
|
|
82
85
|
<div className="topbar-progress">
|
|
@@ -143,17 +146,7 @@ function useShortcuts(onSection: (section: DashboardSection) => void, onRange: (
|
|
|
143
146
|
let pendingG = 0;
|
|
144
147
|
const onKey = (e: KeyboardEvent) => {
|
|
145
148
|
if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
target instanceof HTMLElement &&
|
|
149
|
-
(target.isContentEditable ||
|
|
150
|
-
target.tagName === "INPUT" ||
|
|
151
|
-
target.tagName === "TEXTAREA" ||
|
|
152
|
-
target.tagName === "SELECT")
|
|
153
|
-
) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (document.querySelector('[aria-modal="true"]')) return;
|
|
149
|
+
if (isTypingTarget(e.target) || isDialogOpen()) return;
|
|
157
150
|
const key = e.key.toLowerCase();
|
|
158
151
|
if (pendingG && Date.now() - pendingG < 1200) {
|
|
159
152
|
pendingG = 0;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for the dashboard's page-wide keys (`Shell`'s shortcuts,
|
|
3
|
+
* `CloseButton`'s Esc and Ctrl+C): they stay out of fields being typed
|
|
4
|
+
* in and out of open dialogs, which own their keys.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Whether `target` takes typing (a field, a select, editable content). */
|
|
8
|
+
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
9
|
+
return (
|
|
10
|
+
target instanceof HTMLElement &&
|
|
11
|
+
(target.isContentEditable ||
|
|
12
|
+
target.tagName === "INPUT" ||
|
|
13
|
+
target.tagName === "TEXTAREA" ||
|
|
14
|
+
target.tagName === "SELECT")
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a modal dialog or drawer is open. */
|
|
19
|
+
export function isDialogOpen(): boolean {
|
|
20
|
+
return document.querySelector('[aria-modal="true"]') !== null;
|
|
21
|
+
}
|
|
@@ -103,7 +103,7 @@ export const TranscriptList = memo(function TranscriptList({
|
|
|
103
103
|
const chipText = row.span?.kind ?? row.marker?.kind ?? "";
|
|
104
104
|
const rowContent = (
|
|
105
105
|
<>
|
|
106
|
-
<span className="traces-kind" style={{ "--traces-kind-color": color }
|
|
106
|
+
<span className="traces-kind" style={{ "--traces-kind-color": color }}>
|
|
107
107
|
{chipText}
|
|
108
108
|
</span>
|
|
109
109
|
{row.depth > 0 && (
|
package/src/client/ui/Card.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
export interface CardProps {
|
|
4
4
|
title?: ReactNode;
|
|
@@ -22,7 +22,7 @@ export function Card({ title, description, actions, flush, footer, className, in
|
|
|
22
22
|
return (
|
|
23
23
|
<section
|
|
24
24
|
className={className ? `card rise ${className}` : "card rise"}
|
|
25
|
-
style={index !== undefined ?
|
|
25
|
+
style={index !== undefined ? { "--i": index } : undefined}
|
|
26
26
|
>
|
|
27
27
|
{hasHeader && (
|
|
28
28
|
<header className="card-header">
|
package/src/client/ui/Drawer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
|
|
5
5
|
export interface DrawerProps {
|
|
@@ -50,7 +50,7 @@ export function Drawer({ open, onClose, title, subtitle, actions, width, childre
|
|
|
50
50
|
role="dialog"
|
|
51
51
|
aria-modal="true"
|
|
52
52
|
tabIndex={-1}
|
|
53
|
-
style={width !== undefined ?
|
|
53
|
+
style={width !== undefined ? { "--drawer-w": `${width}px` } : undefined}
|
|
54
54
|
>
|
|
55
55
|
<header className="drawer-header">
|
|
56
56
|
<div className="drawer-titles">
|
package/src/client/ui/Stat.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Sparkline } from "../charts/Sparkline";
|
|
3
3
|
|
|
4
4
|
export interface StatDelta {
|
|
@@ -58,10 +58,7 @@ export interface StatGridProps {
|
|
|
58
58
|
/** Tiles sharing one card, separated by hairlines. */
|
|
59
59
|
export function StatGrid({ min, children }: StatGridProps) {
|
|
60
60
|
return (
|
|
61
|
-
<div
|
|
62
|
-
className="stat-grid rise"
|
|
63
|
-
style={min !== undefined ? ({ "--stat-min": `${min}px` } as CSSProperties) : undefined}
|
|
64
|
-
>
|
|
61
|
+
<div className="stat-grid rise" style={min !== undefined ? { "--stat-min": `${min}px` } : undefined}>
|
|
65
62
|
{children}
|
|
66
63
|
</div>
|
|
67
64
|
);
|
package/src/db.ts
CHANGED
|
@@ -100,7 +100,8 @@ const FORK_DEDUPE_KEY = "fork_dedupe_v1";
|
|
|
100
100
|
// v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
|
|
101
101
|
// collapses provider-side garbage names; a full re-parse replaces the polluted
|
|
102
102
|
// rows already stored in existing databases.
|
|
103
|
-
|
|
103
|
+
// v3: reclassify synthetic non-executions so historical tool error rates are corrected.
|
|
104
|
+
const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v3";
|
|
104
105
|
// Older ingests dropped `Usage.orchestration` (never a stored column) when
|
|
105
106
|
// pricing, so subscription models billed on orchestration tokens — multi-agent
|
|
106
107
|
// Grok most notably — were priced from conversation buckets alone and could not
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { closeDb } from "./db";
|
|
|
22
22
|
export { refreshRollups } from "./rollup";
|
|
23
23
|
export type { StatsJudge, StatsJudgeProvider } from "./frustration";
|
|
24
24
|
export { getGainDashboardStats } from "./gain-aggregator";
|
|
25
|
-
export { formatStatsDashboardUrl, type StartServerOptions, startServer } from "./server";
|
|
25
|
+
export { formatStatsDashboardUrl, type StartServerOptions, setStatsCloseHandler, startServer } from "./server";
|
|
26
26
|
export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
|
|
27
27
|
export type {
|
|
28
28
|
AggregatedStats,
|
package/src/parser.ts
CHANGED
|
@@ -416,6 +416,13 @@ function sanitizeToolName(name: string): string | null {
|
|
|
416
416
|
return candidate.length > 0 ? candidate : null;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/** A call the agent loop skipped carries a synthetic `executed: false` result: it never ran, so it is no tool error. */
|
|
420
|
+
function isToolExecutionError(message: { isError?: unknown; details?: unknown }): boolean {
|
|
421
|
+
if (message.isError !== true) return false;
|
|
422
|
+
const details = message.details as { __synthetic?: unknown; executed?: unknown } | null | undefined;
|
|
423
|
+
return !(details?.__synthetic === true && details.executed === false);
|
|
424
|
+
}
|
|
425
|
+
|
|
419
426
|
/**
|
|
420
427
|
* Build the result linkage for a `toolResult` entry: text characters fed back
|
|
421
428
|
* into context plus the error flag, keyed to the originating call.
|
|
@@ -435,7 +442,7 @@ function extractToolResultLink(sessionFile: string, entry: SessionMessageEntry):
|
|
|
435
442
|
sessionFile,
|
|
436
443
|
toolCallId: msg.toolCallId,
|
|
437
444
|
resultChars,
|
|
438
|
-
isError: msg
|
|
445
|
+
isError: isToolExecutionError(msg),
|
|
439
446
|
};
|
|
440
447
|
}
|
|
441
448
|
|
package/src/server.ts
CHANGED
|
@@ -163,6 +163,19 @@ const ensureClientBuild = async () => {
|
|
|
163
163
|
*/
|
|
164
164
|
const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
|
|
165
165
|
|
|
166
|
+
/** What the page's Close calls; unset, the page offers none. */
|
|
167
|
+
let closeHandler: (() => void) | undefined;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Let the page close the dashboard: while `close` is set, it shows a Close
|
|
171
|
+
* button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
|
|
172
|
+
* Tern shows the page in place of the block it runs in, where the page has no
|
|
173
|
+
* way out of its own; `undefined` takes Close away again.
|
|
174
|
+
*/
|
|
175
|
+
export function setStatsCloseHandler(close: (() => void) | undefined): void {
|
|
176
|
+
closeHandler = close;
|
|
177
|
+
}
|
|
178
|
+
|
|
166
179
|
/**
|
|
167
180
|
* Handle API requests.
|
|
168
181
|
*/
|
|
@@ -268,6 +281,17 @@ export async function handleApi(req: Request): Promise<Response> {
|
|
|
268
281
|
return Response.json(details);
|
|
269
282
|
}
|
|
270
283
|
|
|
284
|
+
if (path === "/api/close") {
|
|
285
|
+
if (req.method === "GET") return Response.json({ closable: closeHandler !== undefined });
|
|
286
|
+
if (req.method !== "POST") return Response.json({ error: "GET or POST required" }, { status: 405 });
|
|
287
|
+
if (req.headers.get(STATS_ACTION_HEADER) !== "1") {
|
|
288
|
+
return Response.json({ error: `${STATS_ACTION_HEADER}: 1 header required` }, { status: 403 });
|
|
289
|
+
}
|
|
290
|
+
if (!closeHandler) return Response.json({ error: "this dashboard is not closed from its page" }, { status: 404 });
|
|
291
|
+
closeHandler();
|
|
292
|
+
return new Response(null, { status: 202 });
|
|
293
|
+
}
|
|
294
|
+
|
|
271
295
|
if (path === "/api/sync") {
|
|
272
296
|
if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
|
|
273
297
|
statsLive().requestSync();
|