From f35c85d8cc1b6e9e3e44ddcb07dc6667be7309f6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:32:34 -0400 Subject: [PATCH 1/2] feat(settings): add manager logs viewer with backend log capture --- backend/src/index.ts | 2 + backend/src/routes/logs.ts | 17 + .../src/services/opencode-single-server.ts | 14 +- backend/src/utils/log-buffer.test.ts | 252 +++++++++ backend/src/utils/log-buffer.ts | 176 +++++++ backend/src/utils/logger.test.ts | 59 +++ backend/src/utils/logger.ts | 26 +- backend/test/routes/logs.test.ts | 100 ++++ .../services/opencode-single-server.test.ts | 61 ++- docs/features/logs.md | 38 ++ docs/features/overview.md | 9 + docs/index.md | 1 + docs/troubleshooting.md | 4 +- frontend/src/api/logs.ts | 22 + .../components/settings/LogsViewer.test.tsx | 98 ++++ .../src/components/settings/LogsViewer.tsx | 160 ++++++ .../settings/SettingsDialog.test.tsx | 205 +++++++- .../components/settings/SettingsDialog.tsx | 59 ++- .../settings/SettingsDialogLogs.test.tsx | 145 ++++++ frontend/src/hooks/useManagerLogs.test.tsx | 488 ++++++++++++++++++ frontend/src/hooks/useManagerLogs.ts | 117 +++++ frontend/src/hooks/useMediaQuery.ts | 25 + frontend/src/hooks/useSettingsDialog.test.tsx | 15 + frontend/src/hooks/useSettingsDialog.ts | 20 +- mkdocs.yml | 1 + shared/src/config/defaults.ts | 8 + shared/src/schemas/index.ts | 1 + shared/src/schemas/logs.ts | 37 ++ 28 files changed, 2121 insertions(+), 39 deletions(-) create mode 100644 backend/src/routes/logs.ts create mode 100644 backend/src/utils/log-buffer.test.ts create mode 100644 backend/src/utils/log-buffer.ts create mode 100644 backend/src/utils/logger.test.ts create mode 100644 backend/test/routes/logs.test.ts create mode 100644 docs/features/logs.md create mode 100644 frontend/src/api/logs.ts create mode 100644 frontend/src/components/settings/LogsViewer.test.tsx create mode 100644 frontend/src/components/settings/LogsViewer.tsx create mode 100644 frontend/src/components/settings/SettingsDialogLogs.test.tsx create mode 100644 frontend/src/hooks/useManagerLogs.test.tsx create mode 100644 frontend/src/hooks/useManagerLogs.ts create mode 100644 frontend/src/hooks/useMediaQuery.ts create mode 100644 shared/src/schemas/logs.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index ddbc7784d..fd9f76e1e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,7 @@ import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' +import { createLogRoutes } from './routes/logs' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' @@ -377,6 +378,7 @@ protectedApi.route('/notifications', createNotificationRoutes(notificationServic protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) +protectedApi.route('/logs', createLogRoutes()) app.route('/api', protectedApi) diff --git a/backend/src/routes/logs.ts b/backend/src/routes/logs.ts new file mode 100644 index 000000000..1ef761480 --- /dev/null +++ b/backend/src/routes/logs.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono' +import { ManagerLogQuerySchema } from '@opencode-manager/shared/schemas' +import { readManagerLogEntries } from '../utils/log-buffer' + +export function createLogRoutes() { + const app = new Hono() + + app.get('/', (c) => { + const result = ManagerLogQuerySchema.safeParse(c.req.query()) + if (!result.success) { + return c.json({ error: 'Invalid request', details: result.error.issues }, 400) + } + return c.json(readManagerLogEntries(result.data)) + }) + + return app +} diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index df7a28e6d..efe98ee58 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -31,6 +31,7 @@ import { resolveProcessIdentityProvider } from './opencode/process-identity' import { SandboxRuntimeService } from './sandbox/runtime' import { CredentialProvider } from './credential-provider' import { mkdirSafe, writeFileAtomic } from '../utils/fs-safe' +import { createProcessLogForwarder } from '../utils/log-buffer' const MIN_OPENCODE_VERSION = '1.0.137' @@ -659,13 +660,24 @@ class OpenCodeServerManager { } ) + const openCodeStdoutLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + const openCodeStderrLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' }) + if (!isDevelopment && this.serverProcess.stderr) { this.serverProcess.stderr.on('data', (data) => { - stderrOutput += data.toString() + const text = data.toString() + stderrOutput += text if (stderrOutput.length > MAX_STDERR_SIZE) { stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE) } + openCodeStderrLog.write(data) }) + this.serverProcess.stderr.on('end', () => openCodeStderrLog.flush()) + } + + if (!isDevelopment && this.serverProcess.stdout) { + this.serverProcess.stdout.on('data', (data) => openCodeStdoutLog.write(data)) + this.serverProcess.stdout.on('end', () => openCodeStdoutLog.flush()) } const spawnedServerPid = this.serverProcess.pid diff --git a/backend/src/utils/log-buffer.test.ts b/backend/src/utils/log-buffer.test.ts new file mode 100644 index 000000000..38d63d01c --- /dev/null +++ b/backend/src/utils/log-buffer.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { DEFAULTS } from '@opencode-manager/shared/config' +import { + appendManagerLogEntry, + createProcessLogForwarder, + readManagerLogEntries, + resetManagerLogBuffer, +} from './log-buffer' + +describe('log buffer', () => { + beforeEach(() => { + resetManagerLogBuffer() + }) + + it('returns an appended entry with seq 1, ISO timestamp and latestSeq 1', () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'hello' }) + + const result = readManagerLogEntries({}) + expect(result.entries).toHaveLength(1) + const entry = result.entries.at(0) + expect(entry?.seq).toBe(1) + expect(entry?.level).toBe('info') + expect(entry?.source).toBe('manager') + expect(entry?.message).toBe('hello') + expect(() => new Date(entry?.timestamp ?? '').toISOString()).not.toThrow() + expect(result.latestSeq).toBe(1) + }) + + it('afterSeq returns only newer entries', () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'one' }) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'two' }) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'three' }) + + const result = readManagerLogEntries({ afterSeq: 2 }) + expect(result.entries.map((entry) => entry.message)).toEqual(['three']) + }) + + it('an afterSeq query with more than DEFAULT_PAGE_SIZE matches drains pages oldest-first without gaps', () => { + const total = DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 2 + for (let i = 0; i < total; i++) { + appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` }) + } + + const firstPage = readManagerLogEntries({ afterSeq: 0 }) + expect(firstPage.entries).toHaveLength(DEFAULTS.LOGS.DEFAULT_PAGE_SIZE) + expect(firstPage.entries[0]?.seq).toBe(1) + expect(firstPage.entries.at(-1)?.seq).toBe(DEFAULTS.LOGS.DEFAULT_PAGE_SIZE) + + const secondPage = readManagerLogEntries({ + afterSeq: firstPage.entries.at(-1)?.seq, + }) + expect(secondPage.entries.map((entry) => entry.seq)).toEqual([ + DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 1, + DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 2, + ]) + + const drainedSeqs = [...firstPage.entries, ...secondPage.entries].map((entry) => entry.seq) + expect(drainedSeqs).toHaveLength(total) + drainedSeqs.forEach((seq, index) => { + expect(seq).toBe(index + 1) + }) + }) + + it('keeps only the newest BUFFER_CAPACITY entries and reports evictions', () => { + const total = DEFAULTS.LOGS.BUFFER_CAPACITY + 5 + for (let i = 0; i < total; i++) { + appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` }) + } + + const newestPage = readManagerLogEntries({ limit: DEFAULTS.LOGS.MAX_PAGE_SIZE }) + expect(newestPage.dropped).toBe(5) + expect(newestPage.oldestSeq).toBe(6) + expect(newestPage.latestSeq).toBe(total) + expect(newestPage.entries.at(0)?.message).toBe(`entry-${total - DEFAULTS.LOGS.MAX_PAGE_SIZE}`) + expect(newestPage.entries.at(-1)?.message).toBe(`entry-${total - 1}`) + + const retainedCount = newestPage.latestSeq - newestPage.oldestSeq + 1 + expect(retainedCount).toBe(DEFAULTS.LOGS.BUFFER_CAPACITY) + }) + + it('level filter keeps minimum severity: warn returns warn and error only', () => { + appendManagerLogEntry({ level: 'debug', source: 'manager', message: 'debug' }) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'info' }) + appendManagerLogEntry({ level: 'warn', source: 'manager', message: 'warn' }) + appendManagerLogEntry({ level: 'error', source: 'manager', message: 'error' }) + + const result = readManagerLogEntries({ level: 'warn' }) + expect(result.entries.map((entry) => entry.level)).toEqual(['warn', 'error']) + }) + + it('source filter excludes other sources', () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'from manager' }) + appendManagerLogEntry({ level: 'info', source: 'opencode', message: 'from opencode' }) + + const result = readManagerLogEntries({ source: 'opencode' }) + expect(result.entries.map((entry) => entry.message)).toEqual(['from opencode']) + }) + + it('limit above MAX_PAGE_SIZE is capped to MAX_PAGE_SIZE newest matches', () => { + const total = DEFAULTS.LOGS.MAX_PAGE_SIZE + 10 + for (let i = 0; i < total; i++) { + appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` }) + } + + const result = readManagerLogEntries({ limit: DEFAULTS.LOGS.MAX_PAGE_SIZE * 10 }) + expect(result.entries).toHaveLength(DEFAULTS.LOGS.MAX_PAGE_SIZE) + expect(result.entries.at(0)?.message).toBe(`entry-${total - DEFAULTS.LOGS.MAX_PAGE_SIZE}`) + expect(result.entries.at(-1)?.message).toBe(`entry-${total - 1}`) + }) + + it('truncates messages longer than MAX_ENTRY_LENGTH with the truncated marker', () => { + const longMessage = 'x'.repeat(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + 100) + appendManagerLogEntry({ level: 'info', source: 'manager', message: longMessage }) + + const result = readManagerLogEntries({}) + expect(result.entries).toHaveLength(1) + const storedMessage = result.entries.at(0)?.message + expect(storedMessage?.endsWith(' …[truncated]')).toBe(true) + expect(storedMessage?.length).toBe(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + ' …[truncated]'.length) + }) +}) + +describe('process log forwarder', () => { + beforeEach(() => { + resetManagerLogBuffer() + }) + + it('emits one entry per complete line and nothing for a trailing partial line', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('first\nsecond\npartial') + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'first', + 'second', + ]) + + forwarder.write('\n') + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'first', + 'second', + 'partial', + ]) + }) + + it('joins a line split across two writes and emits it once', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('hel') + forwarder.write('lo\n') + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual(['hello']) + }) + + it('flush emits a trailing partial line', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('tail without newline') + forwarder.flush() + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'tail without newline', + ]) + }) + + it('skips blank and whitespace-only lines', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('\n \n\t\nreal line\n') + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual(['real line']) + }) + + it('preserves trailing spaces and tabs on nonblank lines', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('formatted value: \n\taligned diagnostic\t\n') + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'formatted value: ', + '\taligned diagnostic\t', + ]) + }) + + it('parses embedded levels and falls back to the forwarder default', () => { + const stdoutForwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + const stderrForwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' }) + + stdoutForwarder.write('ERROR boom\n') + stderrForwarder.write('INFO fine\n') + stderrForwarder.write('no level token here\n') + + expect(readManagerLogEntries({ source: 'opencode' }).entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'ERROR boom', level: 'error', source: 'opencode' }), + expect.objectContaining({ message: 'INFO fine', level: 'info', source: 'opencode' }), + expect.objectContaining({ message: 'no level token here', level: 'error', source: 'opencode' }), + ]), + ) + }) + + it('carries the configured source on every entry', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('one\ntwo\n') + + const result = readManagerLogEntries({ source: 'opencode' }) + expect(result.entries).toHaveLength(2) + expect(result.entries.every((entry) => entry.source === 'opencode')).toBe(true) + }) + + it('appends an endless unterminated line once it exceeds MAX_ENTRY_LENGTH instead of growing unbounded', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + forwarder.write('a'.repeat(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + 1)) + expect(readManagerLogEntries({ source: 'opencode' }).entries).toHaveLength(1) + + forwarder.write('b'.repeat(10)) + forwarder.flush() + + const entries = readManagerLogEntries({ source: 'opencode' }).entries + expect(entries).toHaveLength(2) + expect(entries[0]?.message.startsWith('a')).toBe(true) + expect(entries[1]?.message.startsWith('b')).toBe(true) + }) + + it('rejoins a multibyte UTF-8 character whose bytes are split across two writes', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + const bytes = new TextEncoder().encode('opencode log: 😀 done\n') + forwarder.write(bytes.slice(0, 16)) + forwarder.write(bytes.slice(16)) + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'opencode log: 😀 done', + ]) + }) + + it('flush finalizes a trailing incomplete UTF-8 sequence instead of losing it', () => { + const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) + const bytes = new TextEncoder().encode('warn: café') + forwarder.write(bytes.slice(0, bytes.length - 1)) + forwarder.flush() + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'warn: caf\uFFFD', + ]) + }) + + it('returns the same instanceId on every read and ignores buffer resets', () => { + const first = readManagerLogEntries({}) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'after reset' }) + resetManagerLogBuffer() + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'after reset' }) + const second = readManagerLogEntries({}) + + expect(first.instanceId).toMatch(/^[0-9a-f-]{36}$/i) + expect(second.instanceId).toBe(first.instanceId) + }) +}) diff --git a/backend/src/utils/log-buffer.ts b/backend/src/utils/log-buffer.ts new file mode 100644 index 000000000..71117ad09 --- /dev/null +++ b/backend/src/utils/log-buffer.ts @@ -0,0 +1,176 @@ +import { randomUUID } from 'node:crypto' +import { StringDecoder } from 'node:string_decoder' +import { DEFAULTS } from '@opencode-manager/shared/config' +import type { + ManagerLogEntry, + ManagerLogLevel, + ManagerLogQuery, + ManagerLogSource, + ManagerLogsResponse, +} from '@opencode-manager/shared/schemas' + +const instanceId = randomUUID() +const entries: ManagerLogEntry[] = [] +let nextSeq = 1 +let dropped = 0 + +const LEVEL_SEVERITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +} + +const TRUNCATION_SUFFIX = ' …[truncated]' + +export function stringifyLogArg(value: unknown): string { + if (value instanceof Error) { + return `${value.name}: ${value.message}\n${value.stack ?? ''}` + } + if (typeof value === 'string') { + return value + } + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +export function composeLogMessage(prefix: string, message: string, args: unknown[]): string { + const prefixed = prefix ? `[${prefix}] ${message}` : message + if (args.length === 0) { + return prefixed + } + return [prefixed, ...args.map(stringifyLogArg)].join(' ') +} + +export function appendManagerLogEntry(input: { + level: ManagerLogLevel + source: ManagerLogSource + message: string +}): void { + const message = + input.message.length > DEFAULTS.LOGS.MAX_ENTRY_LENGTH + ? input.message.slice(0, DEFAULTS.LOGS.MAX_ENTRY_LENGTH) + TRUNCATION_SUFFIX + : input.message + + entries.push({ + seq: nextSeq++, + timestamp: new Date().toISOString(), + level: input.level, + source: input.source, + message, + }) + + while (entries.length > DEFAULTS.LOGS.BUFFER_CAPACITY) { + entries.shift() + dropped++ + } +} + +const PROCESS_LOG_LEVEL_PATTERN = /^\s*\[?(DEBUG|INFO|WARN|WARNING|ERROR|FATAL)\b/i + +export function parseProcessLogLevel(line: string, fallback: ManagerLogLevel): ManagerLogLevel { + const match = line.match(PROCESS_LOG_LEVEL_PATTERN) + if (!match) { + return fallback + } + const token = match[1]?.toLowerCase() ?? '' + if (token === 'warning') { + return 'warn' + } + if (token === 'fatal') { + return 'error' + } + if (token === 'debug' || token === 'info' || token === 'warn' || token === 'error') { + return token + } + return fallback +} + +export function createProcessLogForwarder(options: { + source: ManagerLogSource + defaultLevel: ManagerLogLevel +}): { write: (chunk: Uint8Array | string) => void; flush: () => void } { + const decoder = new StringDecoder('utf8') + let remainder = '' + + const appendLine = (line: string) => { + if (line.trimEnd().length === 0) { + return + } + appendManagerLogEntry({ + source: options.source, + level: parseProcessLogLevel(line, options.defaultLevel), + message: line, + }) + } + + const appendDecodedText = (text: string) => { + const parts = (remainder + text).split(/\r?\n/) + remainder = parts.pop() ?? '' + for (const part of parts) { + appendLine(part) + } + if (remainder.length > DEFAULTS.LOGS.MAX_ENTRY_LENGTH) { + appendLine(remainder) + remainder = '' + } + } + + return { + write(chunk: Uint8Array | string): void { + appendDecodedText(decoder.write(chunk)) + }, + flush(): void { + remainder += decoder.end() + if (remainder.length === 0) { + return + } + appendLine(remainder) + remainder = '' + }, + } +} + +export function readManagerLogEntries(query: ManagerLogQuery): ManagerLogsResponse { + const afterSeq = query.afterSeq ?? 0 + const hasCursor = query.afterSeq !== undefined + const minSeverity = query.level ? LEVEL_SEVERITY[query.level] : undefined + + const matched = entries.filter((entry) => { + if (entry.seq <= afterSeq) { + return false + } + if (minSeverity !== undefined && LEVEL_SEVERITY[entry.level] < minSeverity) { + return false + } + if (query.source && entry.source !== query.source) { + return false + } + return true + }) + + const limit = Math.min(query.limit ?? DEFAULTS.LOGS.DEFAULT_PAGE_SIZE, DEFAULTS.LOGS.MAX_PAGE_SIZE) + const page = hasCursor + ? matched.slice(0, limit) + : limit < matched.length + ? matched.slice(matched.length - limit) + : matched + + return { + entries: page, + instanceId, + latestSeq: nextSeq - 1, + oldestSeq: entries[0]?.seq ?? 0, + dropped, + capacity: DEFAULTS.LOGS.BUFFER_CAPACITY, + } +} + +export function resetManagerLogBuffer(): void { + entries.length = 0 + nextSeq = 1 + dropped = 0 +} diff --git a/backend/src/utils/logger.test.ts b/backend/src/utils/logger.test.ts new file mode 100644 index 000000000..953e54f18 --- /dev/null +++ b/backend/src/utils/logger.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { logger } from './logger' +import { readManagerLogEntries, resetManagerLogBuffer } from './log-buffer' + +type ConsoleSpy = ReturnType + +describe('logger', () => { + let warnSpy: ConsoleSpy + let debugSpy: ConsoleSpy + + beforeEach(() => { + resetManagerLogBuffer() + vi.spyOn(console, 'log').mockImplementation(() => {}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + }) + + it('info routes into the buffer with level info and source manager', () => { + logger.info('hello') + + const { entries } = readManagerLogEntries({}) + expect(entries).toHaveLength(1) + expect(entries[0]?.level).toBe('info') + expect(entries[0]?.source).toBe('manager') + expect(entries[0]?.message).toBe('hello') + }) + + it('error folds an Error arg into one composed buffered entry', () => { + logger.error('failed', new Error('boom')) + + const { entries } = readManagerLogEntries({}) + expect(entries).toHaveLength(1) + const message = entries[0]?.message + expect(message).toContain('failed') + expect(message).toContain('Error') + expect(message).toContain('boom') + expect(message).toContain('failed Error: boom') + }) + + it('debug records nothing and writes nothing when ENV.LOGGING.DEBUG is false', () => { + logger.debug('quiet') + + expect(debugSpy).not.toHaveBeenCalled() + const { entries, latestSeq } = readManagerLogEntries({}) + expect(entries).toHaveLength(0) + expect(latestSeq).toBe(0) + }) + + it('warn calls console.warn exactly once with prefixed line containing the message', () => { + logger.warn('careful') + + expect(warnSpy).toHaveBeenCalledTimes(1) + const line = warnSpy.mock.calls[0]?.[0] as string + expect(line).toContain('[WARN]') + expect(line).toContain('careful') + expect(line).toMatch(/^\[\d{4}-\d{2}-\d{2}T.*\] \[WARN\] careful$/) + }) +}) diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index baad99c77..3934c81e7 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -1,4 +1,5 @@ import { ENV } from '@opencode-manager/shared/config/env' +import { appendManagerLogEntry, composeLogMessage } from './log-buffer' type LogLevel = 'info' | 'warn' | 'error' | 'debug' @@ -9,27 +10,36 @@ class Logger { this.prefix = prefix } - private format(level: LogLevel, message: string): string { - const timestamp = new Date().toISOString() - const prefixStr = this.prefix ? `[${this.prefix}] ` : '' - return `[${timestamp}] [${level.toUpperCase()}] ${prefixStr}${message}` + private format(level: LogLevel, composed: string): string { + return `[${new Date().toISOString()}] [${level.toUpperCase()}] ${composed}` + } + + private emit( + level: LogLevel, + write: (line: string) => void, + message: string, + args: unknown[], + ): void { + const composed = composeLogMessage(this.prefix, message, args) + write(this.format(level, composed)) + appendManagerLogEntry({ level, source: 'manager', message: composed }) } info(message: string, ...args: unknown[]): void { - console.log(this.format('info', message), ...args) + this.emit('info', (line) => console.log(line), message, args) } warn(message: string, ...args: unknown[]): void { - console.warn(this.format('warn', message), ...args) + this.emit('warn', (line) => console.warn(line), message, args) } error(message: string, ...args: unknown[]): void { - console.error(this.format('error', message), ...args) + this.emit('error', (line) => console.error(line), message, args) } debug(message: string, ...args: unknown[]): void { if (ENV.LOGGING.DEBUG) { - console.debug(this.format('debug', message), ...args) + this.emit('debug', (line) => console.debug(line), message, args) } } } diff --git a/backend/test/routes/logs.test.ts b/backend/test/routes/logs.test.ts new file mode 100644 index 000000000..d38cd7214 --- /dev/null +++ b/backend/test/routes/logs.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { createLogRoutes } from '../../src/routes/logs' +import { appendManagerLogEntry, resetManagerLogBuffer } from '../../src/utils/log-buffer' +import { DEFAULTS } from '@opencode-manager/shared/config' + +describe('Logs Routes', () => { + const app = createLogRoutes() + + beforeEach(() => { + resetManagerLogBuffer() + }) + + async function getJson(query: string) { + const res = await app.fetch(new Request(`http://localhost/${query}`)) + return { status: res.status, json: (await res.json()) as Record } + } + + it('returns all appended entries in chronological order with latestSeq', async () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'first' }) + appendManagerLogEntry({ level: 'warn', source: 'manager', message: 'second' }) + appendManagerLogEntry({ level: 'error', source: 'opencode', message: 'third' }) + + const { status, json } = await getJson('?') + const entries = json.entries as Array<{ seq: number; message: string }> + + expect(status).toBe(200) + expect(entries.map((entry) => entry.message)).toEqual(['first', 'second', 'third']) + expect(entries.map((entry) => entry.seq)).toEqual([1, 2, 3]) + expect(json.latestSeq).toBe(3) + }) + + it('returns only entries newer than afterSeq', async () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'one' }) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'two' }) + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'three' }) + + const { json } = await getJson('?afterSeq=2') + const entries = json.entries as Array<{ message: string }> + + expect(entries.map((entry) => entry.message)).toEqual(['three']) + }) + + it('filters by minimum level, excluding lower-severity entries', async () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'info entry' }) + appendManagerLogEntry({ level: 'warn', source: 'manager', message: 'warn entry' }) + + const { json } = await getJson('?level=warn') + const entries = json.entries as Array<{ message: string }> + + expect(entries.map((entry) => entry.message)).toEqual(['warn entry']) + }) + + it('filters by source, excluding other sources', async () => { + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'manager line' }) + appendManagerLogEntry({ level: 'info', source: 'opencode', message: 'opencode line' }) + + const { json } = await getJson('?source=opencode') + const entries = json.entries as Array<{ message: string }> + + expect(entries.map((entry) => entry.message)).toEqual(['opencode line']) + }) + + it('rejects unknown levels with 400 and a details payload', async () => { + const { status, json } = await getJson('?level=bogus') + + expect(status).toBe(400) + expect(json.error).toBe('Invalid request') + expect(Array.isArray(json.details)).toBe(true) + }) + + it('rejects non-numeric afterSeq with 400', async () => { + const { status, json } = await getJson('?afterSeq=abc') + + expect(status).toBe(400) + expect(json.error).toBe('Invalid request') + expect(Array.isArray(json.details)).toBe(true) + }) + + it('returns an empty page with buffer metadata when the buffer is empty', async () => { + const { status, json } = await getJson('?') + + expect(status).toBe(200) + expect(json.entries).toEqual([]) + expect(json.latestSeq).toBe(0) + expect(json.capacity).toBe(DEFAULTS.LOGS.BUFFER_CAPACITY) + }) + + it('returns the same stable instanceId across requests and buffer resets', async () => { + const first = await getJson('?') + appendManagerLogEntry({ level: 'info', source: 'manager', message: 'entry' }) + const second = await getJson('?') + resetManagerLogBuffer() + const third = await getJson('?') + + expect(typeof first.json.instanceId).toBe('string') + expect((first.json.instanceId as string).length).toBeGreaterThan(0) + expect(second.json.instanceId).toBe(first.json.instanceId) + expect(third.json.instanceId).toBe(first.json.instanceId) + }) +}) diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 7cb0315ef..5e2d17100 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -11,7 +11,7 @@ const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ const spawnMock = vi.hoisted(() => vi.fn(() => ({ pid: 1234, - stderr: null, + stderr: null as unknown, on: vi.fn(), }))) @@ -1367,6 +1367,65 @@ describe('OpenCodeServerManager - server auth', () => { } }, 15000) + it('records child output that arrives after the exit event but before the stdio streams end', async () => { + const originalNodeEnv = ENV.SERVER.NODE_ENV + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) + try { + sandboxRuntimeServiceMock.SandboxRuntimeService.mockImplementation(() => ({ + isEnabled: () => false, + })) + execSyncMock.mockImplementation(() => { + throw new Error('not found') + }) + readdirSyncMock.mockReturnValue([]) + readFileSyncMock.mockImplementation((filePath: string) => { + if (String(filePath).includes('/proc/1234/stat')) return procStatStringWithGroup(1234, '42') + const error = new Error('No such process') as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + }) + + const { EventEmitter } = await import('events') + const stdout = new EventEmitter() + const stderr = new EventEmitter() + spawnMock.mockImplementationOnce(() => ({ pid: 1234, stdout, stderr, on: vi.fn() })) + + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const { readManagerLogEntries, resetManagerLogBuffer } = await import('../../src/utils/log-buffer') + resetManagerLogBuffer() + const manager = OpenCodeServerManager.getInstance() + manager.setDatabase(createPasswordDb(null)) + + await manager.start() + + const spawnedChild = spawnMock.mock.results[0]!.value as { pid: number; on: ReturnType } + const exitCall = spawnedChild.on.mock.calls.find((call: unknown[]) => call[0] === 'exit') + expect(exitCall).toBeDefined() + + stderr.emit('data', Buffer.from('ERROR boom\npartial tail')) + ;(exitCall![1] as (code: number | null, signal: NodeJS.Signals | null) => void)(1, null) + + expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([ + 'ERROR boom', + ]) + + stderr.emit('data', Buffer.from(' FINAL')) + stderr.emit('end') + stdout.emit('data', Buffer.from('INFO started\n')) + stdout.emit('end') + + const messages = readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message) + expect(messages).toEqual(['ERROR boom', 'partial tail FINAL', 'INFO started']) + expect(messages.filter((message) => message === 'partial tail FINAL')).toHaveLength(1) + expect((manager as any).serverPid).toBeNull() + expect(manager.getLastStartupError()).toContain('exited with code 1') + } finally { + readFileSyncMock.mockReset() + readdirSyncMock.mockReset() + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: originalNodeEnv, configurable: true, writable: true }) + } + }, 15000) + it('reconciles an attested surviving descendant group before an unenforced replacement start', async () => { const originalNodeEnv = ENV.SERVER.NODE_ENV Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) diff --git a/docs/features/logs.md b/docs/features/logs.md new file mode 100644 index 000000000..1f7043d81 --- /dev/null +++ b/docs/features/logs.md @@ -0,0 +1,38 @@ +# Manager Logs + +View the Manager's own log lines and the managed OpenCode server's output directly in the Settings UI, without shell access to the container or host. + +## Overview + +The **Logs** tab in Settings shows a live stream of what the Manager and the OpenCode server are doing — startup messages, session activity, warnings, and errors. This is the quickest way to answer "why did that just fail?" while the app is running. + +Each entry shows a timestamp, severity level, source, and message: + +- **Manager** — Lines emitted by the Manager's own logger (the same lines written to the console/container logs) +- **OpenCode server** — stdout and stderr captured from the supervised OpenCode server process + +## Filters + +| Control | Behavior | +|---------|----------| +| **Level** | Minimum severity filter: *All levels*, *Info and above*, *Warnings and above*, or *Errors* | +| **Source** | Show only **Manager** lines, only **OpenCode server** lines, or both | +| **Search messages** | Case-insensitive text search applied to the displayed entries | + +## Controls + +- **Pause / Resume** — Stop and resume the live stream. While paused, entries arriving from the backend are not added to the view. +- **Clear** — Empty the local view without affecting the backend buffer. +- **Copy** — Copy the currently visible (filtered and searched) entries to the clipboard. +- **Follow scrolling** — The view scrolls to the newest entry automatically. Scrolling up pauses following; scrolling back to the bottom resumes it. + +If entries were evicted before you opened the tab, a notice above the view reports how many earlier entries were dropped. + +## Limits and Behavior + +- The buffer is **in-memory** on the Manager backend and holds up to **2,000** entries (`DEFAULTS.LOGS.BUFFER_CAPACITY`). Older entries are evicted first. +- Individual entries are truncated at **4,000** characters (`DEFAULTS.LOGS.MAX_ENTRY_LENGTH`). +- The buffer is **cleared when the Manager process restarts**. When the frontend detects a backend restart it resets its view and re-polls from the start. +- **Child-process capture is production-only.** In development the OpenCode server inherits the terminal, so only Manager lines appear in the tab. +- The frontend polls the backend every **3 seconds** (`DEFAULTS.LOGS.POLL_INTERVAL_MS`); backend pages are capped at 1,000 entries per response. +- `docker-compose logs` remains the fallback for failures that happen **before** the Manager's HTTP server is up — nothing can be captured in-app at that point. diff --git a/docs/features/overview.md b/docs/features/overview.md index 9dda80042..1d37b885f 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -113,3 +113,12 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI - **Version Management** — View current versions and trigger OpenCode upgrades from the UI [Learn more →](server-health.md) + +### Manager Logs + +- **In-App Log Viewer** — Manager and OpenCode server output in the Settings Logs tab, no shell access needed +- **Filtering** — Minimum severity, source, and case-insensitive text search +- **Live Controls** — Pause/resume, clear, copy, and automatic follow scrolling +- **Bounded Memory** — In-memory ring buffer with explicit dropped-entry notice + +[Learn more →](logs.md) diff --git a/docs/index.md b/docs/index.md index 00c78ada5..f3f75a9a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ OpenCode Manager runs as a pnpm workspace: - **Assistant Mode** — Dedicated AI workspace with auto-provisioned skills for schedule management, notifications, settings, and repo listing — [Learn more](features/assistant-mode.md) - **AI Configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents — [Learn more](features/ai-config.md) - **Server Health & Restart** — Monitor server status, graceful restarts with session resume, auto-recovery — [Learn more](features/server-health.md) +- **Manager Logs** — Manager log lines plus captured OpenCode server stdout/stderr in Settings, with level/source filters, search, and pause/copy controls — [Learn more](features/logs.md) - **MCP Servers** — Add local or remote MCP servers with OAuth support — [Learn more](features/mcp.md) - **Skills** — Skill support for extended agent capabilities — [Learn more](features/skills.md) - **Mobile & PWA** — Responsive UI, installable on any device, iOS-optimized — [Learn more](features/mobile.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b30972173..76c008aba 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,6 +15,8 @@ Common issues and their solutions. docker-compose logs -f ``` +Once the Manager is running, **Settings → Logs** shows the Manager's own log lines in-app, plus the OpenCode server's stdout/stderr, which the Manager captures in memory and does **not** forward to the container console — so those OpenCode-server lines are visible in-app only. If the container or the Manager itself fails before the Manager's HTTP server is up, use `docker-compose logs`; the in-app viewer has nothing to show at that point. + 2. Verify Docker resources (2GB RAM minimum) 3. Check ports aren't in use: @@ -275,6 +277,6 @@ If your issue isn't covered: 3. Open a new issue with: - Steps to reproduce - Expected vs actual behavior - - Container logs: `docker-compose logs` + - Container logs: `docker-compose logs` — required when the Manager fails to start; **Settings → Logs** in-app covers the same Manager lines plus captured OpenCode server output once the Manager is up - Browser console errors - Environment info (OS, browser, Docker version) diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 000000000..5a6052982 --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,22 @@ +import type { + ManagerLogLevel, + ManagerLogSource, + ManagerLogsResponse, +} from '@opencode-manager/shared/schemas' +import { API_BASE_URL } from '@/config' +import { fetchWrapper } from './fetchWrapper' + +export interface GetManagerLogsQuery { + afterSeq?: number + level?: ManagerLogLevel + source?: ManagerLogSource + limit?: number +} + +export const logsApi = { + getManagerLogs: (query: GetManagerLogsQuery = {}): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/logs`, { + params: { ...query }, + }) + }, +} diff --git a/frontend/src/components/settings/LogsViewer.test.tsx b/frontend/src/components/settings/LogsViewer.test.tsx new file mode 100644 index 000000000..3bcc54a3d --- /dev/null +++ b/frontend/src/components/settings/LogsViewer.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LogsViewer } from './LogsViewer' +import { useManagerLogs } from '@/hooks/useManagerLogs' +import type { ManagerLogEntry } from '@opencode-manager/shared/schemas' + +vi.mock('@/hooks/useManagerLogs') + +const entries: ManagerLogEntry[] = [ + { + seq: 1, + timestamp: '2026-01-01T00:00:00.000Z', + level: 'info', + source: 'manager', + message: 'Server started on port 5003', + }, + { + seq: 2, + timestamp: '2026-01-01T00:00:01.000Z', + level: 'error', + source: 'opencode', + message: 'Connection refused while dialing upstream', + }, +] + +function mockLogs(overrides: Partial> = {}) { + vi.mocked(useManagerLogs).mockReturnValue({ + entries, + dropped: 0, + isLoading: false, + error: null, + clear: vi.fn(), + ...overrides, + } as ReturnType) +} + +describe('LogsViewer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLogs() + }) + + it('renders entries with their message, level and source', () => { + render() + + expect(screen.getByText('Server started on port 5003')).toBeInTheDocument() + expect(screen.getByText('Connection refused while dialing upstream')).toBeInTheDocument() + expect(screen.getAllByText(/^(info|error)$/i)).toHaveLength(2) + expect(screen.getByText('Manager')).toBeInTheDocument() + expect(screen.getByText('OpenCode server')).toBeInTheDocument() + }) + + it('hides rows that do not match the search text', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Search log messages'), 'refused') + + expect(screen.queryByText('Server started on port 5003')).not.toBeInTheDocument() + expect(screen.getByText('Connection refused while dialing upstream')).toBeInTheDocument() + }) + + it('re-invokes the hook paused and switches the button to Resume', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /pause/i })) + + expect(vi.mocked(useManagerLogs).mock.lastCall?.[0]).toMatchObject({ paused: true }) + expect(screen.getByRole('button', { name: /resume/i })).toBeInTheDocument() + }) + + it('clears the displayed entries through the hook', async () => { + const user = userEvent.setup() + const clear = vi.fn() + mockLogs({ clear }) + render() + + await user.click(screen.getByRole('button', { name: /clear/i })) + + expect(clear).toHaveBeenCalled() + }) + + it('renders the empty-state copy when no entries are captured', () => { + mockLogs({ entries: [] }) + render() + + expect(screen.getByText('No log entries captured yet.')).toBeInTheDocument() + }) + + it('renders the dropped-entries note when entries were dropped', () => { + mockLogs({ dropped: 3 }) + render() + + expect(screen.getByText(/3 earlier entries dropped \(buffer holds \d+\)/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/LogsViewer.tsx b/frontend/src/components/settings/LogsViewer.tsx new file mode 100644 index 000000000..d226007ba --- /dev/null +++ b/frontend/src/components/settings/LogsViewer.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Pause, Play, Trash2 } from 'lucide-react' +import type { ManagerLogLevel, ManagerLogSource } from '@opencode-manager/shared/schemas' +import { Card, CardContent } from '@/components/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { CopyButton } from '@/components/ui/copy-button' +import { useManagerLogs } from '@/hooks/useManagerLogs' +import { DEFAULTS } from '@/config' +import { cn } from '@/lib/utils' + +type LevelFilter = ManagerLogLevel | 'all' +type SourceFilter = ManagerLogSource | 'all' + +const SOURCE_LABELS: Record = { + manager: 'Manager', + opencode: 'OpenCode server', +} + +const LEVEL_OPTIONS: Array<{ value: LevelFilter; label: string }> = [ + { value: 'all', label: 'All levels' }, + { value: 'info', label: 'Info and above' }, + { value: 'warn', label: 'Warnings and above' }, + { value: 'error', label: 'Errors' }, +] + +const SOURCE_OPTIONS: Array<{ value: SourceFilter; label: string }> = [ + { value: 'all', label: 'All sources' }, + { value: 'manager', label: SOURCE_LABELS.manager }, + { value: 'opencode', label: SOURCE_LABELS.opencode }, +] + +const LEVEL_CHIP_CLASSES: Record = { + error: 'bg-destructive/15 text-destructive', + warn: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', + info: 'bg-accent text-accent-foreground', + debug: 'bg-muted text-muted-foreground', +} + +export function LogsViewer() { + const [level, setLevel] = useState('all') + const [source, setSource] = useState('all') + const [search, setSearch] = useState('') + const [paused, setPaused] = useState(false) + const [isFollowing, setIsFollowing] = useState(true) + const scrollRef = useRef(null) + + const { entries, dropped, clear } = useManagerLogs({ + level: level === 'all' ? undefined : level, + source: source === 'all' ? undefined : source, + paused, + }) + + const displayedEntries = useMemo(() => { + const query = search.toLowerCase() + if (!query) return entries + return entries.filter((entry) => entry.message.toLowerCase().includes(query)) + }, [entries, search]) + + useEffect(() => { + const container = scrollRef.current + if (!container || !isFollowing) return + container.scrollTop = container.scrollHeight + }, [displayedEntries, isFollowing]) + + const handleScroll = () => { + const container = scrollRef.current + if (!container) return + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + setIsFollowing(distanceFromBottom <= 48) + } + + const copyContent = displayedEntries + .map((entry) => `${entry.timestamp} ${entry.level} ${entry.source} ${entry.message}`) + .join('\n') + + return ( + + +
+ + + setSearch(event.target.value)} + placeholder="Search messages" + className="w-56" + aria-label="Search log messages" + /> +
+ + + +
+
+
+ {entries.length === 0 ? ( +

No log entries captured yet.

+ ) : ( + displayedEntries.map((entry) => ( +
+ + {new Date(entry.timestamp).toLocaleTimeString()} + + + {entry.level} + + {SOURCE_LABELS[entry.source]} + {entry.message} +
+ )) + )} +
+ {dropped > 0 && ( +

+ {dropped} earlier entries dropped (buffer holds {DEFAULTS.LOGS.BUFFER_CAPACITY}) +

+ )} +
+
+ ) +} diff --git a/frontend/src/components/settings/SettingsDialog.test.tsx b/frontend/src/components/settings/SettingsDialog.test.tsx index 973681cb3..f394de8d9 100644 --- a/frontend/src/components/settings/SettingsDialog.test.tsx +++ b/frontend/src/components/settings/SettingsDialog.test.tsx @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { SettingsDialog } from './SettingsDialog' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { DESKTOP_MEDIA_QUERY } from '@/hooks/useMediaQuery' vi.mock('@/components/settings/GeneralSettings', () => ({ GeneralSettings: () =>
General Settings Content
, @@ -40,6 +41,10 @@ vi.mock('@/components/settings/VersionSelectDialog', () => ({ VersionSelectDialog: () =>
Version Select Dialog
, })) +vi.mock('@/components/settings/LogsViewer', () => ({ + LogsViewer: () =>
Logs Content
, +})) + vi.mock('@/hooks/useMobile', () => ({ useSwipeBack: vi.fn(() => ({ bind: vi.fn(), @@ -48,11 +53,36 @@ vi.mock('@/hooks/useMobile', () => ({ })), })) +function stubMatchMedia(matches: boolean): () => void { + const listeners = new Set<() => void>() + const mediaQueryList = { + media: DESKTOP_MEDIA_QUERY, + matches, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener) + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener) + }, + } + const original = window.matchMedia + void original + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: () => mediaQueryList, + }) +} + describe('SettingsDialog', () => { beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + }) + it('resets to menu state when dialog closes and reopens', () => { function TestWrapper() { const location = useLocation() @@ -119,6 +149,177 @@ describe('SettingsDialog', () => { expect(screen.getAllByText('Shortcuts').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('OpenCode').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('Providers').length).toBeGreaterThanOrEqual(1) + + const mobileContainer = document.querySelector('.sm\\:hidden') as HTMLElement + const mobile = within(mobileContainer) + expect(mobile.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() + expect(mobileContainer.querySelector('svg.lucide-chevron-left')).toBeNull() + expect(mobile.queryByTestId('logs-settings')).not.toBeInTheDocument() + }) + + it('navigates to the Logs section and reflects settingsTab=logs in the URL', () => { + function TestWrapper() { + const location = useLocation() + const navigate = useNavigate() + + const searchParams = new URLSearchParams(location.search) + const isOpen = searchParams.get('settings') === 'open' + + return ( + <> + + {isOpen && Dialog Open} + {isOpen && {location.search}} + + + ) + } + + render( + + + + ) + + fireEvent.click(screen.getByText('Open Settings')) + + const logsMenuButton = screen.getByText('Live manager and OpenCode server logs').closest('button') + expect(logsMenuButton).not.toBeNull() + fireEvent.click(logsMenuButton!) + + const mobileContainer = document.querySelector('.sm\\:hidden') as HTMLElement + expect(within(mobileContainer).getByTestId('logs-settings')).toBeInTheDocument() + expect(screen.getByTestId('location-search')).toHaveTextContent('settingsTab=logs') + }) + + it('opens directly on the Logs mobile view from ?settings=open&settingsTab=logs and returns to the menu on back', () => { + stubMatchMedia(false) + function TestWrapper() { + const location = useLocation() + const navigate = useNavigate() + + const searchParams = new URLSearchParams(location.search) + const isOpen = searchParams.get('settings') === 'open' + + return ( + <> + + {isOpen && Dialog Open} + + + ) + } + + render( + + + + ) + + expect(screen.getByTestId('dialog-open')).toBeInTheDocument() + + const mobileContainer = document.querySelector('.sm\\:hidden') as HTMLElement + const mobile = within(mobileContainer) + + expect(mobile.getByTestId('logs-settings')).toBeInTheDocument() + expect(mobile.getByRole('heading', { name: 'Logs' })).toBeInTheDocument() + expect(screen.getAllByTestId('logs-settings')).toHaveLength(1) + + const backButton = mobileContainer.querySelector('svg.lucide-chevron-left')?.closest('button') + expect(backButton).not.toBeNull() + fireEvent.click(backButton!) + + expect(mobile.queryByTestId('logs-settings')).not.toBeInTheDocument() + expect(mobile.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() + expect(mobile.getByText('Live manager and OpenCode server logs')).toBeInTheDocument() + expect(mobileContainer.querySelector('svg.lucide-chevron-left')).toBeNull() + expect(screen.queryByTestId('logs-settings')).not.toBeInTheDocument() + }) + + it('mounts exactly one LogsViewer on desktop when the Logs tab is selected', () => { + stubMatchMedia(true) + function TestWrapper() { + const location = useLocation() + const navigate = useNavigate() + + const searchParams = new URLSearchParams(location.search) + const isOpen = searchParams.get('settings') === 'open' + + return ( + <> + + {isOpen && Dialog Open} + + + ) + } + + render( + + + + ) + + fireEvent.click(screen.getByText('Open Logs')) + + expect(screen.getAllByTestId('logs-settings')).toHaveLength(1) + const mobileContainer = document.querySelector('.sm\\:hidden') as HTMLElement + expect(within(mobileContainer).queryByTestId('logs-settings')).not.toBeInTheDocument() + expect(within(mobileContainer).getByRole('heading', { name: 'Logs' })).toBeInTheDocument() + }) + + it('falls back to the menu and account tab for an unknown settingsTab value', () => { + function TestWrapper() { + const location = useLocation() + const isOpen = new URLSearchParams(location.search).get('settings') === 'open' + return ( + <> + {isOpen && Dialog Open} + + + ) + } + + render( + + + + ) + + expect(screen.getByTestId('dialog-open')).toBeInTheDocument() + + const mobileContainer = document.querySelector('.sm\\:hidden') as HTMLElement + const mobile = within(mobileContainer) + expect(mobile.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() + expect(mobile.getByText('Live manager and OpenCode server logs')).toBeInTheDocument() + expect(mobile.queryByTestId('logs-settings')).not.toBeInTheDocument() + expect(mobileContainer.querySelector('svg.lucide-chevron-left')).toBeNull() + + const desktopTrigger = screen.getByRole('tab', { name: 'Account' }) + expect(desktopTrigger).toHaveAttribute('data-state', 'active') + }) + + it('applies compact responsive sizing to all nine desktop tab triggers', () => { + function TestWrapper() { + return + } + + render( + + + + ) + + const triggers = screen.getAllByRole('tab') + expect(triggers).toHaveLength(9) + const expected = ['Account', 'General', 'Notify', 'Voice', 'Git', 'Shortcuts', 'OpenCode', 'Logs', 'Providers'] + expect(triggers.map((trigger) => trigger.textContent)).toEqual(expected) + for (const trigger of triggers) { + expect(trigger.className).toContain('sm:px-2') + expect(trigger.className).toContain('sm:text-xs') + expect(trigger.className).toContain('md:px-3') + expect(trigger.className).toContain('md:text-sm') + } }) it('keeps Settings open when Escape fires inside a nested dialog', () => { diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 1305eebaf..681c01c86 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -3,6 +3,7 @@ import { GeneralSettings } from '@/components/settings/GeneralSettings' import { GitSettings } from '@/components/settings/GitSettings' import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts' import { OpenCodeConfigManager } from '@/components/settings/OpenCodeConfigManager' +import { LogsViewer } from '@/components/settings/LogsViewer' import { OpenCodeServerAuthSettings } from '@/components/settings/OpenCodeServerAuthSettings' import { ManagerTokenSettings } from '@/components/settings/ManagerTokenSettings' import { ServerEnvVarsSettings } from '@/components/settings/ServerEnvVarsSettings' @@ -15,14 +16,18 @@ import { NotificationSettings } from '@/components/settings/NotificationSettings import { VersionSelectDialog } from '@/components/settings/VersionSelectDialog' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react' +import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X, ScrollText, type LucideIcon } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSettingsDialog, isSettingsContentTab, type SettingsContentTab } from '@/hooks/useSettingsDialog' +import { DESKTOP_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' -type SettingsView = 'menu' | 'general' | 'git' | 'shortcuts' | 'opencode' | 'providers' | 'account' | 'voice' | 'notifications' +type SettingsView = 'menu' | SettingsContentTab + +const TAB_TRIGGER_CLASS = 'data-[state=active]:bg-blue-600 data-[state=active]:text-white text-muted-foreground transition-all duration-200 sm:px-2 sm:text-xs md:px-3 md:text-sm' export function SettingsDialog() { - const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog() + const { isOpen, close, activeTab, selectedTab, setActiveTab } = useSettingsDialog() + const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY) const [mobileView, setMobileView] = useState('menu') const [isVersionDialogOpen, setIsVersionDialogOpen] = useState(false) const [sectionHistory, setSectionHistory] = useState([]) @@ -49,7 +54,7 @@ export function SettingsDialog() { : sectionHistory const previousView = previousHistory.at(-1) - if (previousView) { + if (previousView && previousView !== 'menu') { setSectionHistory(previousHistory) setMobileView(previousView) setActiveTab(previousView) @@ -82,7 +87,13 @@ export function SettingsDialog() { return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }) }, [isOpen, close, isVersionDialogOpen]) - const menuItems = [ + useEffect(() => { + if (!isOpen || !selectedTab) return + setMobileView(selectedTab) + pushSectionHistory(selectedTab) + }, [isOpen, selectedTab, pushSectionHistory]) + + const menuItems: Array<{ id: SettingsContentTab; icon: LucideIcon; label: string; description: string }> = [ { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, and sign out' }, { id: 'general', icon: Settings2, label: 'General Settings', description: 'App preferences and behavior' }, { id: 'notifications', icon: Bell, label: 'Notifications', description: 'Push notification preferences' }, @@ -90,20 +101,21 @@ export function SettingsDialog() { { id: 'git', icon: GitBranch, label: 'Git', description: 'Git identity and credentials for repositories' }, { id: 'shortcuts', icon: Keyboard, label: 'Keyboard Shortcuts', description: 'Customize keyboard shortcuts' }, { id: 'opencode', icon: Code, label: 'OpenCode Config', description: 'Manage OpenCode configurations, commands, and agents' }, + { id: 'logs', icon: ScrollText, label: 'Logs', description: 'Live manager and OpenCode server logs' }, { id: 'providers', icon: Key, label: 'Providers', description: 'Manage AI provider API keys' }, ] - const handleOpenMobileView = useCallback((view: SettingsView) => { + const handleOpenMobileView = useCallback((view: SettingsContentTab) => { setMobileView(view) setActiveTab(view) pushSectionHistory(view) }, [setActiveTab, pushSectionHistory]) const handleTabChange = (tab: string) => { - const nextView = tab as SettingsView - setActiveTab(nextView) - setMobileView(nextView) - pushSectionHistory(nextView) + if (!isSettingsContentTab(tab)) return + setActiveTab(tab) + setMobileView(tab) + pushSectionHistory(tab) } return ( @@ -135,29 +147,32 @@ export function SettingsDialog() {
- - + + Account - + General - + Notify - + Voice - + Git - + Shortcuts - + OpenCode - + + Logs + + Providers @@ -183,6 +198,7 @@ export function SettingsDialog() {
+ {isDesktop && } @@ -222,7 +238,7 @@ export function SettingsDialog() { {menuItems.map((item) => (