From 7fc5abfbc60ad0fdcd3e40e6d6f7a092dfbcf149 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:24:56 -0400 Subject: [PATCH 01/19] chore(desktop-main): T1 - add winston-daily-rotate-file ^5.0.0 dependency Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/package.json | 3 +- package-lock.json | 48 +++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/app/packages/desktop-main/package.json b/app/packages/desktop-main/package.json index 46c5c1e..eaf4dd1 100644 --- a/app/packages/desktop-main/package.json +++ b/app/packages/desktop-main/package.json @@ -27,7 +27,8 @@ "nestjs-electron-ipc-transport": "^1.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "winston": "^3.13.0" + "winston": "^3.13.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/package-lock.json b/package-lock.json index 8c78b79..bbbcd83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,8 @@ "nestjs-electron-ipc-transport": "^1.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "winston": "^3.13.0" + "winston": "^3.13.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@types/express": "^4.17.21", @@ -7610,6 +7611,15 @@ "node": ">=16.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/file-type": { "version": "20.4.1", "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", @@ -9497,6 +9507,15 @@ "obliterator": "^1.6.1" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -9708,6 +9727,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -13386,6 +13414,24 @@ "node": ">= 12.0.0" } }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, "node_modules/winston-transport": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", From a1090c354ff77d5392130da2d7c0748dc380f539 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:29:08 -0400 Subject: [PATCH 02/19] feat(desktop-main): T2 - rewrite logger.ts to createLogger factory with DailyRotateFile Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/logger.test.ts | 72 ++++++++++++++++ app/packages/desktop-main/src/logger.ts | 86 +++++++++++++++----- 2 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 app/packages/desktop-main/src/logger.test.ts diff --git a/app/packages/desktop-main/src/logger.test.ts b/app/packages/desktop-main/src/logger.test.ts new file mode 100644 index 0000000..37a7b0f --- /dev/null +++ b/app/packages/desktop-main/src/logger.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; + +/** + * Re-import logger module fresh for each test that cares about the singleton + * state. We use a plain import at the top for the factory tests; the + * singleton re-assignment tests use the same module reference. + */ +import { createLogger, logger as initialLogger } from './logger.js'; + +describe('createLogger', () => { + it('should return a winston.Logger instance', () => { + const result = createLogger('/tmp/test-logs'); + // Winston's createLogger returns a DerivedLogger that extends EventEmitter. + // Check the duck-typed API surface rather than constructor identity, which + // can differ when the same package is loaded from two module cache entries. + expect(typeof result.info).toBe('function'); + expect(typeof result.error).toBe('function'); + expect(typeof result.debug).toBe('function'); + expect(Array.isArray(result.transports)).toBe(true); + }); + + it('should include a DailyRotateFile transport in the transports array', () => { + const result = createLogger('/tmp/test-logs'); + const hasRotate = result.transports.some((t) => t instanceof DailyRotateFile); + expect(hasRotate).toBe(true); + }); + + it('should include a Console transport in the transports array', () => { + const result = createLogger('/tmp/test-logs'); + const hasConsole = result.transports.some( + (t) => t instanceof winston.transports.Console, + ); + expect(hasConsole).toBe(true); + }); + + it('should configure the DailyRotateFile transport with the provided logDir', () => { + const logDir = '/tmp/my-custom-log-dir'; + const result = createLogger(logDir); + const rotateTransport = result.transports.find( + (t) => t instanceof DailyRotateFile, + ) as InstanceType | undefined; + + expect(rotateTransport).toBeDefined(); + // The `dirname` option is stored as `options.dirname` on the transport. + const opts = (rotateTransport as unknown as { options: { dirname: string } }) + .options; + expect(opts.dirname).toBe(logDir); + }); +}); + +describe('logger singleton', () => { + beforeEach(() => { + // Reset to console-only by re-creating; each test starts fresh. + }); + + it('should reassign the exported logger binding when createLogger is called', async () => { + // Capture the initial (console-only) reference. + const beforeCall = initialLogger; + + // createLogger mutates the exported binding. + const returned = createLogger('/tmp/test-logs-singleton'); + + // The returned value and the freshly-imported binding must be the same object. + // We re-import via the same module to read the live binding. + const { logger: afterCall } = await import('./logger.js'); + expect(afterCall).toBe(returned); + // And it must differ from the pre-call fallback logger. + expect(afterCall).not.toBe(beforeCall); + }); +}); diff --git a/app/packages/desktop-main/src/logger.ts b/app/packages/desktop-main/src/logger.ts index d9a14b9..aefc825 100644 --- a/app/packages/desktop-main/src/logger.ts +++ b/app/packages/desktop-main/src/logger.ts @@ -1,24 +1,70 @@ import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; const isDev = process.env['NODE_ENV'] !== 'production'; -export const logger = winston.createLogger({ - level: isDev ? 'debug' : 'info', - format: isDev - ? winston.format.combine( - winston.format.colorize(), - winston.format.timestamp({ format: 'HH:mm:ss' }), - winston.format.printf((info) => { - const { timestamp, level, message, ...meta } = info as Record; - const metaStr = Object.keys(meta).length - ? '\n' + JSON.stringify(meta, null, 2) - : ''; - return `${timestamp} [${level}] ${message}${metaStr}`; - }), - ) - : winston.format.combine( - winston.format.timestamp(), - winston.format.json(), - ), - transports: [new winston.transports.Console()], -}); +/** Shared format used by all transports. */ +const sharedFormat = isDev + ? winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: 'HH:mm:ss' }), + winston.format.printf((info) => { + const { timestamp, level, message, ...meta } = info as Record; + const metaStr = Object.keys(meta).length + ? '\n' + JSON.stringify(meta, null, 2) + : ''; + return `${timestamp} [${level}] ${message}${metaStr}`; + }), + ) + : winston.format.combine( + winston.format.timestamp(), + winston.format.json(), + ); + +/** + * Creates a console-only fallback logger used as the initial singleton value + * before {@link createLogger} is called by main.ts. + */ +function createConsoleOnlyLogger(): winston.Logger { + return winston.createLogger({ + level: isDev ? 'debug' : 'info', + format: sharedFormat, + transports: [new winston.transports.Console()], + }); +} + +/** + * Module-level logger singleton. Initially console-only; reassigned (as a + * live ESM binding) when {@link createLogger} is called from main.ts so that + * all existing importers automatically see the upgraded instance. + */ +export let logger: winston.Logger = createConsoleOnlyLogger(); + +/** + * Creates a full winston logger with both a Console transport and a + * DailyRotateFile transport that writes to `logDir`. Also reassigns the + * exported {@link logger} singleton so that modules that imported it before + * `main.ts` started still get the upgraded instance. + * + * @param logDir - Directory in which daily log files will be written. + * @returns The newly created {@link winston.Logger} instance. + */ +export function createLogger(logDir: string): winston.Logger { + const newLogger = winston.createLogger({ + level: isDev ? 'debug' : 'info', + format: sharedFormat, + transports: [ + new winston.transports.Console(), + new DailyRotateFile({ + dirname: logDir, + filename: 'main-%DATE%.log', + datePattern: 'YYYY-MM-DD', + maxFiles: '14d', + }), + ], + }); + + // Reassign the live ESM binding so importers see the upgraded instance. + logger = newLogger; + return newLogger; +} From cdccf99346c9cc6db4401e5933f721a5b2f4f8f4 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:33:25 -0400 Subject: [PATCH 03/19] feat(desktop-main): T3 - call createLogger with userData/logs path before bootstrap Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/main.test.ts | 32 ++++++++++++++++++++-- app/packages/desktop-main/src/main.ts | 4 +++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/packages/desktop-main/src/main.test.ts b/app/packages/desktop-main/src/main.test.ts index cca2bb5..594e05d 100644 --- a/app/packages/desktop-main/src/main.test.ts +++ b/app/packages/desktop-main/src/main.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; * vi.mock() factory functions run (vi.mock calls are hoisted to the top of the * compiled output, above regular const/let declarations). */ -const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock } = vi.hoisted(() => { +const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock } = vi.hoisted(() => { /** Fake NestJS microservice app returned by `NestFactory.createMicroservice`. */ const fakeApp = { listen: vi.fn().mockResolvedValue(undefined) }; /** Spy constructor for ElectronIPCTransport — tracks `new` invocations. */ @@ -15,9 +15,21 @@ const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathM const createMicroserviceMock = vi.fn().mockResolvedValue(fakeApp); /** Spy for `applyFixPath` — verifies the fix-path bootstrap is called during startup. */ const applyFixPathMock = vi.fn(); - return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock }; + /** Fake Electron `app` object with a `getPath` spy. */ + const mockApp = { getPath: vi.fn().mockReturnValue('/fake/userData') }; + /** Spy for `createLogger` — verifies the file logger is initialised before bootstrap. */ + const createLoggerMock = vi.fn(); + return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock }; }); +vi.mock('electron', () => ({ + app: mockApp, +})); + +vi.mock('./logger.js', () => ({ + createLogger: createLoggerMock, +})); + vi.mock('nestjs-electron-ipc-transport', () => ({ ElectronIPCTransport: ElectronIPCTransportMock, })); @@ -52,6 +64,8 @@ describe('main bootstrap', () => { createMicroserviceMock.mockResolvedValue(fakeApp); fakeApp.listen.mockResolvedValue(undefined); applyFixPathMock.mockImplementation(() => undefined); + mockApp.getPath.mockReturnValue('/fake/userData'); + createLoggerMock.mockImplementation(() => undefined); // Simulate an Electron main-process environment so the module-level guard passes. vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '36.0.0' } }); }); @@ -110,4 +124,18 @@ describe('main bootstrap', () => { // applyFixPath must be invoked exactly once before NestFactory.createMicroservice. expect(applyFixPathMock).toHaveBeenCalledTimes(1); }); + + it('should resolve userData path and initialise the file logger before bootstrap', async () => { + vi.resetModules(); + await import('./main.js'); + + // Flush the event loop so the async bootstrap chain fully resolves. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Electron app.getPath should have been called with 'userData' to derive the log directory. + expect(mockApp.getPath).toHaveBeenCalledWith('userData'); + + // createLogger must have been called with the userData/logs path before NestFactory boots. + expect(createLoggerMock).toHaveBeenCalledWith('/fake/userData/logs'); + }); }); diff --git a/app/packages/desktop-main/src/main.ts b/app/packages/desktop-main/src/main.ts index c4d6c99..a0405ef 100644 --- a/app/packages/desktop-main/src/main.ts +++ b/app/packages/desktop-main/src/main.ts @@ -1,11 +1,15 @@ import 'reflect-metadata'; +import path from 'node:path'; +import { app } from 'electron'; import { NestFactory } from '@nestjs/core'; import { MicroserviceOptions } from '@nestjs/microservices'; import { ElectronIPCTransport } from 'nestjs-electron-ipc-transport'; import { AppModule } from './app.module.js'; import { applyFixPath } from './fix-path-bootstrap.js'; +import { createLogger } from './logger.js'; applyFixPath(); +createLogger(path.join(app.getPath('userData'), 'logs')); // ElectronIPCTransport requires ipcMain, which is only available inside an // Electron main process. Fail fast with a readable message rather than a From 718070010d7225b310557deb8a5cd9d2a09c3a41 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:36:27 -0400 Subject: [PATCH 04/19] feat(desktop-main): T4 - add DiagnosticsService with log tail and path resolution Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/DiagnosticsService.test.ts | 93 +++++++++++++++++++ .../src/services/DiagnosticsService.ts | 60 ++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/packages/desktop-main/src/services/DiagnosticsService.test.ts create mode 100644 app/packages/desktop-main/src/services/DiagnosticsService.ts diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts new file mode 100644 index 0000000..8bfe499 --- /dev/null +++ b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +import * as fsPromises from 'node:fs/promises'; +import { DiagnosticsService } from './DiagnosticsService.js'; + +/** Typed handle to the mocked readFile so tests can configure it cleanly. */ +const mockReadFile = vi.mocked(fsPromises.readFile); + +/** Construct a fresh DiagnosticsService pointed at a predictable log directory. */ +function makeService(logDir = '/var/log/hyveon'): DiagnosticsService { + return new DiagnosticsService(logDir); +} + +describe('DiagnosticsService.getTodayLogPath', () => { + it('should return a date-stamped path matching main-YYYY-MM-DD.log in the configured logDir', () => { + const service = makeService('/var/log/hyveon'); + const logPath = service.getTodayLogPath(); + + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const expectedFilename = `main-${yyyy}-${mm}-${dd}.log`; + + expect(logPath).toBe(`/var/log/hyveon/${expectedFilename}`); + }); + + it('should use the logDir supplied via the injection token', () => { + const service = makeService('/custom/logs'); + const logPath = service.getTodayLogPath(); + expect(logPath.startsWith('/custom/logs/')).toBe(true); + }); +}); + +describe('DiagnosticsService.readTail', () => { + let service: DiagnosticsService; + + beforeEach(() => { + mockReadFile.mockReset(); + service = makeService('/var/log/hyveon'); + }); + + it('should return an empty array when the log file does not yet exist', async () => { + const err = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + mockReadFile.mockRejectedValueOnce(err); + + const result = await service.readTail(); + expect(result).toEqual([]); + }); + + it('should re-throw errors that are not ENOENT', async () => { + const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + mockReadFile.mockRejectedValueOnce(err); + + await expect(service.readTail()).rejects.toThrow('EACCES'); + }); + + it('should return all lines when the file has fewer lines than maxLines', async () => { + mockReadFile.mockResolvedValueOnce('line1\nline2\nline3\n' as unknown as Buffer); + + const result = await service.readTail(500); + expect(result).toEqual(['line1', 'line2', 'line3']); + }); + + it('should return only the last N lines when the file has more lines than maxLines', async () => { + const allLines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`); + mockReadFile.mockResolvedValueOnce((allLines.join('\n') + '\n') as unknown as Buffer); + + const result = await service.readTail(5); + expect(result).toEqual(['line16', 'line17', 'line18', 'line19', 'line20']); + }); + + it('should default to returning at most 500 lines', async () => { + const allLines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`); + mockReadFile.mockResolvedValueOnce((allLines.join('\n') + '\n') as unknown as Buffer); + + const result = await service.readTail(); + expect(result).toHaveLength(500); + expect(result[0]).toBe('line101'); + expect(result[499]).toBe('line600'); + }); + + it('should strip a trailing empty string caused by a trailing newline', async () => { + mockReadFile.mockResolvedValueOnce('alpha\nbeta\n' as unknown as Buffer); + + const result = await service.readTail(); + expect(result).toEqual(['alpha', 'beta']); + }); +}); diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.ts b/app/packages/desktop-main/src/services/DiagnosticsService.ts new file mode 100644 index 0000000..541c913 --- /dev/null +++ b/app/packages/desktop-main/src/services/DiagnosticsService.ts @@ -0,0 +1,60 @@ +import { Injectable, Inject } from '@nestjs/common'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; + +/** Injection token for the directory where DailyRotateFile writes logs. */ +export const DIAGNOSTICS_LOG_DIR = 'DIAGNOSTICS_LOG_DIR'; + +/** + * Provides access to the local application log file written by + * winston-daily-rotate-file. Used by the diagnostics API endpoint so + * operators can read today's log without SSH access. + */ +@Injectable() +export class DiagnosticsService { + constructor( + @Inject(DIAGNOSTICS_LOG_DIR) private readonly logDir: string, + ) {} + + /** + * Returns the absolute path for today's log file using the + * `main-YYYY-MM-DD.log` naming convention that DailyRotateFile applies. + */ + getTodayLogPath(): string { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const datePart = `${yyyy}-${mm}-${dd}`; + return path.join(this.logDir, `main-${datePart}.log`); + } + + /** + * Reads the tail of today's log file, returning up to `maxLines` lines. + * Returns an empty array when the file does not yet exist (e.g. on the + * very first boot before any log rotation has occurred). + * + * @param maxLines - Maximum number of trailing lines to return. Defaults to 500. + */ + async readTail(maxLines = 500): Promise { + const filePath = this.getTodayLogPath(); + let content: string; + try { + content = await fs.readFile(filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw err; + } + + const lines = content.split('\n').filter((line, idx, arr) => { + // Drop trailing empty strings that arise from a trailing newline, + // but only at the very end of the array. + if (line === '' && idx === arr.length - 1) return false; + return true; + }); + + return lines.slice(-maxLines); + } +} From e35baa5fff66e2b3c3cf318209af2e1a408a4cca Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:38:44 -0400 Subject: [PATCH 05/19] feat(desktop-main): T5 - add DiagnosticsController with GET tail and path endpoints Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../diagnostics.controller.test.ts | 46 +++++++++++++++++++ .../src/controllers/diagnostics.controller.ts | 21 +++++++++ 2 files changed, 67 insertions(+) create mode 100644 app/packages/desktop-main/src/controllers/diagnostics.controller.test.ts create mode 100644 app/packages/desktop-main/src/controllers/diagnostics.controller.ts diff --git a/app/packages/desktop-main/src/controllers/diagnostics.controller.test.ts b/app/packages/desktop-main/src/controllers/diagnostics.controller.test.ts new file mode 100644 index 0000000..597685a --- /dev/null +++ b/app/packages/desktop-main/src/controllers/diagnostics.controller.test.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { describe, it, expect, vi } from 'vitest'; +import { DiagnosticsController } from './diagnostics.controller.js'; +import type { DiagnosticsService } from '../services/DiagnosticsService.js'; + +vi.mock('../logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +/** Build a DiagnosticsService stub. */ +function makeDiagnostics(): DiagnosticsService { + return { + readTail: vi.fn().mockResolvedValue(['line1', 'line2', 'line3']), + getTodayLogPath: vi.fn().mockReturnValue('/var/log/app/main-2026-05-23.log'), + } as unknown as DiagnosticsService; +} + +describe('DiagnosticsController', () => { + describe('getTail', () => { + it('should return lines from DiagnosticsService', async () => { + const svc = makeDiagnostics(); + const result = await new DiagnosticsController(svc).getTail(); + expect(result).toEqual({ lines: ['line1', 'line2', 'line3'] }); + }); + + it('should call DiagnosticsService.readTail with 500 lines', async () => { + const svc = makeDiagnostics(); + await new DiagnosticsController(svc).getTail(); + expect(svc.readTail).toHaveBeenCalledWith(500); + }); + }); + + describe('getPath', () => { + it('should return the current log path from DiagnosticsService', () => { + const svc = makeDiagnostics(); + const result = new DiagnosticsController(svc).getPath(); + expect(result).toEqual({ path: '/var/log/app/main-2026-05-23.log' }); + }); + + it('should delegate to DiagnosticsService.getTodayLogPath', () => { + const svc = makeDiagnostics(); + new DiagnosticsController(svc).getPath(); + expect(svc.getTodayLogPath).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/packages/desktop-main/src/controllers/diagnostics.controller.ts b/app/packages/desktop-main/src/controllers/diagnostics.controller.ts new file mode 100644 index 0000000..d3af68a --- /dev/null +++ b/app/packages/desktop-main/src/controllers/diagnostics.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get } from '@nestjs/common'; +import { DiagnosticsService } from '../services/DiagnosticsService.js'; + +/** Exposes local application log data for operator diagnostics. */ +@Controller('diagnostics') +export class DiagnosticsController { + constructor(private readonly diagnostics: DiagnosticsService) {} + + /** Returns the last 500 lines from today's local log file. */ + @Get('tail') + async getTail(): Promise<{ lines: string[] }> { + const lines = await this.diagnostics.readTail(500); + return { lines }; + } + + /** Returns the absolute path of today's local log file. */ + @Get('path') + getPath(): { path: string } { + return { path: this.diagnostics.getTodayLogPath() }; + } +} From f2a6856a5bbbd94dad34e022d6ecf61a37158ab9 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:42:20 -0400 Subject: [PATCH 06/19] feat(desktop-main): T6 - register DiagnosticsService and controller in AppModule Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/app.module.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/packages/desktop-main/src/app.module.ts b/app/packages/desktop-main/src/app.module.ts index 865af11..d5f3dae 100644 --- a/app/packages/desktop-main/src/app.module.ts +++ b/app/packages/desktop-main/src/app.module.ts @@ -1,5 +1,7 @@ +import * as path from 'node:path'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; +import { app as electronApp } from 'electron'; import { AwsModule } from './modules/aws.module.js'; import { DiscordModule } from './modules/discord.module.js'; import { GamesController } from './controllers/games.controller.js'; @@ -9,7 +11,9 @@ import { LogsController } from './controllers/logs.controller.js'; import { FilesController } from './controllers/files.controller.js'; import { DiscordController } from './controllers/discord.controller.js'; import { EnvController } from './controllers/env.controller.js'; +import { DiagnosticsController } from './controllers/diagnostics.controller.js'; import { ApiTokenGuard } from './guards/api-token.guard.js'; +import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsService.js'; /** * Root Nest module. Wires the feature modules (`AwsModule`, `DiscordModule`) to @@ -29,7 +33,15 @@ import { ApiTokenGuard } from './guards/api-token.guard.js'; FilesController, DiscordController, EnvController, + DiagnosticsController, + ], + providers: [ + { provide: APP_GUARD, useClass: ApiTokenGuard }, + { + provide: DIAGNOSTICS_LOG_DIR, + useFactory: () => path.join(electronApp.getPath('userData'), 'logs'), + }, + DiagnosticsService, ], - providers: [{ provide: APP_GUARD, useClass: ApiTokenGuard }], }) export class AppModule {} From dec71d514fb393efd0ab8d54aaf116acd8f8ef44 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:44:13 -0400 Subject: [PATCH 07/19] feat(web): T7 - add diagnosticsTail and diagnosticsLogPath to api service Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/web/src/api.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/packages/web/src/api.service.ts b/app/packages/web/src/api.service.ts index 802a044..5e87827 100644 --- a/app/packages/web/src/api.service.ts +++ b/app/packages/web/src/api.service.ts @@ -272,4 +272,7 @@ export const api = { `/api/discord/permissions/${game}`, { method: 'DELETE' }, ), + + diagnosticsTail: () => request<{ lines: string[] }>('/api/diagnostics/tail'), + diagnosticsLogPath: () => request<{ path: string }>('/api/diagnostics/path'), }; From 8538c997052bd6fe4c9843d726b9f5635bbbdedb Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:48:45 -0400 Subject: [PATCH 08/19] feat(web): T8 - add DiagnosticsPanel component with 5s polling and autoscroll Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DiagnosticsPanel.test.tsx | 97 ++++++++++++++++ .../web/src/components/DiagnosticsPanel.tsx | 107 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 app/packages/web/src/components/DiagnosticsPanel.test.tsx create mode 100644 app/packages/web/src/components/DiagnosticsPanel.tsx diff --git a/app/packages/web/src/components/DiagnosticsPanel.test.tsx b/app/packages/web/src/components/DiagnosticsPanel.test.tsx new file mode 100644 index 0000000..b38cd1d --- /dev/null +++ b/app/packages/web/src/components/DiagnosticsPanel.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { DiagnosticsPanel } from './DiagnosticsPanel.js'; + +// Mock the API client. `vi.mock` is hoisted so the import of DiagnosticsPanel +// above picks up the stub automatically. +const apiMock = vi.hoisted(() => ({ + diagnosticsTail: vi.fn(), + diagnosticsLogPath: vi.fn(), +})); +vi.mock('../api.service.js', () => ({ api: apiMock })); + +/** Sample log lines returned by the mocked API. */ +const SAMPLE_LINES = [ + '2026-05-23T10:00:00Z INFO Server started', + '2026-05-23T10:00:01Z DEBUG Loaded config', + '2026-05-23T10:00:02Z WARN Memory usage high', +]; + +const SAMPLE_PATH = '/var/log/hyveon/diagnostics.log'; + +describe('DiagnosticsPanel', () => { + beforeEach(() => { + // Default: both API calls resolve successfully. + apiMock.diagnosticsTail.mockResolvedValue({ lines: SAMPLE_LINES }); + apiMock.diagnosticsLogPath.mockResolvedValue({ path: SAMPLE_PATH }); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.clearAllMocks(); + }); + + it('should render loading state before data arrives', () => { + // Keep the promise pending so loading remains true during the assertion. + apiMock.diagnosticsTail.mockReturnValue(new Promise(() => {})); + apiMock.diagnosticsLogPath.mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByText('Loading diagnostics…')).toBeInTheDocument(); + }); + + it('should render log lines returned by api.diagnosticsTail', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('2026-05-23T10:00:00Z INFO Server started')).toBeInTheDocument(); + }); + + expect(screen.getByText('2026-05-23T10:00:01Z DEBUG Loaded config')).toBeInTheDocument(); + expect(screen.getByText('2026-05-23T10:00:02Z WARN Memory usage high')).toBeInTheDocument(); + }); + + it('should display the log file path', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(SAMPLE_PATH)).toBeInTheDocument(); + }); + + expect(screen.getByText(/Log file:/)).toBeInTheDocument(); + }); + + it('should show an error message when api.diagnosticsTail rejects', async () => { + apiMock.diagnosticsTail.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + expect(screen.getByRole('alert')).toHaveTextContent('Network error'); + }); + + it('should poll for new lines every 5 seconds', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + render(); + + // Wait for the initial fetch to complete (real microtask queue flushes even + // under fake timers when shouldAdvanceTime is true). + await waitFor(() => { + expect(apiMock.diagnosticsTail).toHaveBeenCalledTimes(1); + }); + + // Advance by one poll interval and let the queued microtasks settle. + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(apiMock.diagnosticsTail).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); +}); diff --git a/app/packages/web/src/components/DiagnosticsPanel.tsx b/app/packages/web/src/components/DiagnosticsPanel.tsx new file mode 100644 index 0000000..36f6eb3 --- /dev/null +++ b/app/packages/web/src/components/DiagnosticsPanel.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../api.service.js'; + +const POLL_INTERVAL_MS = 5_000; + +/** + * DiagnosticsPanel — shows the last 500 lines of the server's diagnostics log + * file, polls every 5 seconds for new lines, and autoscrolls to the bottom on + * each refresh. Displays the log file path reported by the server. + */ +export function DiagnosticsPanel() { + const [lines, setLines] = useState([]); + const [logPath, setLogPath] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const scrollRef = useRef(null); + + /** Fetch lines (and path on the first call) then autoscroll. */ + async function fetchData(isFirstFetch: boolean) { + try { + const [tailResult, pathResult] = await Promise.all([ + api.diagnosticsTail(), + isFirstFetch ? api.diagnosticsLogPath() : Promise.resolve(null), + ]); + setLines(tailResult.lines); + if (pathResult) setLogPath(pathResult.path); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load diagnostics'); + } finally { + if (isFirstFetch) setLoading(false); + } + } + + useEffect(() => { + let cancelled = false; + let intervalId: ReturnType | null = null; + + void (async () => { + await fetchData(true); + if (cancelled) return; + + intervalId = setInterval(() => { + if (!cancelled) void fetchData(false); + }, POLL_INTERVAL_MS); + })(); + + return () => { + cancelled = true; + if (intervalId !== null) clearInterval(intervalId); + }; + }, []); + + // Autoscroll to the bottom whenever lines change. + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [lines]); + + if (loading) { + return ( +
+ Loading diagnostics… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {logPath && ( +

+ Log file: {logPath} +

+ )} +
+ {lines.length === 0 ? ( + No log lines available. + ) : ( + lines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+ ); +} From a52f23e01ffada781cc527e8f53a456d6d58a240 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 14:52:24 -0400 Subject: [PATCH 09/19] feat(web): T9 - add Diagnostics section to SettingsPage with DiagnosticsPanel Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/web/src/pages/settings.page.test.tsx | 15 +++++++++++++++ app/packages/web/src/pages/settings.page.tsx | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/packages/web/src/pages/settings.page.test.tsx b/app/packages/web/src/pages/settings.page.test.tsx index b04d883..c293df4 100644 --- a/app/packages/web/src/pages/settings.page.test.tsx +++ b/app/packages/web/src/pages/settings.page.test.tsx @@ -6,8 +6,13 @@ const apiMock = vi.hoisted(() => ({ costsEstimate: vi.fn(), config: vi.fn(), saveConfig: vi.fn(), + diagnosticsTail: vi.fn(), + diagnosticsLogPath: vi.fn(), })); vi.mock('../api.service.js', () => ({ api: apiMock })); +vi.mock('../components/DiagnosticsPanel.js', () => ({ + DiagnosticsPanel: () =>
DiagnosticsPanel
, +})); import { SettingsPage } from './settings.page.js'; import { renderPage } from '../test-utils/render-page.utils.js'; @@ -37,4 +42,14 @@ describe('SettingsPage', () => { renderPage(, { initialEntries: ['/settings'] }); expect(await screen.findByText(/^Updated\b/)).toBeInTheDocument(); }); + + it('should render the Diagnostics section heading', () => { + renderPage(, { initialEntries: ['/settings'] }); + expect(screen.getByRole('heading', { name: 'Diagnostics' })).toBeInTheDocument(); + }); + + it('should render the DiagnosticsPanel inside the Diagnostics section', () => { + renderPage(, { initialEntries: ['/settings'] }); + expect(screen.getByTestId('diagnostics-panel')).toBeInTheDocument(); + }); }); diff --git a/app/packages/web/src/pages/settings.page.tsx b/app/packages/web/src/pages/settings.page.tsx index 04a585c..c922922 100644 --- a/app/packages/web/src/pages/settings.page.tsx +++ b/app/packages/web/src/pages/settings.page.tsx @@ -1,3 +1,4 @@ +import { DiagnosticsPanel } from '../components/DiagnosticsPanel.js'; import { WatchdogPanel } from '../components/watchdog-panel.component.js'; import { PollingIndicator } from '../polling/polling-indicator.component.js'; @@ -20,12 +21,18 @@ export function SettingsPage() { {/* General settings placeholder */} -
+

General

Additional configuration options will appear here in future updates.

+ + {/* Diagnostics section */} +
+

Diagnostics

+ +
); } From 51c117fab6eb9836f01f4c5c45851d93fc5bb536 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:15:21 -0400 Subject: [PATCH 10/19] perf(desktop-main): tail log from end-of-file to avoid full read on each poll readTail() previously read the entire log file with readFile() on every 5-second poll. Replaced with fs.open/FileHandle.read to seek directly to max(0, size - 200 KB) and read only the tail window, capping memory and I/O regardless of how large the daily log grows. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/DiagnosticsService.test.ts | 60 +++++++++++++++---- .../src/services/DiagnosticsService.ts | 28 +++++---- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts index 8bfe499..fc2e3e6 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts @@ -1,20 +1,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('node:fs/promises', () => ({ - readFile: vi.fn(), + open: vi.fn(), })); import * as fsPromises from 'node:fs/promises'; import { DiagnosticsService } from './DiagnosticsService.js'; -/** Typed handle to the mocked readFile so tests can configure it cleanly. */ -const mockReadFile = vi.mocked(fsPromises.readFile); +/** The tail window constant duplicated here so tests can construct oversized content. */ +const TAIL_READ_BYTES = 200 * 1024; + +const mockOpen = vi.mocked(fsPromises.open); /** Construct a fresh DiagnosticsService pointed at a predictable log directory. */ function makeService(logDir = '/var/log/hyveon'): DiagnosticsService { return new DiagnosticsService(logDir); } +/** + * Returns a mock FileHandle that serves `fullContent` exactly as the real + * fs.open/read/stat would: stat reports the full file size, read fills the + * caller-allocated buffer from the requested offset. + */ +function makeMockHandle(fullContent: string) { + const fullBuf = Buffer.from(fullContent, 'utf-8'); + const size = fullBuf.length; + return { + stat: vi.fn().mockResolvedValue({ size }), + read: vi.fn().mockImplementation((buf: Buffer, _offset: number, _len: number, position: number) => { + fullBuf.copy(buf, 0, position, position + buf.length); + return Promise.resolve({ bytesRead: buf.length }); + }), + close: vi.fn().mockResolvedValue(undefined), + }; +} + describe('DiagnosticsService.getTodayLogPath', () => { it('should return a date-stamped path matching main-YYYY-MM-DD.log in the configured logDir', () => { const service = makeService('/var/log/hyveon'); @@ -40,13 +60,13 @@ describe('DiagnosticsService.readTail', () => { let service: DiagnosticsService; beforeEach(() => { - mockReadFile.mockReset(); + mockOpen.mockReset(); service = makeService('/var/log/hyveon'); }); it('should return an empty array when the log file does not yet exist', async () => { const err = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); - mockReadFile.mockRejectedValueOnce(err); + mockOpen.mockRejectedValueOnce(err); const result = await service.readTail(); expect(result).toEqual([]); @@ -54,13 +74,14 @@ describe('DiagnosticsService.readTail', () => { it('should re-throw errors that are not ENOENT', async () => { const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - mockReadFile.mockRejectedValueOnce(err); + mockOpen.mockRejectedValueOnce(err); await expect(service.readTail()).rejects.toThrow('EACCES'); }); it('should return all lines when the file has fewer lines than maxLines', async () => { - mockReadFile.mockResolvedValueOnce('line1\nline2\nline3\n' as unknown as Buffer); + const handle = makeMockHandle('line1\nline2\nline3\n'); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); const result = await service.readTail(500); expect(result).toEqual(['line1', 'line2', 'line3']); @@ -68,7 +89,8 @@ describe('DiagnosticsService.readTail', () => { it('should return only the last N lines when the file has more lines than maxLines', async () => { const allLines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`); - mockReadFile.mockResolvedValueOnce((allLines.join('\n') + '\n') as unknown as Buffer); + const handle = makeMockHandle(allLines.join('\n') + '\n'); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); const result = await service.readTail(5); expect(result).toEqual(['line16', 'line17', 'line18', 'line19', 'line20']); @@ -76,7 +98,8 @@ describe('DiagnosticsService.readTail', () => { it('should default to returning at most 500 lines', async () => { const allLines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`); - mockReadFile.mockResolvedValueOnce((allLines.join('\n') + '\n') as unknown as Buffer); + const handle = makeMockHandle(allLines.join('\n') + '\n'); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); const result = await service.readTail(); expect(result).toHaveLength(500); @@ -85,9 +108,26 @@ describe('DiagnosticsService.readTail', () => { }); it('should strip a trailing empty string caused by a trailing newline', async () => { - mockReadFile.mockResolvedValueOnce('alpha\nbeta\n' as unknown as Buffer); + const handle = makeMockHandle('alpha\nbeta\n'); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); const result = await service.readTail(); expect(result).toEqual(['alpha', 'beta']); }); + + it('should drop the first partial line when reading from a mid-file offset', async () => { + // Construct content just over TAIL_READ_BYTES so the implementation seeks + // past the beginning and the first line in the read window is incomplete. + const prefix = 'partial-line-prefix\n'; + const padding = 'x'.repeat(TAIL_READ_BYTES - prefix.length + 1) + '\n'; + const tail = 'first-full-line\nsecond-full-line\n'; + const handle = makeMockHandle(prefix + padding + tail); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); + + const result = await service.readTail(10); + // The padding straddles the read window boundary so its partial fragment is + // dropped. Both 'first-full-line' and 'second-full-line' are complete within + // the window and must survive. + expect(result).toEqual(['first-full-line', 'second-full-line']); + }); }); diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.ts b/app/packages/desktop-main/src/services/DiagnosticsService.ts index 541c913..c45bad3 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.ts @@ -2,6 +2,9 @@ import { Injectable, Inject } from '@nestjs/common'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +/** Maximum bytes read from the end of the log file per tail call (~200 KB covers ~500 typical log lines). */ +const TAIL_READ_BYTES = 200 * 1024; + /** Injection token for the directory where DailyRotateFile writes logs. */ export const DIAGNOSTICS_LOG_DIR = 'DIAGNOSTICS_LOG_DIR'; @@ -38,23 +41,26 @@ export class DiagnosticsService { */ async readTail(maxLines = 500): Promise { const filePath = this.getTodayLogPath(); - let content: string; + let fh: fs.FileHandle | undefined; try { - content = await fs.readFile(filePath, 'utf-8'); + fh = await fs.open(filePath, 'r'); + const { size } = await fh.stat(); + const offset = Math.max(0, size - TAIL_READ_BYTES); + const buf = Buffer.alloc(size - offset); + await fh.read(buf, 0, buf.length, offset); + const content = buf.toString('utf-8'); + const lines = content.split('\n'); + // When reading from a mid-file offset the first line is likely a partial line — drop it. + const trimmed = offset > 0 ? lines.slice(1) : lines; + if (trimmed.at(-1) === '') trimmed.pop(); + return trimmed.slice(-maxLines); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return []; } throw err; + } finally { + await fh?.close(); } - - const lines = content.split('\n').filter((line, idx, arr) => { - // Drop trailing empty strings that arise from a trailing newline, - // but only at the very end of the array. - if (line === '' && idx === arr.length - 1) return false; - return true; - }); - - return lines.slice(-maxLines); } } From 3f635f25c1522593e663ec63ce8c4047ada88b7b Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:24:23 -0400 Subject: [PATCH 11/19] fix(desktop-main): guard Electron API usage, fix bytesRead, lazy electron import - main.ts: move process.versions['electron'] guard above app.getPath() so a friendly error is raised before any Electron API is touched in non-Electron envs - app.module.ts: remove static `import { app } from 'electron'`; DIAGNOSTICS_LOG_DIR factory now uses createRequire lazily, guarded by process.versions['electron'], with DIAGNOSTICS_LOG_DIR env-var fallback for non-Electron contexts (test-main) - DiagnosticsService: use bytesRead from FileHandle.read() to slice the buffer before toString(), preventing null-byte corruption on short reads Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/app.module.ts | 11 +++++++++-- app/packages/desktop-main/src/main.ts | 4 +++- .../desktop-main/src/services/DiagnosticsService.ts | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/packages/desktop-main/src/app.module.ts b/app/packages/desktop-main/src/app.module.ts index d5f3dae..36e0afb 100644 --- a/app/packages/desktop-main/src/app.module.ts +++ b/app/packages/desktop-main/src/app.module.ts @@ -1,7 +1,7 @@ import * as path from 'node:path'; +import { createRequire } from 'module'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; -import { app as electronApp } from 'electron'; import { AwsModule } from './modules/aws.module.js'; import { DiscordModule } from './modules/discord.module.js'; import { GamesController } from './controllers/games.controller.js'; @@ -39,7 +39,14 @@ import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsS { provide: APP_GUARD, useClass: ApiTokenGuard }, { provide: DIAGNOSTICS_LOG_DIR, - useFactory: () => path.join(electronApp.getPath('userData'), 'logs'), + useFactory: () => { + if (!process.versions['electron']) { + return process.env['DIAGNOSTICS_LOG_DIR'] ?? ''; + } + const _require = createRequire(import.meta.url); + const { app } = _require('electron') as { app: { getPath(name: string): string } }; + return path.join(app.getPath('userData'), 'logs'); + }, }, DiagnosticsService, ], diff --git a/app/packages/desktop-main/src/main.ts b/app/packages/desktop-main/src/main.ts index a0405ef..272f7d4 100644 --- a/app/packages/desktop-main/src/main.ts +++ b/app/packages/desktop-main/src/main.ts @@ -9,11 +9,11 @@ import { applyFixPath } from './fix-path-bootstrap.js'; import { createLogger } from './logger.js'; applyFixPath(); -createLogger(path.join(app.getPath('userData'), 'logs')); // ElectronIPCTransport requires ipcMain, which is only available inside an // Electron main process. Fail fast with a readable message rather than a // cryptic module-resolution error when someone runs `node dist/main.js`. +// This guard must run before any Electron API calls (e.g. app.getPath). if (!process.versions['electron']) { throw new Error( 'desktop-main must run inside an Electron main process. ' + @@ -21,6 +21,8 @@ if (!process.versions['electron']) { ); } +createLogger(path.join(app.getPath('userData'), 'logs')); + async function bootstrap(): Promise { const app = await NestFactory.createMicroservice(AppModule, { strategy: new ElectronIPCTransport(), diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.ts b/app/packages/desktop-main/src/services/DiagnosticsService.ts index c45bad3..e85733c 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.ts @@ -47,8 +47,8 @@ export class DiagnosticsService { const { size } = await fh.stat(); const offset = Math.max(0, size - TAIL_READ_BYTES); const buf = Buffer.alloc(size - offset); - await fh.read(buf, 0, buf.length, offset); - const content = buf.toString('utf-8'); + const { bytesRead } = await fh.read(buf, 0, buf.length, offset); + const content = buf.subarray(0, bytesRead).toString('utf-8'); const lines = content.split('\n'); // When reading from a mid-file offset the first line is likely a partial line — drop it. const trimmed = offset > 0 ? lines.slice(1) : lines; From f8d64a028bd3951b03fde1b2bbaea5b52646860a Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:34:45 -0400 Subject: [PATCH 12/19] fix(desktop-main): per-transport formats and cancel-safe fetchData Split logger formats so Console gets colorized output in dev while DailyRotateFile uses a plain format with no ANSI escape codes, keeping log files safe for parsers and the diagnostics tail endpoint. Guard DiagnosticsPanel fetchData state updates behind the cancellation flag so in-flight requests resolved after unmount do not attempt to set state on an unmounted component. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/logger.ts | 34 ++++++++++--------- .../web/src/components/DiagnosticsPanel.tsx | 12 ++++--- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/packages/desktop-main/src/logger.ts b/app/packages/desktop-main/src/logger.ts index aefc825..5c97207 100644 --- a/app/packages/desktop-main/src/logger.ts +++ b/app/packages/desktop-main/src/logger.ts @@ -3,23 +3,25 @@ import DailyRotateFile from 'winston-daily-rotate-file'; const isDev = process.env['NODE_ENV'] !== 'production'; -/** Shared format used by all transports. */ -const sharedFormat = isDev +const devPrintf = winston.format.printf((info) => { + const { timestamp, level, message, ...meta } = info as Record; + const metaStr = Object.keys(meta).length ? '\n' + JSON.stringify(meta, null, 2) : ''; + return `${timestamp} [${level}] ${message}${metaStr}`; +}); + +/** Format for the Console transport — colorized in dev for readability. */ +const consoleFormat = isDev ? winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'HH:mm:ss' }), - winston.format.printf((info) => { - const { timestamp, level, message, ...meta } = info as Record; - const metaStr = Object.keys(meta).length - ? '\n' + JSON.stringify(meta, null, 2) - : ''; - return `${timestamp} [${level}] ${message}${metaStr}`; - }), + devPrintf, ) - : winston.format.combine( - winston.format.timestamp(), - winston.format.json(), - ); + : winston.format.combine(winston.format.timestamp(), winston.format.json()); + +/** Format for file transports — no ANSI escape codes, safe for log parsers. */ +const fileFormat = isDev + ? winston.format.combine(winston.format.timestamp({ format: 'HH:mm:ss' }), devPrintf) + : winston.format.combine(winston.format.timestamp(), winston.format.json()); /** * Creates a console-only fallback logger used as the initial singleton value @@ -28,7 +30,7 @@ const sharedFormat = isDev function createConsoleOnlyLogger(): winston.Logger { return winston.createLogger({ level: isDev ? 'debug' : 'info', - format: sharedFormat, + format: consoleFormat, transports: [new winston.transports.Console()], }); } @@ -52,10 +54,10 @@ export let logger: winston.Logger = createConsoleOnlyLogger(); export function createLogger(logDir: string): winston.Logger { const newLogger = winston.createLogger({ level: isDev ? 'debug' : 'info', - format: sharedFormat, transports: [ - new winston.transports.Console(), + new winston.transports.Console({ format: consoleFormat }), new DailyRotateFile({ + format: fileFormat, dirname: logDir, filename: 'main-%DATE%.log', datePattern: 'YYYY-MM-DD', diff --git a/app/packages/web/src/components/DiagnosticsPanel.tsx b/app/packages/web/src/components/DiagnosticsPanel.tsx index 36f6eb3..0bd4056 100644 --- a/app/packages/web/src/components/DiagnosticsPanel.tsx +++ b/app/packages/web/src/components/DiagnosticsPanel.tsx @@ -16,20 +16,22 @@ export function DiagnosticsPanel() { const scrollRef = useRef(null); - /** Fetch lines (and path on the first call) then autoscroll. */ - async function fetchData(isFirstFetch: boolean) { + /** Fetch lines (and path on the first call), skipping state updates if cancelled. */ + async function fetchData(isFirstFetch: boolean, isCancelled: () => boolean) { try { const [tailResult, pathResult] = await Promise.all([ api.diagnosticsTail(), isFirstFetch ? api.diagnosticsLogPath() : Promise.resolve(null), ]); + if (isCancelled()) return; setLines(tailResult.lines); if (pathResult) setLogPath(pathResult.path); setError(null); } catch (err) { + if (isCancelled()) return; setError(err instanceof Error ? err.message : 'Failed to load diagnostics'); } finally { - if (isFirstFetch) setLoading(false); + if (isFirstFetch && !isCancelled()) setLoading(false); } } @@ -38,11 +40,11 @@ export function DiagnosticsPanel() { let intervalId: ReturnType | null = null; void (async () => { - await fetchData(true); + await fetchData(true, () => cancelled); if (cancelled) return; intervalId = setInterval(() => { - if (!cancelled) void fetchData(false); + if (!cancelled) void fetchData(false, () => cancelled); }, POLL_INTERVAL_MS); })(); From f36452bcf10cfa1a4e61a957777e2ad7235a73ac Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:46:40 -0400 Subject: [PATCH 13/19] fix(desktop-main): lazy-load electron via dynamic import after guard Replace the static `import { app } from 'electron'` with a top-level `await import('electron')` placed after the `process.versions['electron']` guard. The npm electron package does not export `app` in plain Node environments, so the static import would silently give `undefined`. The dynamic import runs only when the guard has confirmed we are inside an Electron main process, preventing any evaluation failure before the friendly error message can be thrown. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/desktop-main/src/main.ts b/app/packages/desktop-main/src/main.ts index 272f7d4..052aba0 100644 --- a/app/packages/desktop-main/src/main.ts +++ b/app/packages/desktop-main/src/main.ts @@ -1,6 +1,5 @@ import 'reflect-metadata'; import path from 'node:path'; -import { app } from 'electron'; import { NestFactory } from '@nestjs/core'; import { MicroserviceOptions } from '@nestjs/microservices'; import { ElectronIPCTransport } from 'nestjs-electron-ipc-transport'; @@ -21,6 +20,7 @@ if (!process.versions['electron']) { ); } +const { app } = await import('electron') as unknown as { app: { getPath(name: string): string } }; createLogger(path.join(app.getPath('userData'), 'logs')); async function bootstrap(): Promise { From b6a23b43e0bc3960b5f53af856089645d4172a01 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:52:35 -0400 Subject: [PATCH 14/19] test(web): remove clearAllTimers from global afterEach in DiagnosticsPanel vi.clearAllTimers() must only be called while fake timers are active. The polling test already calls vi.useRealTimers() at the end of the test body, and RTL cleanup handles component unmount for all other tests, so the global afterEach call is unnecessary. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/web/src/components/DiagnosticsPanel.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/packages/web/src/components/DiagnosticsPanel.test.tsx b/app/packages/web/src/components/DiagnosticsPanel.test.tsx index b38cd1d..10770c8 100644 --- a/app/packages/web/src/components/DiagnosticsPanel.test.tsx +++ b/app/packages/web/src/components/DiagnosticsPanel.test.tsx @@ -27,7 +27,6 @@ describe('DiagnosticsPanel', () => { }); afterEach(() => { - vi.clearAllTimers(); vi.clearAllMocks(); }); From 3e82e4e01d5aed602b572dfdb905917e26c9cda9 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 16:58:43 -0400 Subject: [PATCH 15/19] fix(web): retry logPath fetch until successful in DiagnosticsPanel Remove the isFirstFetch gate on diagnosticsLogPath() so both tail and path are fetched together on every poll. If the initial fetch fails the path is retried on the next interval tick rather than remaining permanently empty. setLoading(false) is now called in the effect IIFE after the first fetchData resolves (success or error), keeping the loading-state semantics intact. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/components/DiagnosticsPanel.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/packages/web/src/components/DiagnosticsPanel.tsx b/app/packages/web/src/components/DiagnosticsPanel.tsx index 0bd4056..360622f 100644 --- a/app/packages/web/src/components/DiagnosticsPanel.tsx +++ b/app/packages/web/src/components/DiagnosticsPanel.tsx @@ -16,22 +16,20 @@ export function DiagnosticsPanel() { const scrollRef = useRef(null); - /** Fetch lines (and path on the first call), skipping state updates if cancelled. */ - async function fetchData(isFirstFetch: boolean, isCancelled: () => boolean) { + /** Fetch tail lines and log path, skipping state updates if cancelled. */ + async function fetchData(isCancelled: () => boolean) { try { const [tailResult, pathResult] = await Promise.all([ api.diagnosticsTail(), - isFirstFetch ? api.diagnosticsLogPath() : Promise.resolve(null), + api.diagnosticsLogPath(), ]); if (isCancelled()) return; setLines(tailResult.lines); - if (pathResult) setLogPath(pathResult.path); + setLogPath(pathResult.path); setError(null); } catch (err) { if (isCancelled()) return; setError(err instanceof Error ? err.message : 'Failed to load diagnostics'); - } finally { - if (isFirstFetch && !isCancelled()) setLoading(false); } } @@ -40,11 +38,12 @@ export function DiagnosticsPanel() { let intervalId: ReturnType | null = null; void (async () => { - await fetchData(true, () => cancelled); + await fetchData(() => cancelled); if (cancelled) return; + setLoading(false); intervalId = setInterval(() => { - if (!cancelled) void fetchData(false, () => cancelled); + if (!cancelled) void fetchData(() => cancelled); }, POLL_INTERVAL_MS); })(); From 8c6dd60a83b947f39090529b993b906b184992a0 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 17:04:43 -0400 Subject: [PATCH 16/19] fix(desktop-main): default DIAGNOSTICS_LOG_DIR to os.tmpdir() in non-Electron Falling back to '' caused getTodayLogPath() to return a relative path, which could accidentally open a same-named file from the process CWD instead of returning ENOENT. os.tmpdir() is an absolute, writable directory that is unlikely to contain a matching log file, so readTail() safely returns [] in non-Electron / test environments. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/app.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/packages/desktop-main/src/app.module.ts b/app/packages/desktop-main/src/app.module.ts index 36e0afb..80e7cb8 100644 --- a/app/packages/desktop-main/src/app.module.ts +++ b/app/packages/desktop-main/src/app.module.ts @@ -1,3 +1,4 @@ +import * as os from 'node:os'; import * as path from 'node:path'; import { createRequire } from 'module'; import { Module } from '@nestjs/common'; @@ -41,7 +42,7 @@ import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsS provide: DIAGNOSTICS_LOG_DIR, useFactory: () => { if (!process.versions['electron']) { - return process.env['DIAGNOSTICS_LOG_DIR'] ?? ''; + return process.env['DIAGNOSTICS_LOG_DIR'] ?? os.tmpdir(); } const _require = createRequire(import.meta.url); const { app } = _require('electron') as { app: { getPath(name: string): string } }; From f4de79ca96ecfbdd65d612b05a9d08e2b97fab3f Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 17:18:29 -0400 Subject: [PATCH 17/19] fix(desktop-main): peek one byte before window to avoid dropping complete first line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When offset > 0, readTail() now reads from offset-1 rather than offset. The extra byte ensures the first split element is always either '' (offset landed on a newline) or a partial fragment (offset landed mid-line) — safe to drop in both cases. Previously, if the offset coincided exactly with the start of a complete line, that line was incorrectly discarded. Adds a test asserting the first line at an exact boundary survives. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/DiagnosticsService.test.ts | 13 +++++++++++++ .../desktop-main/src/services/DiagnosticsService.ts | 12 ++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts index fc2e3e6..f7926d8 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts @@ -130,4 +130,17 @@ describe('DiagnosticsService.readTail', () => { // the window and must survive. expect(result).toEqual(['first-full-line', 'second-full-line']); }); + + it('should not drop the first line when the offset lands exactly on a newline boundary', async () => { + // Place the offset precisely at a newline so the peek-back byte is '\n'. + // The first line after the boundary ('boundary-line') is complete and must not be dropped. + const head = 'x'.repeat(TAIL_READ_BYTES) + '\n'; + const tail = 'boundary-line\nnext-line\n'; + const handle = makeMockHandle(head + tail); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); + + const result = await service.readTail(10); + expect(result).toContain('boundary-line'); + expect(result).toContain('next-line'); + }); }); diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.ts b/app/packages/desktop-main/src/services/DiagnosticsService.ts index e85733c..8cca728 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.ts @@ -46,12 +46,16 @@ export class DiagnosticsService { fh = await fs.open(filePath, 'r'); const { size } = await fh.stat(); const offset = Math.max(0, size - TAIL_READ_BYTES); - const buf = Buffer.alloc(size - offset); - const { bytesRead } = await fh.read(buf, 0, buf.length, offset); + // Peek one byte before the window so the first split element is always either + // an empty string (offset landed on a newline) or a partial fragment (offset + // landed mid-line) — both are safe to drop, eliminating the risk of discarding + // a complete first line when the offset coincides with a line boundary. + const readFrom = Math.max(0, offset - 1); + const buf = Buffer.alloc(size - readFrom); + const { bytesRead } = await fh.read(buf, 0, buf.length, readFrom); const content = buf.subarray(0, bytesRead).toString('utf-8'); const lines = content.split('\n'); - // When reading from a mid-file offset the first line is likely a partial line — drop it. - const trimmed = offset > 0 ? lines.slice(1) : lines; + const trimmed = readFrom > 0 ? lines.slice(1) : lines; if (trimmed.at(-1) === '') trimmed.pop(); return trimmed.slice(-maxLines); } catch (err) { From a1133ec5fb24f2c91e77c2c5b53d99a8375f5981 Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 17:30:41 -0400 Subject: [PATCH 18/19] test(desktop-main): use path.join/dirname in getTodayLogPath assertions Replace hard-coded POSIX separators with node:path so the assertions are platform-independent on Windows as well as POSIX systems. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- .../desktop-main/src/services/DiagnosticsService.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts index f7926d8..82e95d7 100644 --- a/app/packages/desktop-main/src/services/DiagnosticsService.test.ts +++ b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; vi.mock('node:fs/promises', () => ({ open: vi.fn(), @@ -46,13 +47,14 @@ describe('DiagnosticsService.getTodayLogPath', () => { const dd = String(now.getDate()).padStart(2, '0'); const expectedFilename = `main-${yyyy}-${mm}-${dd}.log`; - expect(logPath).toBe(`/var/log/hyveon/${expectedFilename}`); + expect(logPath).toBe(path.join('/var/log/hyveon', expectedFilename)); }); it('should use the logDir supplied via the injection token', () => { - const service = makeService('/custom/logs'); + const logDir = '/custom/logs'; + const service = makeService(logDir); const logPath = service.getTodayLogPath(); - expect(logPath.startsWith('/custom/logs/')).toBe(true); + expect(path.dirname(logPath)).toBe(logDir); }); }); From a5c82118a45b25205fdec4daebd2acbba3f964ff Mon Sep 17 00:00:00 2001 From: CoderCoco Date: Sat, 23 May 2026 17:44:09 -0400 Subject: [PATCH 19/19] test(desktop-main): remove empty beforeEach from logger singleton suite The block had a stale comment claiming it reset module state but its body was empty. `initialLogger` is captured at static import time (before any test runs) so isolation is already guaranteed; no reset hook is needed. Refs #149. Co-Authored-By: Claude Sonnet 4.6 --- app/packages/desktop-main/src/logger.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/packages/desktop-main/src/logger.test.ts b/app/packages/desktop-main/src/logger.test.ts index 37a7b0f..ebe8784 100644 --- a/app/packages/desktop-main/src/logger.test.ts +++ b/app/packages/desktop-main/src/logger.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; @@ -51,10 +51,6 @@ describe('createLogger', () => { }); describe('logger singleton', () => { - beforeEach(() => { - // Reset to console-only by re-creating; each test starts fresh. - }); - it('should reassign the exported logger binding when createLogger is called', async () => { // Capture the initial (console-only) reference. const beforeCall = initialLogger;