mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move telemetry out of core into a usage statistics plugin
This commit is contained in:
1 parent
f8edb5f299
commit
6217b91ae2
56 files changed
+2064
-564
No files matched your search
@@ -11,7 +11,6 @@ import {
|
||||
AUDIT_FORWARD_URL_ENV,
|
||||
AUDIT_FORWARD_URL_SETTING,
|
||||
} from "../../utils/audit-forwarder.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import {
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
parseNotificationAllowlist,
|
||||
@@ -193,117 +192,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* get:
|
||||
* summary: Get analytics enabled setting
|
||||
* description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Analytics enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* locked:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const override = getTelemetryEnvOverride();
|
||||
if (override !== null) {
|
||||
return res.json({ enabled: override, locked: true });
|
||||
}
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
),
|
||||
locked: false,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* patch:
|
||||
* summary: Update analytics enabled setting (admin only)
|
||||
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 409:
|
||||
* description: Setting is locked by the ENABLE_TELEMETRY environment variable.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
if (getTelemetryEnvOverride() !== null) {
|
||||
return res.status(409).json({
|
||||
error: "Telemetry is locked by the ENABLE_TELEMETRY env variable",
|
||||
});
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"analytics_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_analytics_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/audit-forwarding:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* ctx.plugins: which plugins are installed and whether each runs, under
|
||||
* plugins:read. Ids and versions only, nothing operational.
|
||||
*/
|
||||
|
||||
import type { PluginPlugins } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
|
||||
export function createPluginPlugins(manifest: PluginManifest): PluginPlugins {
|
||||
return {
|
||||
list: async () => {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"plugins:read",
|
||||
manifest.capabilities,
|
||||
);
|
||||
const { getPluginRuntime } = await import("./index.js");
|
||||
return getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
state: plugin.state,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -945,6 +946,7 @@ export function createPluginContext(
|
||||
}),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
plugins: createPluginPlugins(manifest),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
|
||||
@@ -274,9 +274,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
|
||||
startAnalyticsHeartbeat();
|
||||
|
||||
// After plugins, so their sync entities are registered before a pass.
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
try {
|
||||
|
||||
@@ -23,9 +23,6 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/analytics.js", () => ({
|
||||
getTelemetryEnvOverride: () => null,
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
|
||||
BIN
Binary file not shown.
+2
@@ -901,6 +901,8 @@ export const ROWS: Record<string, Row[]> = {
|
||||
["terminal_session_persistence_enabled", "false"],
|
||||
["terminal_image_max_count", "7"],
|
||||
["session_sharing_globally_enabled", "false"],
|
||||
["analytics_enabled", "false"],
|
||||
["analytics_instance_id", "d4-instance-id"],
|
||||
// Long enough that the fixture's recordings never age out.
|
||||
["session_recording_retention_days", "3650"],
|
||||
].map(([key, value]) => ({ key, value })),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
list: () => [
|
||||
{
|
||||
id: "alpha",
|
||||
source: "bundled",
|
||||
state: "active",
|
||||
manifest: { version: "1.2.0" },
|
||||
dir: "/secret/path",
|
||||
lastError: "boom",
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
source: "user",
|
||||
state: "stopped",
|
||||
manifest: { version: "0.1.0" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginPlugins } = await import("../../plugins/ctx-plugins.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const manifest = (capabilities: string[]) =>
|
||||
({ id: "reader", capabilities }) as unknown as PluginManifest;
|
||||
|
||||
describe("ctx.plugins", () => {
|
||||
it("lists id, version, source and state only", async () => {
|
||||
const plugins = createPluginPlugins(manifest(["plugins:read"]));
|
||||
expect(await plugins.list()).toEqual([
|
||||
{ id: "alpha", version: "1.2.0", source: "bundled", state: "active" },
|
||||
{ id: "beta", version: "0.1.0", source: "user", state: "stopped" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses without plugins:read", async () => {
|
||||
const plugins = createPluginPlugins(manifest([]));
|
||||
await expect(plugins.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -543,6 +543,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
|
||||
globallyEnabled: false,
|
||||
});
|
||||
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
|
||||
enabled: false,
|
||||
instanceId: "d4-instance-id",
|
||||
});
|
||||
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
|
||||
domain: "termix.d4.example",
|
||||
email: "d4@example.com",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The telemetry settings migration: an instance that turned telemetry off in
|
||||
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (pluginId: string, key: string) =>
|
||||
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) => (pluginInstalled ? { id } : null),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
|
||||
find(pluginId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
_scope: string,
|
||||
_id: null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, key);
|
||||
if (existing) existing.value = value;
|
||||
else settingsRows.push({ pluginId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTelemetrySettingsMigration } =
|
||||
await import("../../upgrade/telemetry-settings-migration.js");
|
||||
|
||||
const stored = (key: string) => {
|
||||
const row = find("telemetry", key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
describe("runTelemetrySettingsMigration", () => {
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
settingsRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
it("moves a disabled switch and the instance id", async () => {
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
coreSettings.set("analytics_instance_id", "abc-123");
|
||||
|
||||
const result = await runTelemetrySettingsMigration();
|
||||
|
||||
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
|
||||
expect(stored("enabled")).toBe(false);
|
||||
expect(stored("instanceId")).toBe("abc-123");
|
||||
});
|
||||
|
||||
it("writes nothing for an enabled instance without an id", async () => {
|
||||
coreSettings.set("analytics_enabled", "true");
|
||||
await runTelemetrySettingsMigration();
|
||||
expect(settingsRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is idempotent and never overwrites plugin values", async () => {
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
coreSettings.set("analytics_instance_id", "old");
|
||||
settingsRows.push({
|
||||
pluginId: "telemetry",
|
||||
key: "instanceId",
|
||||
value: JSON.stringify("new"),
|
||||
});
|
||||
|
||||
await runTelemetrySettingsMigration();
|
||||
const second = await runTelemetrySettingsMigration();
|
||||
|
||||
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
|
||||
expect(stored("instanceId")).toBe("new");
|
||||
expect(settingsRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing without the plugin row", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
await runTelemetrySettingsMigration();
|
||||
expect(settingsRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockGetBoolean = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
const mockSet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = ["from", "where", "groupBy"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: mockGetBoolean,
|
||||
get: mockGet,
|
||||
set: mockSet,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: vi.fn(() => makeChain([{ count: 0 }])),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: {},
|
||||
hosts: {},
|
||||
recentActivity: { type: "type", timestamp: "timestamp" },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
Logger: class {
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { post: mockPost },
|
||||
}));
|
||||
|
||||
describe("analytics", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||
});
|
||||
|
||||
it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "TRUE";
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getTelemetryEnvOverride returns null when unset or blank", async () => {
|
||||
const { getTelemetryEnvOverride } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
expect(getTelemetryEnvOverride()).toBe(null);
|
||||
|
||||
process.env.ENABLE_TELEMETRY = " ";
|
||||
expect(getTelemetryEnvOverride()).toBe(null);
|
||||
});
|
||||
|
||||
it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
const { startAnalyticsHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
startAnalyticsHeartbeat();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toBe("existing-id");
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||
mockGet.mockResolvedValue(null);
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(false);
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue("instance-123");
|
||||
mockPost.mockResolvedValue({});
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = mockPost.mock.calls[0];
|
||||
expect(url).toContain("/capture/");
|
||||
expect(body).toMatchObject({
|
||||
api_key: "phc_test",
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: "instance-123",
|
||||
properties: expect.objectContaining({
|
||||
user_count: 0,
|
||||
host_count: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.j
|
||||
import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js";
|
||||
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
|
||||
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
|
||||
import { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js";
|
||||
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
|
||||
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
|
||||
@@ -67,6 +68,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
["runSsoSettingsMigration", runSsoSettingsMigration],
|
||||
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
|
||||
// Last: it reads what the moves above wrote.
|
||||
["runHostDefaultsMigration", runHostDefaultsMigration],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Moves the 2.8 telemetry settings into the telemetry plugin: the admin
|
||||
* switch (settings.analytics_enabled) into its `enabled` admin setting, and
|
||||
* the instance id (settings.analytics_instance_id) into `instanceId`, so the
|
||||
* instance keeps reporting under the same id.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row. The
|
||||
* legacy settings rows stay in place until 3.0.0. The plugin waits a few
|
||||
* minutes before its first send, so this lands first.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "telemetry";
|
||||
|
||||
export interface TelemetrySettingsMigrationResult {
|
||||
movedEnabled: boolean;
|
||||
movedInstanceId: boolean;
|
||||
}
|
||||
|
||||
export async function runTelemetrySettingsMigration(): Promise<TelemetrySettingsMigrationResult> {
|
||||
const result: TelemetrySettingsMigrationResult = {
|
||||
movedEnabled: false,
|
||||
movedInstanceId: false,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins.
|
||||
try {
|
||||
if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const coreSettings = createCurrentSettingsRepository();
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const moves: Array<[string, string, unknown]> = [];
|
||||
if ((await coreSettings.get("analytics_enabled")) === "false") {
|
||||
moves.push(["analytics_enabled", "enabled", false]);
|
||||
}
|
||||
const instanceId = await coreSettings.get("analytics_instance_id");
|
||||
if (instanceId) {
|
||||
moves.push(["analytics_instance_id", "instanceId", instanceId]);
|
||||
}
|
||||
|
||||
for (const [, key, value] of moves) {
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, key);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
if (key === "enabled") result.movedEnabled = true;
|
||||
else result.movedInstanceId = true;
|
||||
}
|
||||
|
||||
if (result.movedEnabled || result.movedInstanceId) {
|
||||
databaseLogger.info("Moved telemetry settings into plugin settings", {
|
||||
operation: "telemetry_settings_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { Logger } from "./logger.js";
|
||||
|
||||
const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function getTelemetryEnvOverride(): boolean | null {
|
||||
const envVal = process.env.ENABLE_TELEMETRY;
|
||||
if (envVal === undefined) return null;
|
||||
const normalized = envVal.trim().toLowerCase();
|
||||
if (normalized === "") return null;
|
||||
return normalized === "true";
|
||||
}
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
const override = getTelemetryEnvOverride();
|
||||
if (override !== null) return override;
|
||||
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const existing = await settings.get("analytics_instance_id");
|
||||
if (existing) return existing;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await settings.set("analytics_instance_id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
return process.env.VERSION || "unknown";
|
||||
}
|
||||
|
||||
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: recentActivity.type,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||
.groupBy(recentActivity.type);
|
||||
|
||||
// Activity types come from whichever plugins recorded them, so report the
|
||||
// ones that appeared rather than a fixed list.
|
||||
const usage: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
if (/^[a-z][a-z0-9_]*$/.test(row.type)) {
|
||||
usage[`used_${row.type}`] = Number(row.count);
|
||||
}
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function collectCounts(): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const [userRows, hostRows] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||
]);
|
||||
|
||||
return {
|
||||
userCount: Number(userRows[0]?.count ?? 0),
|
||||
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
if (!(await isAnalyticsEnabled())) return;
|
||||
|
||||
const instanceId = await getOrCreateInstanceId();
|
||||
const { userCount, hostCount } = await collectCounts();
|
||||
const featureUsage = await collectFeatureUsage();
|
||||
|
||||
await axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: apiKey,
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: instanceId,
|
||||
properties: {
|
||||
version: getAppVersion(),
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
...featureUsage,
|
||||
},
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||
operation: "analytics_heartbeat_sent",
|
||||
});
|
||||
} catch (err) {
|
||||
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||
operation: "analytics_heartbeat_failed",
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (getTelemetryEnvOverride() === false) {
|
||||
analyticsLogger.info("Telemetry disabled by ENABLE_TELEMETRY", {
|
||||
operation: "analytics_disabled_by_env",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
|
||||
operation: "analytics_disabled_no_key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void collectAndSendHeartbeat();
|
||||
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
@@ -46,35 +46,6 @@ export async function updateSessionTimeout(
|
||||
// Tailscale settings/device API wrappers moved to
|
||||
// plugins/tailscale/src/frontend/tailscale-api.ts.
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAnalyticsEnabled(): Promise<{
|
||||
enabled: boolean;
|
||||
locked?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/analytics-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAnalyticsEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/analytics-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BRANDING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1255,10 +1255,6 @@
|
||||
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
|
||||
"allowPasswordReset": "Allow Password Reset",
|
||||
"allowPasswordResetDesc": "Reset code via Docker logs",
|
||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
||||
"analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
|
||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||
"auditForwardingTitle": "Forward to SIEM",
|
||||
"auditForwardingDesc": "Ship a copy of every audit entry to an external collector as NDJSON. Local logs stay the source of truth.",
|
||||
"auditForwardingEnvNotice": "An environment variable also configures forwarding; the URL saved here takes precedence.",
|
||||
@@ -1872,6 +1868,10 @@
|
||||
"title": "Read Termix settings",
|
||||
"consequence": "Server settings, not your hosts or credentials."
|
||||
},
|
||||
"plugins:read": {
|
||||
"title": "See installed features",
|
||||
"consequence": "It can see which features are installed and running."
|
||||
},
|
||||
"ui:surface": {
|
||||
"title": "Add its own screens",
|
||||
"consequence": "Tabs, panels and settings you can hide later."
|
||||
|
||||
@@ -37,8 +37,6 @@ import {
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
getBranding,
|
||||
updateBranding,
|
||||
type BrandingSettings,
|
||||
@@ -121,8 +119,6 @@ export function AdminSettingsPanel({
|
||||
const [sessionTimeout, setSessionTimeout] = useState("24");
|
||||
const [statusInterval, setStatusInterval] = useState("60");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
|
||||
@@ -286,7 +282,6 @@ export function AdminSettingsPanel({
|
||||
level,
|
||||
oidcProv,
|
||||
secondFactorExternal,
|
||||
analytics,
|
||||
notificationEndpoints,
|
||||
branding,
|
||||
] = await Promise.allSettled([
|
||||
@@ -298,7 +293,6 @@ export function AdminSettingsPanel({
|
||||
getLogLevel(),
|
||||
getExternalAutoProvision(),
|
||||
getSecondFactorAfterExternalLogin(),
|
||||
getAnalyticsEnabled(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getBranding(),
|
||||
]);
|
||||
@@ -323,10 +317,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
if (level.status === "fulfilled") setLogLevel(level.value.level);
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
setAnalyticsLocked(analytics.value.locked ?? false);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||
}
|
||||
@@ -407,18 +397,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAnalytics() {
|
||||
if (analyticsLocked) return;
|
||||
const newVal = !analyticsEnabled;
|
||||
setAnalyticsEnabled(newVal);
|
||||
try {
|
||||
await updateAnalyticsEnabled(newVal);
|
||||
} catch {
|
||||
setAnalyticsEnabled(!newVal);
|
||||
toast.error(t("admin.updateAnalyticsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
|
||||
const previous = notificationPrivateEndpoints;
|
||||
setNotificationPrivateEndpoints(hosts);
|
||||
@@ -784,9 +762,6 @@ export function AdminSettingsPanel({
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
analyticsEnabled={analyticsEnabled}
|
||||
analyticsLocked={analyticsLocked}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
onSaveNotificationPrivateEndpoints={
|
||||
handleSaveNotificationPrivateEndpoints
|
||||
|
||||
@@ -10,9 +10,6 @@ import type { TlsStatus } from "@/api/tls-api";
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
analyticsEnabled: boolean;
|
||||
analyticsLocked: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
notificationPrivateEndpoints: string[];
|
||||
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||
allowRegistration: boolean;
|
||||
@@ -39,9 +36,6 @@ type GeneralSettingsSectionProps = {
|
||||
export function AdminGeneralSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
analyticsEnabled,
|
||||
analyticsLocked,
|
||||
handleToggleAnalytics,
|
||||
notificationPrivateEndpoints,
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
allowRegistration,
|
||||
@@ -74,20 +68,6 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.analyticsEnabled")}
|
||||
description={
|
||||
analyticsLocked
|
||||
? t("admin.analyticsEnabledLockedDesc")
|
||||
: t("admin.analyticsEnabledDesc")
|
||||
}
|
||||
>
|
||||
<AdminToggle
|
||||
on={analyticsEnabled}
|
||||
onToggle={handleToggleAnalytics}
|
||||
disabled={analyticsLocked}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.notificationPrivateEndpoints")}
|
||||
|
||||
Reference in new issue
Block a user