From c08feba0ad550fbfc8e22971017b271437d9085f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 19 Nov 2025 19:53:10 +0000 Subject: [PATCH] Improve settings (#168) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/local-client-cli/Dockerfile | 6 +- frontend/local-client-cli/src/args.ts | 16 ++++- frontend/local-client-cli/src/cli.ts | 39 ++++++++++- frontend/local-client-cli/src/healthcheck.ts | 66 +++++++++++++++++++ frontend/local-client-cli/webpack.config.js | 7 +- frontend/obsidian-plugin/package.json | 5 +- .../obsidian-plugin/src/vault-link-plugin.ts | 41 ------------ .../views/cursors/remote-cursors-plugin.ts | 4 +- .../src/views/settings/settings-tab.ts | 49 ++++++++------ frontend/package-lock.json | 39 ++--------- frontend/sync-client/package.json | 1 + .../sync-client/src/persistence/settings.ts | 6 +- frontend/sync-client/src/sync-client.ts | 38 +++++++++-- .../sync-client/src/sync-operations/syncer.ts | 4 +- .../src/utils/fix-sized-cache.test.ts | 36 ++++++++++ .../sync-client/src/utils/fix-sized-cache.ts | 28 ++++---- .../sync-client/src/utils/set-up-telemetry.ts | 33 ++++++++++ package-lock.json | 6 ++ sync-server/src/main.rs | 6 +- 19 files changed, 302 insertions(+), 128 deletions(-) create mode 100644 frontend/local-client-cli/src/healthcheck.ts create mode 100644 frontend/sync-client/src/utils/set-up-telemetry.ts create mode 100644 package-lock.json diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 6b8e1d6c..695ab587 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -16,10 +16,14 @@ LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="andras@schmelczer.dev" COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js +COPY --from=builder /build/local-client-cli/dist/healthcheck.js /app/healthcheck.js + +HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=1 \ + CMD node /app/healthcheck.js /tmp/vaultlink-health.json WORKDIR /vault VOLUME ["/vault"] -ENTRYPOINT ["node", "/app/cli.js"] +ENTRYPOINT ["node", "/app/cli.js", "--health", "/tmp/vaultlink-health.json"] CMD ["--help"] diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 08ef2a6b..fc2d4a95 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -12,6 +12,8 @@ export interface CliArgs { ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; logLevel: LogLevel; + health?: string; + enableTelemetry?: boolean; } export function parseArgs(argv: string[]): CliArgs { @@ -51,6 +53,14 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "INFO" ) + .option( + "--health ", + "[OPTIONAL] Path to health status file for Docker healthcheck" + ) + .option( + "--enable-telemetry", + "[OPTIONAL] Enable telemetry (disabled by default)" + ) .addHelpText( "after", ` @@ -78,6 +88,8 @@ Examples: | number | undefined; const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; + const health = opts.health as string | undefined; + const enableTelemetry = opts.enableTelemetry as boolean | undefined; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ if (localPath === undefined) { @@ -117,6 +129,8 @@ Examples: maxFileSizeMB: maxFileSizeMb, ignorePatterns: ignorePattern, webSocketRetryIntervalMs: websocketRetryIntervalMs, - logLevel + logLevel, + health, + enableTelemetry }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 5a3c6546..2a4cef98 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -1,5 +1,7 @@ import * as path from "path"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; import { SyncClient, DEFAULT_SETTINGS, @@ -13,6 +15,19 @@ import { FileWatcher } from "./file-watcher"; import { formatLogLine, colorize, styleText } from "./logger-formatter"; import packageJson from "../package.json"; +function writeHealthStatus( + filePath: string, + connectionStatus: NetworkConnectionStatus +): void { + try { + fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); + } catch (error) { + console.error( + `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + const LOG_LEVEL_ORDER = { [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 1, @@ -78,11 +93,13 @@ async function main(): Promise { syncConcurrency: args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, + diffCacheSizeMB: DEFAULT_SETTINGS.diffCacheSizeMB, ignorePatterns, webSocketRetryIntervalMs: args.webSocketRetryIntervalMs ?? DEFAULT_SETTINGS.webSocketRetryIntervalMs, - isSyncEnabled: true + isSyncEnabled: true, + enableTelemetry: args.enableTelemetry ?? false }; const client = await SyncClient.create({ @@ -119,6 +136,21 @@ async function main(): Promise { nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" }); + if (args.health !== undefined) { + const healthFile = args.health; + const healthInterval = setInterval(() => { + void client.checkConnection().then((status) => { + writeHealthStatus(healthFile, status); + }); + }, 30 * 1000); // every 30 seconds + const clearHealthInterval = (): void => { + clearInterval(healthInterval); + }; + process.on("SIGINT", clearHealthInterval); + process.on("SIGTERM", clearHealthInterval); + process.on("exit", clearHealthInterval); + } + // Add colored log formatter with level filtering client.logger.addOnMessageListener((logLine) => { // Only show messages at or above the configured log level @@ -132,7 +164,10 @@ async function main(): Promise { const fileWatcher = new FileWatcher(absolutePath, client); client.addWebSocketStatusChangeListener(() => { - client.logger.info("WebSocket status changed"); + const isConnected = client.isWebSocketConnected; + client.logger.info( + `WebSocket status changed: ${isConnected ? "connected" : "disconnected"}` + ); }); client.addRemainingSyncOperationsListener((remaining) => { diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts new file mode 100644 index 00000000..256cd2d8 --- /dev/null +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +/** + * Healthcheck script for Docker container + * Checks if the sync client is connected to the server + */ + +import * as fs from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; + +function isHealthStatus(value: unknown): value is NetworkConnectionStatus { + if (typeof value !== "object" || value === null) { + return false; + } + + return ( + "isSuccessful" in value && + typeof value.isSuccessful === "boolean" && + "isWebSocketConnected" in value && + typeof value.isWebSocketConnected === "boolean" && + "serverMessage" in value && + typeof value.serverMessage === "string" + ); +} + +function main(): void { + if (process.argv.length < 3) { + console.error("Usage: healthcheck "); + process.exit(1); + } + const [, , healthFile] = process.argv; + + try { + // Check if health file exists + if (!fs.existsSync(healthFile)) { + console.error(`Health file does not exist: ${healthFile}`); + process.exit(1); + } + + // Read and parse health status + const content = fs.readFileSync(healthFile, "utf-8"); + const parsed: unknown = JSON.parse(content); + + // Validate the parsed object using type guard + if (!isHealthStatus(parsed)) { + throw new Error("Invalid health status format"); + } + + const status = parsed; + + if (!status.isSuccessful || !status.isWebSocketConnected) { + console.error("Not connected to server: " + status.serverMessage); + process.exit(1); + } + + console.log("Healthy: Connected to server"); + process.exit(0); + } catch (error) { + console.error( + `Health check failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} + +main(); diff --git a/frontend/local-client-cli/webpack.config.js b/frontend/local-client-cli/webpack.config.js index e17754b2..32b3b125 100644 --- a/frontend/local-client-cli/webpack.config.js +++ b/frontend/local-client-cli/webpack.config.js @@ -2,7 +2,10 @@ const path = require("path"); const webpack = require("webpack"); module.exports = { - entry: "./src/cli.ts", + entry: { + cli: "./src/cli.ts", + healthcheck: "./src/healthcheck.ts" + }, target: "node", mode: "production", optimization: { @@ -21,7 +24,7 @@ module.exports = { }, output: { globalObject: "this", - filename: "cli.js", + filename: "[name].js", path: path.resolve(__dirname, "dist") }, plugins: [ diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index 12a52f28..8c4fcee2 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -13,8 +13,6 @@ "author": "", "license": "MIT", "devDependencies": { - "@plausible-analytics/tracker": "^0.4.3", - "@sentry/browser": "^10.8.0", "@types/node": "^24.8.1", "css-loader": "^7.1.2", "date-fns": "^4.1.0", @@ -22,7 +20,7 @@ "fs-extra": "^11.3.0", "mini-css-extract-plugin": "^2.9.2", "obsidian": "1.10.2", - "reconcile-text": "^0.5.0", + "reconcile-text": "^0.7.1", "resolve-url-loader": "^5.0.0", "sass": "^1.91.0", "sass-loader": "^16.0.5", @@ -33,7 +31,6 @@ "tsx": "^4.20.5", "typescript": "5.8.3", "url": "^0.11.4", - "virtual-scroller": "^1.13.1", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 25a03ff6..fc16aae2 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -11,8 +11,6 @@ import { HistoryView } from "./views/history/history-view"; import { StatusBar } from "./views/status-bar/status-bar"; import { LogsView } from "./views/logs/logs-view"; import { StatusDescription } from "./views/status-description/status-description"; -import * as Sentry from "@sentry/browser"; -import { init as plausibleInit } from "@plausible-analytics/tracker"; import { SyncClient, rateLimit, @@ -50,45 +48,6 @@ export default class VaultLinkPlugin extends Plugin { ".trash/**" ); - plausibleInit({ - domain: "vault-link", - endpoint: "https://stats.schmelczer.dev/status", - autoCapturePageviews: true, - captureOnLocalhost: true, - logging: true - }); - - Sentry.init({ - dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1", - skipBrowserExtensionCheck: false - }); - - const onError = (event: ErrorEvent): void => { - Sentry.captureException(event.error, { - extra: { - message: event.message, - filename: event.filename, - lineno: event.lineno, - colno: event.colno - } - }); - }; - window.addEventListener("error", onError); - this.disposables.push(() => { - window.removeEventListener("error", onError); - }); - - const onUnhandledRejection = (event: PromiseRejectionEvent): void => { - Sentry.captureException(event.reason); - }; - window.addEventListener("unhandledrejection", onUnhandledRejection); - this.disposables.push(() => { - window.removeEventListener( - "unhandledrejection", - onUnhandledRejection - ); - }); - const isDebugBuild = process.env.NODE_ENV === "development"; const debugOptions = isDebugBuild ? { diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index 86ddd6cd..5f867f90 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -33,7 +33,7 @@ export class RemoteCursorsPluginValue implements PluginValue { isOutdated: boolean; }[] = []; - private static app: App; + private static app?: App; public decorations: DecorationSet = RangeSet.of([]); public static setCursors( @@ -88,7 +88,7 @@ export class RemoteCursorsPluginValue implements PluginValue { private static findFileForEditor( editor: EditorView ): RelativePath | undefined { - return RemoteCursorsPluginValue.app.workspace + return RemoteCursorsPluginValue.app?.workspace .getLeavesOfType("markdown") .map((leaf) => leaf.view) .filter((view) => view instanceof MarkdownView) diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts index 2d129edc..e4c16e6e 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -72,6 +72,7 @@ export class SyncSettingsTab extends PluginSettingTab { this.renderSettingsHeader(containerEl); this.renderConnectionSettings(containerEl); this.renderSyncSettings(containerEl); + this.renderMiscSettings(containerEl); } public hide(): void { @@ -193,38 +194,28 @@ export class SyncSettingsTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .addButton((button) => - button.setButtonText("Apply").onClick(async () => { + new Setting(containerEl).addButton((button) => + button + .setButtonText("Apply & test connection") + .onClick(async () => { if (this.areThereUnsavedChanges()) { await this.syncClient.setSettings({ vaultName: this.editedVaultName, remoteUri: this.editedServerUri, token: this.editedToken }); + new Notice("Checking connection to the server..."); new Notice( - "The changes have been applied successfully!" + ( + await this.syncClient.checkConnection() + ).serverMessage ); await this.statusDescription.updateConnectionState(); } else { new Notice("No changes to apply"); } }) - ) - .addButton((button) => - button.setButtonText("Test connection").onClick(async () => { - if (this.areThereUnsavedChanges()) { - new Notice( - "There are unsaved changes, testing with the currently saved settings" - ); - } - - new Notice( - (await this.syncClient.checkConnection()).serverMessage - ); - await this.statusDescription.updateConnectionState(); - }) - ); + ); } private areThereUnsavedChanges(): boolean { @@ -339,6 +330,26 @@ export class SyncSettingsTab extends PluginSettingTab { ); } + private renderMiscSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Other" }); + + new Setting(containerEl) + .setName("Enable telemetry") + .setDesc( + "Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties." + ) + .setTooltip( + "Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties." + ) + .addToggle((toggle) => + toggle + .setValue(this.syncClient.getSettings().enableTelemetry) + .onChange(async (value) => + this.syncClient.setSetting("enableTelemetry", value) + ) + ); + } + private setStatusDescriptionSubscription( newSubscription?: () => unknown ): void { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b52eb1d..bbb98a1a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -871,13 +871,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@plausible-analytics/tracker": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.3.tgz", - "integrity": "sha512-RKTgH5xu7Pa77VS4OEnS4woPhDxRgWLJlt9f6JhwgBC9ilknCfJIVEN2A1D8OR7hzgxMQF/hPyls9iN9ReAm3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@sentry-internal/browser-utils": { "version": "10.8.0", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz", @@ -3488,10 +3481,9 @@ } }, "node_modules/reconcile-text": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.5.0.tgz", - "integrity": "sha512-zki3lqw9Oxdhm9ZvDN17VyYoL1Isc8BEL07ILVDE2yGfNEI7thrkczoNCUr+hkFU2rzZtfxECTG0b7p61AJ6wg==", - "dev": true, + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.7.1.tgz", + "integrity": "sha512-khedcYvAKs7ELKh5Z8mz2vyomMY5TqznV1dB+k/7qUAX9cheMNN5/EPJVQYZepOMunYbnQitvhFJX3kD4IMcNw==", "license": "MIT" }, "node_modules/regex-parser": { @@ -3499,11 +3491,6 @@ "dev": true, "license": "MIT" }, - "node_modules/request-animation-frame-timeout": { - "version": "2.0.4", - "dev": true, - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "dev": true, @@ -4329,14 +4316,6 @@ "resolved": "obsidian-plugin", "link": true }, - "node_modules/virtual-scroller": { - "version": "1.13.1", - "dev": true, - "license": "MIT", - "dependencies": { - "request-animation-frame-timeout": "^2.0.3" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -4661,8 +4640,6 @@ "version": "0.10.0", "license": "MIT", "devDependencies": { - "@plausible-analytics/tracker": "^0.4.3", - "@sentry/browser": "^10.8.0", "@types/node": "^24.8.1", "css-loader": "^7.1.2", "date-fns": "^4.1.0", @@ -4670,7 +4647,7 @@ "fs-extra": "^11.3.0", "mini-css-extract-plugin": "^2.9.2", "obsidian": "1.10.2", - "reconcile-text": "^0.5.0", + "reconcile-text": "^0.7.1", "resolve-url-loader": "^5.0.0", "sass": "^1.91.0", "sass-loader": "^16.0.5", @@ -4681,7 +4658,6 @@ "tsx": "^4.20.5", "typescript": "5.8.3", "url": "^0.11.4", - "virtual-scroller": "^1.13.1", "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } @@ -4696,6 +4672,7 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@sentry/browser": "^10.8.0", "@types/node": "^24.8.1", "ts-loader": "^9.5.2", "tslib": "2.8.1", @@ -4729,12 +4706,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "sync-client/node_modules/reconcile-text": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.7.1.tgz", - "integrity": "sha512-khedcYvAKs7ELKh5Z8mz2vyomMY5TqznV1dB+k/7qUAX9cheMNN5/EPJVQYZepOMunYbnQitvhFJX3kD4IMcNw==", - "license": "MIT" - }, "test-client": { "version": "0.10.0", "bin": { diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index d35e5a3d..75ad6e49 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -28,6 +28,7 @@ "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", + "@sentry/browser": "^10.8.0", "ws": "^8.18.3" } } diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index b0aff937..87821728 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -9,6 +9,8 @@ export interface SyncSettings { maxFileSizeMB: number; ignorePatterns: string[]; webSocketRetryIntervalMs: number; + diffCacheSizeMB: number; + enableTelemetry: boolean; } export const DEFAULT_SETTINGS: SyncSettings = { @@ -19,7 +21,9 @@ export const DEFAULT_SETTINGS: SyncSettings = { isSyncEnabled: false, maxFileSizeMB: 10, ignorePatterns: [], - webSocketRetryIntervalMs: 3500 + webSocketRetryIntervalMs: 3500, + diffCacheSizeMB: 4, + enableTelemetry: false }; export class Settings { diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 33a1cac5..9547af65 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -22,11 +22,13 @@ import type { CursorSpan } from "./services/types/CursorSpan"; import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; import { FileChangeNotifier } from "./sync-operations/file-change-notifier"; import { FixedSizeDocumentCache } from "./utils/fix-sized-cache"; +import { setUpTelemetry } from "./utils/set-up-telemetry"; export class SyncClient { private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; private hasStartedOfflineSync = false; private hasFinishedOfflineSync = false; + private unloadTelemetry?: () => void; private constructor( private readonly history: SyncHistory, @@ -38,8 +40,13 @@ export class SyncClient { private readonly _logger: Logger, private readonly connectionStatus: ConnectionStatus, private readonly cursorTracker: CursorTracker, - private readonly fileChangeNotifier: FileChangeNotifier + private readonly fileChangeNotifier: FileChangeNotifier, + private readonly contentCache: FixedSizeDocumentCache ) { + if (settings.getSettings().enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } + this.settings.addOnSettingsChangeListener( async (newSettings, oldSettings) => { if (newSettings.vaultName !== oldSettings.vaultName) { @@ -53,6 +60,24 @@ export class SyncClient { this.stop(); } } + + if ( + newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB + ) { + this.contentCache.resize( + newSettings.diffCacheSizeMB * 1024 * 1024 + ); + } + + if ( + newSettings.enableTelemetry !== oldSettings.enableTelemetry + ) { + if (newSettings.enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } else { + this.unloadTelemetry?.(); + } + } } ); } @@ -65,6 +90,10 @@ export class SyncClient { return this.database.length; } + public get isWebSocketConnected(): boolean { + return this.webSocketManager.isWebSocketConnected; + } + public static async create({ fs, persistence, @@ -152,8 +181,7 @@ export class SyncClient { settings, syncService, fileOperations, - unrestrictedSyncer, - contentCache + unrestrictedSyncer ); const webSocketManager = new WebSocketManager( @@ -182,7 +210,8 @@ export class SyncClient { logger, connectionStatus, cursorTracker, - fileChangeNotifier + fileChangeNotifier, + contentCache ); logger.info("SyncClient initialised"); @@ -235,6 +264,7 @@ export class SyncClient { public async reset(): Promise { this.stop(); this.connectionStatus.startReset(); + this.contentCache.clear(); await this.syncer.reset(); this.history.reset(); this.database.reset(); diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 1c8ac36e..a4badd9a 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -34,8 +34,7 @@ export class Syncer { settings: Settings, private readonly syncService: SyncService, private readonly operations: FileOperations, - private readonly internalSyncer: UnrestrictedSyncer, - private readonly contentCache: FixedSizeDocumentCache + private readonly internalSyncer: UnrestrictedSyncer ) { this.syncQueue = new PQueue({ concurrency: settings.getSettings().syncConcurrency @@ -252,7 +251,6 @@ export class Syncer { public async reset(): Promise { await this.waitUntilFinished(); - this.contentCache.clear(); } public async syncRemotelyUpdatedFile( diff --git a/frontend/sync-client/src/utils/fix-sized-cache.test.ts b/frontend/sync-client/src/utils/fix-sized-cache.test.ts index 46bc4144..4a24aafb 100644 --- a/frontend/sync-client/src/utils/fix-sized-cache.test.ts +++ b/frontend/sync-client/src/utils/fix-sized-cache.test.ts @@ -236,4 +236,40 @@ describe("fixedSizeDocumentCache", () => { assert.equal(cache.get(2), doc2); assert.equal(cache.get(3), doc3); }); + + it("resizeToLargerSizeNoEviction", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + cache.put(2, doc2); + + cache.resize(10); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + }); + + it("resizeCausesMultipleEvictions", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + const doc4 = new Uint8Array([7, 8]); + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + cache.put(4, doc4); + // Cache has 8 bytes total + + cache.resize(2); + + // Should evict doc1, doc2, doc3 to get down to 2 bytes + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), undefined); + assert.equal(cache.get(3), undefined); + assert.equal(cache.get(4), doc4); + }); }); diff --git a/frontend/sync-client/src/utils/fix-sized-cache.ts b/frontend/sync-client/src/utils/fix-sized-cache.ts index 7adee7b0..cf0ba47e 100644 --- a/frontend/sync-client/src/utils/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/fix-sized-cache.ts @@ -14,14 +14,12 @@ class LRUNode { // evicting the least recently used documents when the size limit is exceeded. export class FixedSizeDocumentCache { - private readonly maxSizeInBytes: number; private currentSizeInBytes: number; private readonly cache: Map; private head: LRUNode | null; // Least recently used private tail: LRUNode | null; // Most recently used - public constructor(maxSizeInBytes: number) { - this.maxSizeInBytes = maxSizeInBytes; + public constructor(private maxSizeInBytes: number) { this.currentSizeInBytes = 0; this.cache = new Map(); this.head = null; @@ -56,14 +54,7 @@ export class FixedSizeDocumentCache { this.cache.set(updateId, newNode); this.addToTail(newNode); this.currentSizeInBytes += content.byteLength; - - // Evict least recently used documents if over size limit - while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) { - const lruNode = this.head; - this.removeNode(lruNode); - this.cache.delete(lruNode.key); - this.currentSizeInBytes -= lruNode.value.byteLength; - } + this.fitBelowMaxSize(); } public clear(): void { @@ -73,6 +64,21 @@ export class FixedSizeDocumentCache { this.currentSizeInBytes = 0; } + public resize(newMaxSizeInBytes: number): void { + this.maxSizeInBytes = newMaxSizeInBytes; + this.fitBelowMaxSize(); + } + + private fitBelowMaxSize(): void { + // Evict least recently used documents if over size limit + while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) { + const lruNode = this.head; + this.removeNode(lruNode); + this.cache.delete(lruNode.key); + this.currentSizeInBytes -= lruNode.value.byteLength; + } + } + private removeNode(node: LRUNode): void { if (node.prev) { node.prev.next = node.next; diff --git a/frontend/sync-client/src/utils/set-up-telemetry.ts b/frontend/sync-client/src/utils/set-up-telemetry.ts new file mode 100644 index 00000000..e4a4d881 --- /dev/null +++ b/frontend/sync-client/src/utils/set-up-telemetry.ts @@ -0,0 +1,33 @@ +import * as Sentry from "@sentry/browser"; + +export const setUpTelemetry = (): (() => void) => { + Sentry.init({ + dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1", + skipBrowserExtensionCheck: false + }); + + const onError = (event: ErrorEvent): void => { + Sentry.captureException(event.error, { + extra: { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno + } + }); + }; + window.addEventListener("error", onError); + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + Sentry.captureException(event.reason); + }; + window.addEventListener("unhandledrejection", onUnhandledRejection); + + return (): void => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onUnhandledRejection); + Sentry.close(5000).catch(() => { + // Ignore errors during shutdown + }); + }; +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9e0474fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "vault-link", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs index aba6574e..82b75721 100644 --- a/sync-server/src/main.rs +++ b/sync-server/src/main.rs @@ -92,9 +92,9 @@ fn set_up_logging( .with_line_number(is_debug_mode) .compact(); - let stdout_layer = tracing_subscriber::fmt::layer() + let stderr_layer = tracing_subscriber::fmt::layer() .with_ansi(use_colors) - .with_writer(std::io::stdout) + .with_writer(std::io::stderr) .event_format(format.clone()); let file_layer = tracing_subscriber::fmt::layer() @@ -104,8 +104,8 @@ fn set_up_logging( tracing_subscriber::registry() .with(env_filter) - .with(stdout_layer) .with(file_layer) + .with(stderr_layer) .try_init() .context("Failed to initialise tracing") .map_err(init_error)?;