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/app/packages/desktop-main/src/app.module.ts b/app/packages/desktop-main/src/app.module.ts index 865af11..80e7cb8 100644 --- a/app/packages/desktop-main/src/app.module.ts +++ b/app/packages/desktop-main/src/app.module.ts @@ -1,3 +1,6 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createRequire } from 'module'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { AwsModule } from './modules/aws.module.js'; @@ -9,7 +12,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 +34,22 @@ import { ApiTokenGuard } from './guards/api-token.guard.js'; FilesController, DiscordController, EnvController, + DiagnosticsController, + ], + providers: [ + { provide: APP_GUARD, useClass: ApiTokenGuard }, + { + provide: DIAGNOSTICS_LOG_DIR, + useFactory: () => { + if (!process.versions['electron']) { + return process.env['DIAGNOSTICS_LOG_DIR'] ?? os.tmpdir(); + } + const _require = createRequire(import.meta.url); + const { app } = _require('electron') as { app: { getPath(name: string): string } }; + return path.join(app.getPath('userData'), 'logs'); + }, + }, + DiagnosticsService, ], - providers: [{ provide: APP_GUARD, useClass: ApiTokenGuard }], }) export class AppModule {} 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() }; + } +} 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..ebe8784 --- /dev/null +++ b/app/packages/desktop-main/src/logger.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } 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', () => { + 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..5c97207 100644 --- a/app/packages/desktop-main/src/logger.ts +++ b/app/packages/desktop-main/src/logger.ts @@ -1,24 +1,72 @@ 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()], +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' }), + devPrintf, + ) + : 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 + * before {@link createLogger} is called by main.ts. + */ +function createConsoleOnlyLogger(): winston.Logger { + return winston.createLogger({ + level: isDev ? 'debug' : 'info', + format: consoleFormat, + 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', + transports: [ + new winston.transports.Console({ format: consoleFormat }), + new DailyRotateFile({ + format: fileFormat, + 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; +} 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..052aba0 100644 --- a/app/packages/desktop-main/src/main.ts +++ b/app/packages/desktop-main/src/main.ts @@ -1,15 +1,18 @@ import 'reflect-metadata'; +import path from 'node:path'; 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(); // 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. ' + @@ -17,6 +20,9 @@ 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 { const app = await NestFactory.createMicroservice(AppModule, { strategy: new ElectronIPCTransport(), 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..82e95d7 --- /dev/null +++ b/app/packages/desktop-main/src/services/DiagnosticsService.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; + +vi.mock('node:fs/promises', () => ({ + open: vi.fn(), +})); + +import * as fsPromises from 'node:fs/promises'; +import { DiagnosticsService } from './DiagnosticsService.js'; + +/** 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'); + 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(path.join('/var/log/hyveon', expectedFilename)); + }); + + it('should use the logDir supplied via the injection token', () => { + const logDir = '/custom/logs'; + const service = makeService(logDir); + const logPath = service.getTodayLogPath(); + expect(path.dirname(logPath)).toBe(logDir); + }); +}); + +describe('DiagnosticsService.readTail', () => { + let service: DiagnosticsService; + + beforeEach(() => { + 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' }); + mockOpen.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' }); + mockOpen.mockRejectedValueOnce(err); + + await expect(service.readTail()).rejects.toThrow('EACCES'); + }); + + it('should return all lines when the file has fewer lines than maxLines', async () => { + 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']); + }); + + 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}`); + 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']); + }); + + it('should default to returning at most 500 lines', async () => { + const allLines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`); + const handle = makeMockHandle(allLines.join('\n') + '\n'); + mockOpen.mockResolvedValueOnce(handle as unknown as fsPromises.FileHandle); + + 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 () => { + 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']); + }); + + 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 new file mode 100644 index 0000000..8cca728 --- /dev/null +++ b/app/packages/desktop-main/src/services/DiagnosticsService.ts @@ -0,0 +1,70 @@ +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'; + +/** + * 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 fh: fs.FileHandle | undefined; + try { + fh = await fs.open(filePath, 'r'); + const { size } = await fh.stat(); + const offset = Math.max(0, size - TAIL_READ_BYTES); + // 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'); + const trimmed = readFrom > 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(); + } + } +} 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'), }; 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..10770c8 --- /dev/null +++ b/app/packages/web/src/components/DiagnosticsPanel.test.tsx @@ -0,0 +1,96 @@ +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.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..360622f --- /dev/null +++ b/app/packages/web/src/components/DiagnosticsPanel.tsx @@ -0,0 +1,108 @@ +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 tail lines and log path, skipping state updates if cancelled. */ + async function fetchData(isCancelled: () => boolean) { + try { + const [tailResult, pathResult] = await Promise.all([ + api.diagnosticsTail(), + api.diagnosticsLogPath(), + ]); + if (isCancelled()) return; + setLines(tailResult.lines); + setLogPath(pathResult.path); + setError(null); + } catch (err) { + if (isCancelled()) return; + setError(err instanceof Error ? err.message : 'Failed to load diagnostics'); + } + } + + useEffect(() => { + let cancelled = false; + let intervalId: ReturnType | null = null; + + void (async () => { + await fetchData(() => cancelled); + if (cancelled) return; + setLoading(false); + + intervalId = setInterval(() => { + if (!cancelled) void fetchData(() => cancelled); + }, 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} +
+ )) + )} +
+
+ ); +} 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

+ +
); } 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",