源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# `@tutti-os/analytics-debug`
|
||||
|
||||
Host-agnostic storage and React UI for inspecting analytics events.
|
||||
|
||||
The package does not connect to a daemon, choose a feature flag, or own product
|
||||
translations. Each host adapts its own event stream into
|
||||
`AnalyticsDebugEventStore` and supplies localized labels to the React panel.
|
||||
This keeps Tutti and TSH transport policy independent while sharing bounded
|
||||
storage, redaction, rendering, and floating-panel behavior.
|
||||
|
||||
```ts
|
||||
import { AnalyticsDebugEventStore } from "@tutti-os/analytics-debug";
|
||||
import { AnalyticsDebugFloatingEntry } from "@tutti-os/analytics-debug/react";
|
||||
|
||||
const store = new AnalyticsDebugEventStore({
|
||||
redact: (event) => event
|
||||
});
|
||||
|
||||
eventSource.subscribe((events) => store.recordEvents(events));
|
||||
```
|
||||
|
||||
Hosts must load `@tutti-os/ui-system/styles.css` and include the published debug
|
||||
bundle in Tailwind v4 source discovery. Adjust the relative path for the host
|
||||
stylesheet, for example:
|
||||
|
||||
```css
|
||||
@source "../node_modules/@tutti-os/analytics-debug/dist";
|
||||
```
|
||||
|
||||
Events are kept in memory only. The default limit is 200 events. Hosts that may
|
||||
receive sensitive parameters should supply a `redact` function before exposing
|
||||
the panel.
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@tutti-os/analytics-debug",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./react": "./src/react/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tutti-os/tutti.git",
|
||||
"directory": "packages/analytics/debug"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"test": "node --test --experimental-strip-types \"./src/**/*.test.ts\"",
|
||||
"typecheck": "node ../../../tools/scripts/run-tsgo-typecheck.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tutti-os/analytics": "workspace:*",
|
||||
"@tutti-os/ui-system": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tutti-os/config-tsconfig": "workspace:*",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"tutti": {
|
||||
"tailwindSourceRoot": "src"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AnalyticsDebugEventStore } from "./eventStore.ts";
|
||||
|
||||
test("debug event store keeps immutable bounded snapshots", () => {
|
||||
const store = new AnalyticsDebugEventStore({ maxEvents: 2 });
|
||||
const params = {
|
||||
context: {
|
||||
source: "dashboard",
|
||||
tags: ["primary"]
|
||||
}
|
||||
};
|
||||
let notifications = 0;
|
||||
store.subscribe(() => {
|
||||
notifications++;
|
||||
});
|
||||
|
||||
store.recordEvents([
|
||||
{ clientTS: 1, name: "discarded.event" },
|
||||
{ clientTS: 2, name: "workspace.opened", params },
|
||||
{ clientTS: 3, name: "workspace.closed" }
|
||||
]);
|
||||
params.context.source = "mutated";
|
||||
params.context.tags.push("mutated");
|
||||
|
||||
assert.deepEqual(store.getSnapshot(), [
|
||||
{
|
||||
clientTS: 2,
|
||||
name: "workspace.opened",
|
||||
params: {
|
||||
context: {
|
||||
source: "dashboard",
|
||||
tags: ["primary"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ clientTS: 3, name: "workspace.closed" }
|
||||
]);
|
||||
const snapshot = store.getSnapshot();
|
||||
const storedContext = snapshot[0]?.params?.context;
|
||||
assert.ok(storedContext);
|
||||
assert.throws(() => {
|
||||
(storedContext as { source: string }).source = "consumer mutation";
|
||||
}, TypeError);
|
||||
assert.deepEqual(store.getSnapshot(), snapshot);
|
||||
assert.equal(notifications, 1);
|
||||
});
|
||||
|
||||
test("debug event store redacts before retaining events", () => {
|
||||
const store = new AnalyticsDebugEventStore({
|
||||
redact(event) {
|
||||
if (event.name === "private.event") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
params: {
|
||||
...event.params,
|
||||
token: "[redacted]"
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
store.recordEvents([
|
||||
{
|
||||
clientTS: 1,
|
||||
name: "public.event",
|
||||
params: { token: "secret" }
|
||||
},
|
||||
{
|
||||
clientTS: 2,
|
||||
name: "private.event"
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(store.getSnapshot(), [
|
||||
{
|
||||
clientTS: 1,
|
||||
name: "public.event",
|
||||
params: { token: "[redacted]" }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("debug event store isolates listener failures", () => {
|
||||
const store = new AnalyticsDebugEventStore();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => {
|
||||
throw new Error("broken debug consumer");
|
||||
});
|
||||
store.subscribe(() => {
|
||||
notifications++;
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
store.recordEvents([{ clientTS: 1, name: "workspace.opened" }]);
|
||||
});
|
||||
assert.equal(notifications, 1);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { AnalyticsTransportEvent } from "@tutti-os/analytics";
|
||||
|
||||
export type AnalyticsDebugEvent = AnalyticsTransportEvent;
|
||||
export type AnalyticsDebugEventSnapshot = readonly AnalyticsDebugEvent[];
|
||||
|
||||
export interface AnalyticsDebugEventStoreContract {
|
||||
clear(): void;
|
||||
getSnapshot(): AnalyticsDebugEventSnapshot;
|
||||
recordEvents(events: readonly AnalyticsDebugEvent[]): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export type AnalyticsDebugEventRedactor = (
|
||||
event: AnalyticsDebugEvent
|
||||
) => AnalyticsDebugEvent | null;
|
||||
|
||||
export interface AnalyticsDebugEventStoreOptions {
|
||||
maxEvents?: number;
|
||||
redact?: AnalyticsDebugEventRedactor;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 200;
|
||||
|
||||
/**
|
||||
* Bounded, in-memory analytics event store.
|
||||
*
|
||||
* Transport connection and availability policy intentionally stay in the
|
||||
* product host. Redaction runs before an event enters the store.
|
||||
*/
|
||||
export class AnalyticsDebugEventStore implements AnalyticsDebugEventStoreContract {
|
||||
private events: AnalyticsDebugEventSnapshot = Object.freeze([]);
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly maxEvents: number;
|
||||
private readonly redact: AnalyticsDebugEventRedactor;
|
||||
|
||||
constructor(options: AnalyticsDebugEventStoreOptions = {}) {
|
||||
this.maxEvents = normalizeMaxEvents(options.maxEvents);
|
||||
this.redact = options.redact ?? cloneAnalyticsDebugEvent;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.events = Object.freeze([]);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
getSnapshot(): AnalyticsDebugEventSnapshot {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
recordEvents(events: readonly AnalyticsDebugEvent[]): void {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted: AnalyticsDebugEvent[] = [];
|
||||
for (const event of events) {
|
||||
try {
|
||||
const redacted = this.redact(cloneAnalyticsDebugEvent(event));
|
||||
if (redacted) {
|
||||
accepted.push(
|
||||
freezeAnalyticsDebugEvent(cloneAnalyticsDebugEvent(redacted))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A host redactor must not interrupt event reporting or panel updates.
|
||||
}
|
||||
}
|
||||
if (accepted.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.events = Object.freeze(
|
||||
[...this.events, ...accepted].slice(-this.maxEvents)
|
||||
);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// One debug consumer must not break reporting or other consumers.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMaxEvents(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return DEFAULT_MAX_EVENTS;
|
||||
}
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new RangeError(
|
||||
"analytics debug maxEvents must be a positive integer"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cloneAnalyticsDebugEvent(
|
||||
event: AnalyticsDebugEvent
|
||||
): AnalyticsDebugEvent {
|
||||
return {
|
||||
clientTS: event.clientTS,
|
||||
name: event.name,
|
||||
...(event.params !== undefined
|
||||
? { params: structuredClone(event.params) }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function freezeAnalyticsDebugEvent(
|
||||
event: AnalyticsDebugEvent
|
||||
): AnalyticsDebugEvent {
|
||||
deepFreeze(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function deepFreeze(value: unknown, seen = new WeakSet<object>()): void {
|
||||
if (
|
||||
(typeof value !== "object" && typeof value !== "function") ||
|
||||
value === null ||
|
||||
seen.has(value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
for (const propertyValue of Object.values(value)) {
|
||||
deepFreeze(propertyValue, seen);
|
||||
}
|
||||
Object.freeze(value);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
AnalyticsDebugEventStore,
|
||||
type AnalyticsDebugEvent,
|
||||
type AnalyticsDebugEventRedactor,
|
||||
type AnalyticsDebugEventSnapshot,
|
||||
type AnalyticsDebugEventStoreContract,
|
||||
type AnalyticsDebugEventStoreOptions
|
||||
} from "./eventStore.ts";
|
||||
export {
|
||||
hasAnalyticsDebugFloatingDragMoved,
|
||||
resolveAnalyticsDebugFloatingPosition,
|
||||
type AnalyticsDebugPoint,
|
||||
type AnalyticsDebugPosition,
|
||||
type AnalyticsDebugSize,
|
||||
type ResolveAnalyticsDebugFloatingPositionInput
|
||||
} from "./react/floatingPosition.ts";
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent
|
||||
} from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CloseIcon,
|
||||
DeleteIcon,
|
||||
HealthIcon,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ScrollArea
|
||||
} from "@tutti-os/ui-system";
|
||||
|
||||
import type { AnalyticsDebugEventStoreContract } from "../eventStore.ts";
|
||||
import {
|
||||
hasAnalyticsDebugFloatingDragMoved,
|
||||
resolveAnalyticsDebugFloatingPosition,
|
||||
type AnalyticsDebugPosition
|
||||
} from "./floatingPosition.ts";
|
||||
|
||||
export interface AnalyticsDebugLabels {
|
||||
clear: string;
|
||||
clientTimestamp(value: number): string;
|
||||
close: string;
|
||||
count(value: number): string;
|
||||
empty: string;
|
||||
open: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsDebugFloatingEntryProps {
|
||||
labels: AnalyticsDebugLabels;
|
||||
store: AnalyticsDebugEventStoreContract;
|
||||
formatEventTime?: (timestamp: number) => string;
|
||||
}
|
||||
|
||||
interface DragSession {
|
||||
dragging: boolean;
|
||||
floatingSize: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
pointerStart: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
startPosition: AnalyticsDebugPosition;
|
||||
}
|
||||
|
||||
export function AnalyticsDebugFloatingEntry({
|
||||
formatEventTime = defaultFormatEventTime,
|
||||
labels,
|
||||
store
|
||||
}: AnalyticsDebugFloatingEntryProps) {
|
||||
const [position, setPosition] = useState<AnalyticsDebugPosition | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const dragSessionRef = useRef<DragSession | null>(null);
|
||||
const suppressNextClickRef = useRef(false);
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => store.subscribe(listener),
|
||||
[store]
|
||||
);
|
||||
const getSnapshot = useCallback(() => store.getSnapshot(), [store]);
|
||||
const events = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const orderedEvents = useMemo(() => [...events].reverse(), [events]);
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
dragSessionRef.current = {
|
||||
dragging: false,
|
||||
floatingSize: {
|
||||
height: rect.height,
|
||||
width: rect.width
|
||||
},
|
||||
pointerStart: {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
},
|
||||
startPosition: {
|
||||
left: rect.left,
|
||||
top: rect.top
|
||||
}
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerCurrent = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
const dragging =
|
||||
dragSession.dragging ||
|
||||
hasAnalyticsDebugFloatingDragMoved({
|
||||
pointerCurrent,
|
||||
pointerStart: dragSession.pointerStart
|
||||
});
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragSession.dragging = true;
|
||||
suppressNextClickRef.current = true;
|
||||
setPosition(
|
||||
resolveAnalyticsDebugFloatingPosition({
|
||||
floatingSize: dragSession.floatingSize,
|
||||
pointerCurrent,
|
||||
pointerStart: dragSession.pointerStart,
|
||||
startPosition: dragSession.startPosition,
|
||||
viewport: {
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handlePointerEnd = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
dragSessionRef.current = null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleClickCapture = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (!suppressNextClickRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressNextClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-4 bottom-4 z-[var(--z-dialog-popover)]"
|
||||
data-analytics-debug-floating-root="true"
|
||||
style={
|
||||
position
|
||||
? {
|
||||
bottom: "auto",
|
||||
left: position.left,
|
||||
right: "auto",
|
||||
top: position.top
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label={labels.open}
|
||||
className="relative size-11 touch-none rounded-full border border-[var(--border-1)] bg-[var(--background-fronted)] text-[var(--text-primary)] shadow-soft hover:bg-[var(--background-hover)]"
|
||||
onClickCapture={handleClickCapture}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<HealthIcon size={18} />
|
||||
{events.length > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex min-w-5 items-center justify-center rounded-full bg-[var(--accent-bg)] px-1 text-[10px] leading-5 text-[var(--accent)]">
|
||||
{events.length}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[min(420px,calc(100vw-32px))] gap-0 p-0"
|
||||
data-analytics-debug-floating-panel="true"
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
style={{ zIndex: "var(--z-dialog-popover)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-[var(--border-1)] px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium text-[var(--text-primary)]">
|
||||
{labels.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-tertiary)]">
|
||||
{labels.count(events.length)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
disabled={events.length === 0}
|
||||
onClick={() => store.clear()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<DeleteIcon size={14} />
|
||||
{labels.clear}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={labels.close}
|
||||
onClick={() => setPanelOpen(false)}
|
||||
size="icon-sm"
|
||||
title={labels.close}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CloseIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[min(420px,calc(100vh-120px))]">
|
||||
{orderedEvents.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
{labels.empty}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border-1)]">
|
||||
{orderedEvents.map((event, index) => (
|
||||
<article
|
||||
className="px-3 py-2.5"
|
||||
key={`${event.name}-${event.clientTS}-${index}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[13px] font-medium text-[var(--text-primary)]">
|
||||
{event.name}
|
||||
</div>
|
||||
<Badge className="shrink-0" variant="secondary">
|
||||
{formatEventTime(event.clientTS)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mb-1 text-[11px] text-[var(--text-tertiary)]">
|
||||
{labels.clientTimestamp(event.clientTS)}
|
||||
</div>
|
||||
<pre className="max-w-full whitespace-pre-wrap break-words rounded-md bg-[var(--transparency-block)] p-2 text-[11px] leading-4 text-[var(--text-secondary)] [overflow-wrap:anywhere]">
|
||||
{formatParams(event.params)}
|
||||
</pre>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultFormatEventTime(timestamp: number): string {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return "--:--:--";
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function formatParams(params: unknown): string {
|
||||
if (!params || typeof params !== "object") {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(params, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
hasAnalyticsDebugFloatingDragMoved,
|
||||
resolveAnalyticsDebugFloatingPosition
|
||||
} from "./floatingPosition.ts";
|
||||
|
||||
test("floating debug entry follows the pointer within viewport bounds", () => {
|
||||
assert.deepEqual(
|
||||
resolveAnalyticsDebugFloatingPosition({
|
||||
floatingSize: { height: 44, width: 44 },
|
||||
pointerCurrent: { x: 1000, y: 900 },
|
||||
pointerStart: { x: 20, y: 20 },
|
||||
startPosition: { left: 24, top: 24 },
|
||||
viewport: { height: 600, width: 800 }
|
||||
}),
|
||||
{ left: 748, top: 548 }
|
||||
);
|
||||
assert.equal(
|
||||
hasAnalyticsDebugFloatingDragMoved({
|
||||
pointerCurrent: { x: 103, y: 104 },
|
||||
pointerStart: { x: 100, y: 100 }
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const FLOATING_VIEWPORT_MARGIN = 8;
|
||||
const FLOATING_DRAG_THRESHOLD = 5;
|
||||
|
||||
export interface AnalyticsDebugPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsDebugPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsDebugSize {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface ResolveAnalyticsDebugFloatingPositionInput {
|
||||
floatingSize: AnalyticsDebugSize;
|
||||
pointerCurrent: AnalyticsDebugPoint;
|
||||
pointerStart: AnalyticsDebugPoint;
|
||||
startPosition: AnalyticsDebugPosition;
|
||||
viewport: AnalyticsDebugSize;
|
||||
}
|
||||
|
||||
export function resolveAnalyticsDebugFloatingPosition({
|
||||
floatingSize,
|
||||
pointerCurrent,
|
||||
pointerStart,
|
||||
startPosition,
|
||||
viewport
|
||||
}: ResolveAnalyticsDebugFloatingPositionInput): AnalyticsDebugPosition {
|
||||
return {
|
||||
left: clamp(
|
||||
startPosition.left + pointerCurrent.x - pointerStart.x,
|
||||
FLOATING_VIEWPORT_MARGIN,
|
||||
viewport.width - floatingSize.width - FLOATING_VIEWPORT_MARGIN
|
||||
),
|
||||
top: clamp(
|
||||
startPosition.top + pointerCurrent.y - pointerStart.y,
|
||||
FLOATING_VIEWPORT_MARGIN,
|
||||
viewport.height - floatingSize.height - FLOATING_VIEWPORT_MARGIN
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAnalyticsDebugFloatingDragMoved({
|
||||
pointerCurrent,
|
||||
pointerStart
|
||||
}: Pick<
|
||||
ResolveAnalyticsDebugFloatingPositionInput,
|
||||
"pointerCurrent" | "pointerStart"
|
||||
>): boolean {
|
||||
return (
|
||||
Math.hypot(
|
||||
pointerCurrent.x - pointerStart.x,
|
||||
pointerCurrent.y - pointerStart.y
|
||||
) > FLOATING_DRAG_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AnalyticsDebugFloatingEntry,
|
||||
type AnalyticsDebugFloatingEntryProps,
|
||||
type AnalyticsDebugLabels
|
||||
} from "./AnalyticsDebugFloatingEntry.tsx";
|
||||
export {
|
||||
hasAnalyticsDebugFloatingDragMoved,
|
||||
resolveAnalyticsDebugFloatingPosition,
|
||||
type AnalyticsDebugPoint,
|
||||
type AnalyticsDebugPosition,
|
||||
type AnalyticsDebugSize,
|
||||
type ResolveAnalyticsDebugFloatingPositionInput
|
||||
} from "./floatingPosition.ts";
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../packages/configs/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
clean: true,
|
||||
dts: true,
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"react/index": "src/react/index.ts"
|
||||
},
|
||||
external: ["react", "react-dom"],
|
||||
format: ["esm"],
|
||||
sourcemap: true
|
||||
});
|
||||
Reference in New Issue
Block a user