feat: move telemetry out of core into a usage statistics plugin

This commit is contained in:
LukeGus committed 2026-09-30 23:54:38 -05:00
1 parent f8edb5f299
commit 6217b91ae2
56 files changed
+2064 -564

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Moves the daily anonymous usage report out of Termix core. Adds platform info, feature usage counts and the list of running features, each with its own switch, a per-user opt-out, a preview of the exact report and a button to send it now.
+29
View File
@@ -0,0 +1,29 @@
# Usage Statistics
Sends a small anonymous report once a day so the Termix developers can see how many instances run and which features get used.
## What is sent
Always, while it is on:
- A random instance ID, the Termix version, and how many users and hosts exist.
Each of these has its own switch in Admin Settings > Usage Statistics:
- Platform info: operating system, CPU architecture, Node.js version, database type, and whether Termix runs in Docker, the desktop app or as a plain server.
- Feature usage: how many times each kind of tab was opened, plus SSH logins and host connections, since the last report.
- Installed features: the ids of the running built-in features.
It never sends usernames, hostnames, IP addresses, credentials or anything else that identifies you or your servers. Admins can preview the exact report before it is sent.
## Turning it off
- Admin Settings > Usage Statistics > Share anonymous usage statistics.
- Each user can leave their own feature usage out in User Profile > Usage Statistics.
- `ENABLE_TELEMETRY=false` turns it off and locks the switch. `ENABLE_TELEMETRY=true` locks it on.
## Environment
- `ENABLE_TELEMETRY`: `true` or `false` overrides the admin switch.
- `POSTHOG_API_KEY`: the PostHog project key. Defaults to the Termix project.
- `POSTHOG_HOST`: the PostHog host. Defaults to `https://us.i.posthog.com`.
+58
View File
@@ -0,0 +1,58 @@
{
"permissions": {
"manage": {
"title": "Manage usage statistics",
"description": "See what usage statistics are sent, send them now and reset the instance ID."
}
},
"settings": {
"enabled": {
"label": "Share anonymous usage statistics",
"description": "Sends a small anonymous summary once a day to help improve Termix. It never includes usernames, hostnames, IP addresses or credentials."
},
"includePlatform": {
"label": "Include platform info",
"description": "The operating system, CPU architecture, Node.js version, database type and how Termix is installed (Docker, desktop app or server)."
},
"includeFeatureUsage": {
"label": "Include feature usage",
"description": "How many times each kind of tab was opened and how many SSH logins happened since the last report. Users can turn off their own part in their profile."
},
"includePlugins": {
"label": "Include installed features",
"description": "Which built-in features are installed and running."
},
"instanceId": {
"label": "Instance ID"
},
"shareFeatureUsage": {
"label": "Include my feature usage",
"description": "Counts which kinds of tabs you open in the anonymous usage statistics. Nothing about you or your hosts is sent."
}
},
"status": {
"title": "Status",
"on": "Sharing usage statistics once a day",
"off": "Not sharing usage statistics",
"lockedOn": "Turned on by the ENABLE_TELEMETRY environment variable. Change it there.",
"lockedOff": "Turned off by the ENABLE_TELEMETRY environment variable. Change it there.",
"instanceId": "Instance ID: {{id}}",
"noInstanceId": "No instance ID yet",
"lastSent": "Last sent {{date}}",
"neverSent": "Not sent yet",
"nextSend": "Next report due {{date}}",
"lastError": "Last attempt failed: {{error}}",
"preview": "Preview report",
"sendNow": "Send now",
"sending": "Sending...",
"sent": "Usage statistics sent",
"notSent": "Usage statistics are turned off, nothing was sent",
"resetId": "Reset instance ID",
"resetIdConfirm": "Reset the instance ID? Future reports will look like a new install.",
"resetIdDone": "Instance ID reset",
"previewTitle": "Next report",
"previewDescription": "This is exactly what would be sent right now.",
"close": "Close",
"loadFailed": "Could not load the usage statistics status"
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"id": "telemetry",
"name": "Usage Statistics",
"version": "1.0.0",
"description": "Sends an anonymous daily summary of how Termix is used, to help decide what to work on next.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Monitoring",
"icon": "ChartNoAxesColumn",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:core-refs",
"network:outbound",
"network:serve",
"events:core",
"db:own",
"kv:own",
"plugins:read",
"ui:surface"
],
"contributes": {
"permissions": [
{
"name": "manage",
"titleKey": "permissions.manage.title",
"descriptionKey": "permissions.manage.description",
"defaultRoles": ["admin"]
}
],
"settings": {
"admin": [
{
"key": "status",
"type": "custom",
"component": "status",
"permission": "manage"
},
{
"key": "enabled",
"type": "boolean",
"labelKey": "settings.enabled.label",
"descriptionKey": "settings.enabled.description",
"default": true
},
{
"key": "includePlatform",
"type": "boolean",
"labelKey": "settings.includePlatform.label",
"descriptionKey": "settings.includePlatform.description",
"requires": "enabled",
"default": true
},
{
"key": "includeFeatureUsage",
"type": "boolean",
"labelKey": "settings.includeFeatureUsage.label",
"descriptionKey": "settings.includeFeatureUsage.description",
"requires": "enabled",
"default": true
},
{
"key": "includePlugins",
"type": "boolean",
"labelKey": "settings.includePlugins.label",
"descriptionKey": "settings.includePlugins.description",
"requires": "enabled",
"default": true
},
{
"key": "instanceId",
"type": "string",
"labelKey": "settings.instanceId.label",
"hidden": true,
"default": ""
}
],
"user": [
{
"key": "shareFeatureUsage",
"type": "boolean",
"labelKey": "settings.shareFeatureUsage.label",
"descriptionKey": "settings.shareFeatureUsage.description",
"default": true
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@termix-plugin/telemetry",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"drizzle-orm": "^0.45.2"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+140
View File
@@ -0,0 +1,140 @@
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { envOverride } from "./config.js";
import { usageProperty, type UsageCounts, type UsageStore } from "./usage.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db hands back the server's own drizzle handle and tables, untyped.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface TelemetrySettings {
enabled: boolean;
includePlatform: boolean;
includeFeatureUsage: boolean;
includePlugins: boolean;
instanceId: string;
}
export interface TelemetryPayload {
event: "instance_heartbeat";
distinct_id: string;
properties: Record<string, unknown>;
}
type Env = Record<string, string | undefined>;
export async function readSettings(
ctx: PluginContext,
): Promise<TelemetrySettings> {
const all = await ctx.settings.getAll("admin");
return {
enabled: all.enabled !== false,
includePlatform: all.includePlatform !== false,
includeFeatureUsage: all.includeFeatureUsage !== false,
includePlugins: all.includePlugins !== false,
instanceId: typeof all.instanceId === "string" ? all.instanceId : "",
};
}
/** The env variable decides when set, the admin switch otherwise. */
export function isEnabled(settings: TelemetrySettings, env?: Env): boolean {
return envOverride(env) ?? settings.enabled;
}
/**
* Made on first use rather than on activate, so the boot copy of a 2.8
* instance id is never shadowed by a new one.
*/
export async function ensureInstanceId(ctx: PluginContext): Promise<string> {
const existing = await ctx.settings.get<string>("instanceId");
if (existing) return existing;
const id = randomUUID();
await ctx.settings.set("instanceId", id);
return id;
}
export function detectDeployment(
env: Env = process.env,
exists: (path: string) => boolean = fs.existsSync,
): "desktop" | "docker" | "server" {
if (env.ELECTRON_EMBEDDED === "true") return "desktop";
if (exists("/.dockerenv")) return "docker";
return "server";
}
async function countRows(ctx: PluginContext): Promise<{
userCount: number;
hostCount: number;
}> {
const { users, hosts } = await ctx.db.refs<{ users: Table; hosts: Table }>();
const db = await ctx.db.client<Drizzle>();
const count = async (table: Table) => {
const rows = await db.select({ count: sql<number>`count(*)` }).from(table);
return Number(rows[0]?.count ?? 0);
};
const [userCount, hostCount] = await Promise.all([
count(users),
count(hosts),
]);
return { userCount, hostCount };
}
export interface BuiltPayload {
payload: TelemetryPayload;
/** The counts the payload carries, to subtract once it is sent. */
usage: UsageCounts;
}
export async function buildPayload(
ctx: PluginContext,
usage: UsageStore,
env: Env = process.env,
): Promise<BuiltPayload> {
const settings = await readSettings(ctx);
const distinctId = await ensureInstanceId(ctx);
const { userCount, hostCount } = await countRows(ctx);
const properties: Record<string, unknown> = {
version: env.VERSION || "unknown",
user_count: userCount,
host_count: hostCount,
};
if (settings.includePlatform) {
properties.os = process.platform;
properties.arch = process.arch;
properties.node_version = process.versions.node;
properties.db_dialect = ctx.db.dialect;
properties.deployment = detectDeployment(env);
}
let carried: UsageCounts = {};
if (settings.includeFeatureUsage) {
carried = await usage.read();
for (const [name, count] of Object.entries(carried)) {
properties[usageProperty(name)] = count;
}
}
if (settings.includePlugins) {
const running = (await ctx.plugins.list())
.filter((plugin) => plugin.state === "active")
.map((plugin) => plugin.id)
.sort();
properties.plugins_enabled = running;
properties.plugin_count = running.length;
}
return {
payload: {
event: "instance_heartbeat",
distinct_id: distinctId,
properties,
},
usage: carried,
};
}
+26
View File
@@ -0,0 +1,26 @@
/** Public project key. Only lets a client send events, never read them. */
export const DEFAULT_POSTHOG_API_KEY =
"phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd";
export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
type Env = Record<string, string | undefined>;
/** ENABLE_TELEMETRY wins over the admin setting when set. */
export function envOverride(env: Env = process.env): boolean | null {
const value = env.ENABLE_TELEMETRY?.trim().toLowerCase();
if (!value) return null;
return value === "true";
}
export function posthogConfig(env: Env = process.env): {
apiKey: string;
host: string;
} {
return {
apiKey: env.POSTHOG_API_KEY?.trim() || DEFAULT_POSTHOG_API_KEY,
host: (env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST).replace(
/\/+$/,
"",
),
};
}
+65
View File
@@ -0,0 +1,65 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { envOverride } from "./config.js";
import { isEnabled, readSettings } from "./collect.js";
import { CHECK_INTERVAL_MS, createReporter } from "./reporter.js";
import { registerRoutes } from "./routes.js";
import { createUsageStore } from "./usage.js";
/** First check a few minutes after boot, once core's boot copies ran. */
const FIRST_CHECK_DELAY_MS = 3 * 60 * 1000;
type Env = Record<string, string | undefined>;
export async function activateWith(ctx: PluginContext, env: Env) {
const usage = createUsageStore(ctx.kv);
const reporter = createReporter(ctx, usage, env);
const check = async () => {
try {
await reporter.send();
} catch {
// Logged by the reporter; the next check tries again.
}
};
ctx.schedule.after(FIRST_CHECK_DELAY_MS, check);
ctx.schedule.every(CHECK_INTERVAL_MS, check);
const countEvent = (feature: string) => async () => {
const settings = await readSettings(ctx);
if (isEnabled(settings, env) && settings.includeFeatureUsage) {
await usage.add({ [feature]: 1 });
}
};
const onLogin = countEvent("ssh_login");
const onConnected = countEvent("host_connected");
ctx.events.on("host.login", () => void onLogin().catch(() => undefined));
ctx.events.on("host.session.status", (payload) => {
if ((payload as { online?: boolean } | null)?.online) {
void onConnected().catch(() => undefined);
}
});
ctx.settings.onValidate("admin", async (values) => {
const locked = envOverride(env);
if (locked === null || values.enabled === undefined) return;
const stored = (await ctx.settings.get<boolean>("enabled")) !== false;
if (values.enabled !== stored) {
return {
enabled:
"This is set by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
};
}
});
registerRoutes(ctx, ctx.http.router<Router>(), reporter, usage, env);
return { reporter, usage };
}
export async function activate(ctx: PluginContext) {
await activateWith(ctx, process.env);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+117
View File
@@ -0,0 +1,117 @@
import { randomUUID } from "node:crypto";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { envOverride, posthogConfig } from "./config.js";
import { buildPayload, isEnabled, readSettings } from "./collect.js";
import type { UsageStore } from "./usage.js";
export const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Checked hourly, so a restart never skips or doubles a day. */
export const CHECK_INTERVAL_MS = 60 * 60 * 1000;
const STATE_KEY = "state";
export interface ReporterState {
lastSentAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
}
export type SendResult =
{ sent: true } | { sent: false; reason: "disabled" | "not-due" };
type Env = Record<string, string | undefined>;
export function createReporter(
ctx: PluginContext,
usage: UsageStore,
env: Env = process.env,
now: () => number = Date.now,
) {
async function state(): Promise<ReporterState> {
const stored = (await ctx.kv.get(STATE_KEY)) as
Partial<ReporterState> | undefined;
return {
lastSentAt: stored?.lastSentAt ?? null,
lastAttemptAt: stored?.lastAttemptAt ?? null,
lastError: stored?.lastError ?? null,
};
}
async function saveState(patch: Partial<ReporterState>) {
await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch });
}
async function nextDueAt(): Promise<string | null> {
const { lastSentAt } = await state();
if (!lastSentAt) return null;
return new Date(Date.parse(lastSentAt) + REPORT_INTERVAL_MS).toISOString();
}
async function send(options: { force?: boolean } = {}): Promise<SendResult> {
if (!isEnabled(await readSettings(ctx), env)) {
return { sent: false, reason: "disabled" };
}
if (!options.force) {
const due = await nextDueAt();
if (due && Date.parse(due) > now()) {
return { sent: false, reason: "not-due" };
}
}
const attemptAt = new Date(now()).toISOString();
const { apiKey, host } = posthogConfig(env);
try {
const { payload, usage: carried } = await buildPayload(ctx, usage, env);
const response = await ctx.fetch(`${host}/capture/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey, ...payload }),
timeoutMs: 10_000,
});
if (!response.ok) {
throw new Error(`PostHog answered ${response.status}`);
}
await usage.subtract(carried);
await saveState({
lastSentAt: attemptAt,
lastAttemptAt: attemptAt,
lastError: null,
});
ctx.log.info("Sent daily usage statistics");
return { sent: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await saveState({ lastAttemptAt: attemptAt, lastError: message });
ctx.log.warn(`Could not send usage statistics: ${message}`);
throw error;
}
}
return {
state,
send,
async status() {
const settings = await readSettings(ctx);
return {
enabled: isEnabled(settings, env),
locked: envOverride(env) !== null,
instanceId: settings.instanceId || null,
nextDueAt: await nextDueAt(),
...(await state()),
};
},
async preview() {
return (await buildPayload(ctx, usage, env)).payload;
},
async resetInstanceId(): Promise<string> {
const id = randomUUID();
await ctx.settings.set("instanceId", id);
return id;
},
};
}
export type Reporter = ReturnType<typeof createReporter>;
+148
View File
@@ -0,0 +1,148 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { isEnabled, readSettings } from "./collect.js";
import type { Reporter } from "./reporter.js";
import { sanitizeUsage, type UsageStore } from "./usage.js";
/** Whether the signed-in user's feature usage should be counted. */
export async function shouldTrackUser(
ctx: PluginContext,
userId: string | undefined,
env?: Record<string, string | undefined>,
): Promise<boolean> {
if (!userId) return false;
const settings = await readSettings(ctx);
if (!isEnabled(settings, env) || !settings.includeFeatureUsage) return false;
return (await ctx.settings.getUser(userId, "shareFeatureUsage")) !== false;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
reporter: Reporter,
usage: UsageStore,
env?: Record<string, string | undefined>,
): void {
const manage = ctx.rbac.require("manage") as unknown as RequestHandler;
/**
* @openapi
* /plugin-api/telemetry/status:
* get:
* summary: Usage statistics status
* description: Whether usage statistics are sent, whether ENABLE_TELEMETRY locks that, the instance ID and the last attempt.
* tags: [Usage Statistics]
* responses:
* 200:
* description: Status.
* 403:
* description: Missing the telemetry.manage permission.
*/
router.get("/status", manage, async (_req: Request, res: Response) => {
res.json(await reporter.status());
});
/**
* @openapi
* /plugin-api/telemetry/preview:
* get:
* summary: Preview the next report
* description: The exact event that would be sent right now, without the API key.
* tags: [Usage Statistics]
* responses:
* 200:
* description: The event.
* 403:
* description: Missing the telemetry.manage permission.
*/
router.get("/preview", manage, async (_req: Request, res: Response) => {
res.json(await reporter.preview());
});
/**
* @openapi
* /plugin-api/telemetry/send:
* post:
* summary: Send usage statistics now
* description: Sends the report straight away. Does nothing while usage statistics are turned off.
* tags: [Usage Statistics]
* responses:
* 200:
* description: Whether a report was sent.
* 403:
* description: Missing the telemetry.manage permission.
* 502:
* description: The report could not be delivered.
*/
router.post("/send", manage, async (_req: Request, res: Response) => {
try {
res.json(await reporter.send({ force: true }));
} catch (error) {
res.status(502).json({
error: error instanceof Error ? error.message : String(error),
});
}
});
/**
* @openapi
* /plugin-api/telemetry/reset-id:
* post:
* summary: Reset the instance ID
* description: Picks a new random instance ID, so later reports look like a new install.
* tags: [Usage Statistics]
* responses:
* 200:
* description: The new ID.
* 403:
* description: Missing the telemetry.manage permission.
*/
router.post("/reset-id", manage, async (_req: Request, res: Response) => {
res.json({ instanceId: await reporter.resetInstanceId() });
});
/**
* @openapi
* /plugin-api/telemetry/usage/config:
* get:
* summary: Whether to count this user's feature usage
* description: False when usage statistics or feature usage are off, or the user turned off their own part.
* tags: [Usage Statistics]
* responses:
* 200:
* description: "{ track: boolean }"
*/
router.get("/usage/config", async (_req: Request, res: Response) => {
res.json({ track: await shouldTrackUser(ctx, ctx.currentActor(), env) });
});
/**
* @openapi
* /plugin-api/telemetry/usage:
* post:
* summary: Add feature usage counts
* description: Counts from the signed-in user's browser, added to the next report. Ignored when this user is not counted. Names and counts are checked and capped.
* tags: [Usage Statistics]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* features:
* type: object
* additionalProperties:
* type: integer
* responses:
* 204:
* description: Recorded or ignored.
*/
router.post("/usage", async (req: Request, res: Response) => {
if (await shouldTrackUser(ctx, ctx.currentActor(), env)) {
const counts = sanitizeUsage(req.body?.features);
if (Object.keys(counts).length > 0) await usage.add(counts);
}
res.status(204).end();
});
}
+75
View File
@@ -0,0 +1,75 @@
import type { PluginKeyValue } from "@termix/plugin-sdk/backend";
const USAGE_KEY = "usage";
const FEATURE_NAME = /^[a-z][a-z0-9_.-]{0,63}$/;
export const MAX_FEATURES = 100;
const MAX_COUNT = 100_000;
export type UsageCounts = Record<string, number>;
/** Keeps valid names with positive whole counts, capped. */
export function sanitizeUsage(input: unknown): UsageCounts {
const clean: UsageCounts = {};
if (!input || typeof input !== "object" || Array.isArray(input)) {
return clean;
}
for (const [name, raw] of Object.entries(input)) {
if (!FEATURE_NAME.test(name)) continue;
const count = Math.floor(Number(raw));
if (!Number.isFinite(count) || count <= 0) continue;
clean[name] = Math.min(count, MAX_COUNT);
if (Object.keys(clean).length >= MAX_FEATURES) break;
}
return clean;
}
export function createUsageStore(kv: PluginKeyValue) {
// One write at a time, so two requests never drop each other's counts.
let queue: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const next = queue.then(fn, fn);
queue = next.catch(() => undefined);
return next;
};
const read = async (): Promise<UsageCounts> =>
sanitizeUsage(await kv.get(USAGE_KEY));
return {
read,
add: (counts: UsageCounts) =>
serial(async () => {
const current = await read();
for (const [name, count] of Object.entries(sanitizeUsage(counts))) {
if (
!(name in current) &&
Object.keys(current).length >= MAX_FEATURES
) {
continue;
}
current[name] = Math.min((current[name] ?? 0) + count, MAX_COUNT);
}
await kv.set(USAGE_KEY, current);
}),
/** Drops what a successful report already carried. */
subtract: (sent: UsageCounts) =>
serial(async () => {
const current = await read();
for (const [name, count] of Object.entries(sent)) {
const left = (current[name] ?? 0) - count;
if (left > 0) current[name] = left;
else delete current[name];
}
await kv.set(USAGE_KEY, current);
}),
};
}
export type UsageStore = ReturnType<typeof createUsageStore>;
/** PostHog property name for a feature: used_tab_terminal. */
export function usageProperty(name: string): string {
return `used_${name.replace(/[.-]/g, "_")}`;
}
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
usePluginApi,
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
useConfirmation,
} from "@termix/plugin-sdk/ui";
export interface TelemetryStatus {
enabled: boolean;
locked: boolean;
instanceId: string | null;
lastSentAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
nextDueAt: string | null;
}
function errorMessage(error: unknown): string {
const data = (error as { response?: { data?: { error?: string } } })?.response
?.data;
if (data?.error) return data.error;
return error instanceof Error ? error.message : String(error);
}
export function TelemetryStatusSetting({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const api = usePluginApi();
const { confirmWithToast } = useConfirmation();
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [sending, setSending] = useState(false);
const [preview, setPreview] = useState<unknown>(null);
const load = useCallback(async () => {
try {
const { data } = await api.get<TelemetryStatus>("/status");
setStatus(data);
} catch {
setStatus(null);
toast.error(t("status.loadFailed"));
}
}, [api, t]);
useEffect(() => {
if (running) void load();
}, [running, load]);
async function sendNow() {
setSending(true);
try {
const { data } = await api.post<{ sent: boolean }>("/send");
if (data.sent) toast.success(t("status.sent"));
else toast.info(t("status.notSent"));
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSending(false);
await load();
}
}
async function showPreview() {
try {
const { data } = await api.get("/preview");
setPreview(data);
} catch (error) {
toast.error(errorMessage(error));
}
}
function resetId() {
void confirmWithToast(t("status.resetIdConfirm"), async () => {
try {
await api.post("/reset-id");
toast.success(t("status.resetIdDone"));
await load();
} catch (error) {
toast.error(errorMessage(error));
}
});
}
let summary = t("status.off");
if (status?.locked) {
summary = status.enabled ? t("status.lockedOn") : t("status.lockedOff");
} else if (status?.enabled) {
summary = t("status.on");
}
const muted = "text-[10px] text-muted-foreground";
return (
<div className="flex flex-col gap-2 border border-border bg-background/50 p-2">
<span className="text-xs font-medium">{t("status.title")}</span>
{status && (
<>
<span className={muted}>{summary}</span>
<span className={`${muted} break-all`}>
{status.instanceId
? t("status.instanceId", { id: status.instanceId })
: t("status.noInstanceId")}
</span>
<span className={muted}>
{status.lastSentAt
? t("status.lastSent", {
date: new Date(status.lastSentAt).toLocaleString(),
})
: t("status.neverSent")}
</span>
{status.enabled && status.nextDueAt && (
<span className={muted}>
{t("status.nextSend", {
date: new Date(status.nextDueAt).toLocaleString(),
})}
</span>
)}
{status.lastError && (
<span className="text-[10px] text-destructive">
{t("status.lastError", { error: status.lastError })}
</span>
)}
</>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
disabled={!running}
onClick={showPreview}
>
{t("status.preview")}
</Button>
<Button
type="button"
variant="outline"
className="rounded-none"
disabled={!running || sending || !status?.enabled}
onClick={sendNow}
>
{sending ? t("status.sending") : t("status.sendNow")}
</Button>
<Button
type="button"
variant="outline"
className="rounded-none"
disabled={!running}
onClick={resetId}
>
{t("status.resetId")}
</Button>
</div>
<Dialog
open={preview !== null}
onOpenChange={(open) => !open && setPreview(null)}
>
<DialogContent className="rounded-none sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("status.previewTitle")}</DialogTitle>
<DialogDescription>
{t("status.previewDescription")}
</DialogDescription>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto border border-border bg-muted/40 p-2 text-[11px]">
{JSON.stringify(preview, null, 2)}
</pre>
<DialogFooter>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => setPreview(null)}
>
{t("status.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { TelemetryStatusSetting } from "./TelemetryStatusSetting";
import { startTabTracker } from "./tracker";
export function activate(app: TermixApp): void {
app.registerSettingsComponent("status", TelemetryStatusSetting);
if (!app.guest) app.onDispose(startTabTracker(app));
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
+109
View File
@@ -0,0 +1,109 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
export const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
type Counts = Record<string, number>;
/** Open tabs per type. Slot ids change on every read, so only types count. */
export function countTabTypes(layout: unknown): Counts {
const tabs = (layout as { tabs?: unknown } | null)?.tabs;
const counts: Counts = {};
if (!Array.isArray(tabs)) return counts;
for (const tab of tabs) {
const type = (tab as { type?: unknown })?.type;
if (typeof type === "string" && type)
counts[type] = (counts[type] ?? 0) + 1;
}
return counts;
}
/** Tabs opened between two snapshots, keyed as tab.<type>. */
export function openedSince(before: Counts, after: Counts): Counts {
const opened: Counts = {};
for (const [type, count] of Object.entries(after)) {
const added = count - (before[type] ?? 0);
if (added > 0) opened[`tab.${type}`] = added;
}
return opened;
}
/**
* Counts which kinds of tabs the user opens and sends the totals to the
* plugin's backend every few minutes. Does nothing unless the backend says
* this user is counted.
*/
export function startTabTracker(
app: TermixApp,
flushMs: number = FLUSH_INTERVAL_MS,
): () => void {
let track = false;
let ready = false;
let previous: Counts = {};
let pending: Counts = {};
const snapshot = () => countTabTypes(app.tabs.getLayout());
const refreshConfig = async () => {
try {
const { data } = await app.api.get<{ track?: boolean }>("/usage/config");
track = data?.track === true;
} catch {
track = false;
}
if (!track) pending = {};
};
const flush = (keepalive = false) => {
if (!track || Object.keys(pending).length === 0) return;
const features = pending;
pending = {};
void app
.fetch("/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ features }),
keepalive,
})
.catch(() => undefined);
};
const disposers: Array<() => void> = [];
disposers.push(
app.tabs.onReady(() => {
// Tabs restored after login were opened in an earlier session.
previous = snapshot();
ready = true;
}),
);
disposers.push(
app.tabs.onChange(() => {
const current = snapshot();
if (ready && track) {
for (const [name, count] of Object.entries(
openedSince(previous, current),
)) {
pending[name] = (pending[name] ?? 0) + count;
}
}
previous = current;
}),
);
disposers.push(app.onSettingsChanged(() => void refreshConfig()));
const timer = setInterval(() => flush(), flushMs);
disposers.push(() => clearInterval(timer));
const onPageHide = () => flush(true);
window.addEventListener("pagehide", onPageHide);
disposers.push(() => window.removeEventListener("pagehide", onPageHide));
void refreshConfig();
return () => {
flush(true);
for (const dispose of disposers) dispose();
};
}
@@ -0,0 +1,378 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activateWith } from "../../src/backend/index.js";
import {
DEFAULT_POSTHOG_API_KEY,
envOverride,
posthogConfig,
} from "../../src/backend/config.js";
import { detectDeployment } from "../../src/backend/collect.js";
import {
MAX_FEATURES,
sanitizeUsage,
usageProperty,
} from "../../src/backend/usage.js";
import { CHECK_INTERVAL_MS } from "../../src/backend/reporter.js";
const manifest = manifestJson as unknown as PluginManifest;
const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
interface Sent {
url: string;
body: Record<string, unknown> & { properties: Record<string, unknown> };
}
let cleanup: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const fn of cleanup) await fn();
cleanup = [];
});
async function setup(
options: {
settings?: Record<string, unknown>;
env?: Record<string, string | undefined>;
permissions?: string[];
status?: number;
} = {},
) {
const db: TestDb = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("u1", "u1");
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("u2", "u2");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(1);
const sent: Sent[] = [];
let router: Router | null = null;
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
settings: options.settings,
permissions: options.permissions ?? ["manage"],
installedPlugins: [
{ id: "telemetry", version: "1.0.0", source: "bundled", state: "active" },
{ id: "docker", version: "1.0.0", source: "bundled", state: "active" },
{ id: "serial", version: "1.0.0", source: "bundled", state: "stopped" },
],
fetch: async (url, init) => {
sent.push({ url, body: JSON.parse(String(init?.body)) });
return new Response("{}", { status: options.status ?? 200 });
},
});
mock.setActor("u1");
const env = { VERSION: "2.9.0", ...options.env };
const { reporter, usage } = await activateWith(mock.ctx, env);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? "u1");
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
cleanup.push(
() => new Promise<void>((resolve) => server.close(() => resolve())),
() => db.close(),
);
const call = async (
method: string,
path: string,
body?: unknown,
user = "u1",
) => {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
};
return { mock, reporter, usage, sent, call };
}
describe("config", () => {
it("reads ENABLE_TELEMETRY as a lock only when set", () => {
expect(envOverride({})).toBeNull();
expect(envOverride({ ENABLE_TELEMETRY: " " })).toBeNull();
expect(envOverride({ ENABLE_TELEMETRY: "TRUE" })).toBe(true);
expect(envOverride({ ENABLE_TELEMETRY: "false" })).toBe(false);
expect(envOverride({ ENABLE_TELEMETRY: "no" })).toBe(false);
});
it("uses the embedded key unless the env sets one", () => {
expect(posthogConfig({})).toEqual({
apiKey: DEFAULT_POSTHOG_API_KEY,
host: "https://us.i.posthog.com",
});
expect(
posthogConfig({
POSTHOG_API_KEY: "k",
POSTHOG_HOST: "https://ph.local/",
}),
).toEqual({ apiKey: "k", host: "https://ph.local" });
});
it("detects how Termix runs", () => {
expect(detectDeployment({ ELECTRON_EMBEDDED: "true" }, () => true)).toBe(
"desktop",
);
expect(detectDeployment({}, () => true)).toBe("docker");
expect(detectDeployment({}, () => false)).toBe("server");
});
});
describe("usage counts", () => {
it("keeps valid names and positive whole counts", () => {
expect(
sanitizeUsage({
"tab.terminal": 2.7,
"Bad Name": 1,
zero: 0,
neg: -3,
nan: "x",
big: 1e9,
}),
).toEqual({ "tab.terminal": 2, big: 100_000 });
expect(sanitizeUsage(null)).toEqual({});
expect(sanitizeUsage([1])).toEqual({});
});
it("caps the number of features", () => {
const many = Object.fromEntries(
Array.from({ length: MAX_FEATURES + 20 }, (_, i) => [`f${i}`, 1]),
);
expect(Object.keys(sanitizeUsage(many))).toHaveLength(MAX_FEATURES);
});
it("maps a feature to a property name", () => {
expect(usageProperty("tab.file-manager")).toBe("used_tab_file_manager");
});
});
describe("reporting", () => {
it("sends a full report with every section on", async () => {
const { mock, usage, sent } = await setup();
await usage.add({ "tab.terminal": 3 });
mock.ctx.events.emit("host.login", { hostId: 1 });
mock.ctx.events.emit("host.session.status", { hostId: 1, online: true });
mock.ctx.events.emit("host.session.status", { hostId: 1, online: false });
await vi.waitFor(async () =>
expect(await usage.read()).toMatchObject({
ssh_login: 1,
host_connected: 1,
}),
);
await mock.runScheduled();
expect(sent).toHaveLength(1);
const { url, body } = sent[0];
expect(url).toBe("https://us.i.posthog.com/capture/");
expect(body.api_key).toBe(DEFAULT_POSTHOG_API_KEY);
expect(body.event).toBe("instance_heartbeat");
expect(typeof body.distinct_id).toBe("string");
expect(body.properties).toMatchObject({
version: "2.9.0",
user_count: 2,
host_count: 1,
os: process.platform,
arch: process.arch,
db_dialect: "sqlite",
used_tab_terminal: 3,
used_ssh_login: 1,
used_host_connected: 1,
plugins_enabled: ["docker", "telemetry"],
plugin_count: 2,
});
expect(await usage.read()).toEqual({});
});
it("checks hourly and sends at most once a day", async () => {
const { mock, sent } = await setup();
expect(mock.scheduled.find((job) => job.kind === "every")?.ms).toBe(
CHECK_INTERVAL_MS,
);
await mock.runScheduled();
await mock.runScheduled();
expect(sent).toHaveLength(1);
});
it("leaves out each section when its switch is off", async () => {
const { mock, usage, sent } = await setup({
settings: {
includePlatform: false,
includeFeatureUsage: false,
includePlugins: false,
},
});
await usage.add({ "tab.terminal": 1 });
await mock.runScheduled();
expect(Object.keys(sent[0].body.properties).sort()).toEqual([
"host_count",
"user_count",
"version",
]);
expect(await usage.read()).toEqual({ "tab.terminal": 1 });
});
it("sends nothing and counts nothing while turned off", async () => {
const { mock, usage, sent } = await setup({ settings: { enabled: false } });
mock.ctx.events.emit("host.login", { hostId: 1 });
await mock.runScheduled();
expect(sent).toEqual([]);
expect(await usage.read()).toEqual({});
});
it("lets ENABLE_TELEMETRY win over the admin switch", async () => {
const off = await setup({ env: { ENABLE_TELEMETRY: "false" } });
await off.mock.runScheduled();
expect(off.sent).toEqual([]);
const on = await setup({
settings: { enabled: false },
env: { ENABLE_TELEMETRY: "true" },
});
await on.mock.runScheduled();
expect(on.sent).toHaveLength(1);
});
it("keeps the 2.8 instance id", async () => {
const { mock, sent } = await setup({
settings: { instanceId: "legacy-id" },
});
await mock.runScheduled();
expect(sent[0].body.distinct_id).toBe("legacy-id");
});
it("records a failed send and keeps the counts", async () => {
const { mock, reporter, usage, sent } = await setup({ status: 500 });
await usage.add({ "tab.terminal": 1 });
await mock.runScheduled();
expect(sent.length).toBeGreaterThan(0);
const state = await reporter.state();
expect(state.lastError).toBe("PostHog answered 500");
expect(state.lastSentAt).toBeNull();
expect(await usage.read()).toEqual({ "tab.terminal": 1 });
});
it("refuses a change to the switch while ENABLE_TELEMETRY locks it", async () => {
const locked = await setup({ env: { ENABLE_TELEMETRY: "true" } });
expect(
await locked.mock.validateSettings("admin", { enabled: false }),
).toHaveProperty("enabled");
expect(
await locked.mock.validateSettings("admin", { enabled: true }),
).not.toHaveProperty("enabled");
const free = await setup();
expect(
await free.mock.validateSettings("admin", { enabled: false }),
).not.toHaveProperty("enabled");
});
});
describe("routes", () => {
it("serves status, preview, send and reset to a manager", async () => {
const { call, sent } = await setup();
const preview = await call("GET", "/preview");
expect(preview.status).toBe(200);
expect(preview.body.event).toBe("instance_heartbeat");
expect(preview.body).not.toHaveProperty("api_key");
expect(sent).toEqual([]);
expect((await call("POST", "/send")).body).toEqual({ sent: true });
const status = await call("GET", "/status");
expect(status.body).toMatchObject({
enabled: true,
locked: false,
lastError: null,
});
expect(status.body.lastSentAt).toBeTruthy();
expect(status.body.nextDueAt).toBeTruthy();
const before = status.body.instanceId;
const reset = await call("POST", "/reset-id");
expect(reset.body.instanceId).not.toBe(before);
});
it("answers 502 when the report cannot be delivered", async () => {
const { call } = await setup({ status: 503 });
const response = await call("POST", "/send");
expect(response.status).toBe(502);
expect(response.body.error).toBe("PostHog answered 503");
});
it("keeps the admin routes from users without manage", async () => {
const { call } = await setup({ permissions: [] });
for (const [method, path] of [
["GET", "/status"],
["GET", "/preview"],
["POST", "/send"],
["POST", "/reset-id"],
]) {
expect((await call(method, path)).status).toBe(403);
}
expect((await call("GET", "/usage/config")).status).toBe(200);
});
it("records usage only for users who are counted", async () => {
const { mock, call, usage } = await setup({ permissions: [] });
expect((await call("GET", "/usage/config")).body).toEqual({ track: true });
await call("POST", "/usage", {
features: { "tab.terminal": 2, "BAD!": 5 },
});
expect(await usage.read()).toEqual({ "tab.terminal": 2 });
await mock.ctx.settings.setUser("u2", "shareFeatureUsage", false);
expect((await call("GET", "/usage/config", undefined, "u2")).body).toEqual({
track: false,
});
const ignored = await call(
"POST",
"/usage",
{ features: { "tab.terminal": 9 } },
"u2",
);
expect(ignored.status).toBe(204);
expect(await usage.read()).toEqual({ "tab.terminal": 2 });
});
it("counts nobody while feature usage is off", async () => {
const { call, usage } = await setup({
settings: { includeFeatureUsage: false },
});
expect((await call("GET", "/usage/config")).body).toEqual({ track: false });
await call("POST", "/usage", { features: { "tab.terminal": 1 } });
expect(await usage.read()).toEqual({});
});
});
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import {
countTabTypes,
openedSince,
startTabTracker,
} from "../../src/frontend/tracker";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const api = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) =>
values ? `${key} ${Object.values(values).join(" ")}` : key,
}),
usePluginApi: () => api,
}));
const { TelemetryStatusSetting } =
await import("../../src/frontend/TelemetryStatusSetting");
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
api.get.mockReset();
api.post.mockReset();
vi.useRealTimers();
});
function fakeApp(track: boolean) {
let layout: unknown = { version: 1, tabs: [] };
let onChange: () => void = () => undefined;
const posts: unknown[] = [];
const app = {
api: {
get: vi.fn(async () => ({ data: { track } })),
},
fetch: vi.fn(async (_path: string, init?: RequestInit) => {
posts.push(JSON.parse(String(init?.body)));
return new Response(null, { status: 204 });
}),
tabs: {
getLayout: () => layout,
onReady: (listener: () => void) => {
listener();
return () => undefined;
},
onChange: (listener: () => void) => {
onChange = listener;
return () => undefined;
},
},
onSettingsChanged: () => () => undefined,
} as unknown as TermixApp;
return {
app,
posts,
setTabs(types: string[]) {
layout = { version: 1, tabs: types.map((type) => ({ type })) };
onChange();
},
};
}
describe("tab counting", () => {
it("counts tabs per type and the ones opened since", () => {
const before = countTabTypes({
tabs: [{ type: "terminal" }, { type: "terminal" }, { type: "docker" }],
});
expect(before).toEqual({ terminal: 2, docker: 1 });
expect(countTabTypes(null)).toEqual({});
expect(
openedSince(before, { terminal: 3, docker: 0, "file-manager": 1 }),
).toEqual({ "tab.terminal": 1, "tab.file-manager": 1 });
});
});
describe("startTabTracker", () => {
it("sends newly opened tabs on the timer", async () => {
vi.useFakeTimers();
const fake = fakeApp(true);
const stop = startTabTracker(fake.app, 1000);
await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled());
await Promise.resolve();
fake.setTabs(["terminal"]);
fake.setTabs(["terminal", "terminal", "docker"]);
fake.setTabs(["docker"]);
await vi.advanceTimersByTimeAsync(1000);
expect(fake.posts).toEqual([
{ features: { "tab.terminal": 2, "tab.docker": 1 } },
]);
await vi.advanceTimersByTimeAsync(1000);
expect(fake.posts).toHaveLength(1);
stop();
});
it("sends nothing for a user who is not counted", async () => {
vi.useFakeTimers();
const fake = fakeApp(false);
const stop = startTabTracker(fake.app, 1000);
await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled());
fake.setTabs(["terminal"]);
await vi.advanceTimersByTimeAsync(1000);
stop();
expect(fake.posts).toEqual([]);
});
});
describe("telemetry activate", () => {
it("registers the status component and removes it on deactivate", async () => {
api.get.mockResolvedValue({ data: { track: false } });
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
expect(rendered.registered.settingsComponents()).toEqual(["status"]);
await rendered.deactivate();
expect(rendered.registered.settingsComponents()).toEqual([]);
rendered = null;
});
});
describe("TelemetryStatusSetting", () => {
const props = {
pluginId: "telemetry",
values: {},
setValue: () => undefined,
running: true,
};
it("shows the state, instance id and last error", async () => {
api.get.mockResolvedValue({
data: {
enabled: true,
locked: false,
instanceId: "abc-123",
lastSentAt: null,
lastAttemptAt: "2026-09-30T00:00:00.000Z",
lastError: "PostHog answered 500",
nextDueAt: null,
},
});
render(<TelemetryStatusSetting {...props} />);
await waitFor(() => expect(screen.getByText("status.on")).toBeTruthy());
expect(screen.getByText("status.instanceId abc-123")).toBeTruthy();
expect(screen.getByText("status.neverSent")).toBeTruthy();
expect(
screen.getByText("status.lastError PostHog answered 500"),
).toBeTruthy();
});
it("says when ENABLE_TELEMETRY locks it off", async () => {
api.get.mockResolvedValue({
data: {
enabled: false,
locked: true,
instanceId: null,
lastSentAt: null,
lastAttemptAt: null,
lastError: null,
nextDueAt: null,
},
});
render(<TelemetryStatusSetting {...props} />);
await waitFor(() =>
expect(screen.getByText("status.lockedOff")).toBeTruthy(),
);
expect(
(screen.getByText("status.sendNow") as HTMLButtonElement).disabled,
).toBe(true);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);