mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(logging): rotate embedded core + shell logs to ~/.openhuman/logs (#1278)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { triggerSentryTestEvent } from '../../../services/analytics';
|
||||
import { APP_ENVIRONMENT } from '../../../utils/config';
|
||||
@@ -249,6 +250,61 @@ const SentryTestRow = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Surfaces the on-disk log folder so users running into "stuck on
|
||||
// Initializing OpenHuman..." (and similar startup issues) can grab today's
|
||||
// `openhuman-YYYY-MM-DD.log` and send it to support without hunting through
|
||||
// `~/.openhuman/logs/`. Invokes the `reveal_logs_folder` Tauri command which
|
||||
// `open`/`explorer`/`xdg-open`s the directory in the platform file manager.
|
||||
const LogsFolderRow = () => {
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
invoke<string | null>('logs_folder_path')
|
||||
.then(p => setPath(p ?? null))
|
||||
.catch(err => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClick = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke('reveal_logs_folder');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isTauri()) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900">App logs</div>
|
||||
<div className="text-xs text-slate-700 mt-0.5">
|
||||
Open the folder containing rolling daily log files. Attach the most recent file when
|
||||
reporting an issue.
|
||||
</div>
|
||||
{path && <div className="text-[11px] text-slate-500 mt-1 font-mono truncate">{path}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium transition-colors">
|
||||
Open logs folder
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="status" aria-live="polite" className="mt-2 text-xs text-coral-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DeveloperOptionsPanel = () => {
|
||||
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const showSentryTest = APP_ENVIRONMENT === 'staging';
|
||||
@@ -263,6 +319,7 @@ const DeveloperOptionsPanel = () => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<LogsFolderRow />
|
||||
{showSentryTest && <SentryTestRow />}
|
||||
{developerItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
|
||||
@@ -12,8 +12,12 @@ import { renderWithProviders } from '../../../../test/test-utils';
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
trigger: vi.fn(),
|
||||
appEnvironment: 'staging' as 'staging' | 'production' | 'development',
|
||||
invoke: vi.fn(),
|
||||
isTauri: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invoke, isTauri: hoisted.isTauri }));
|
||||
|
||||
vi.mock('../../../../services/analytics', () => ({ triggerSentryTestEvent: hoisted.trigger }));
|
||||
|
||||
vi.mock('../../../../utils/config', () => ({
|
||||
@@ -38,6 +42,14 @@ async function importPanel() {
|
||||
describe('DeveloperOptionsPanel — Sentry test row', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.trigger.mockReset();
|
||||
// The panel always renders LogsFolderRow, which fires
|
||||
// `invoke('logs_folder_path')` on mount. Stub it to a resolved no-op
|
||||
// so this suite's tests focus on the Sentry row without unhandled
|
||||
// rejections from the App-logs effect.
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(null);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
hoisted.appEnvironment = 'staging';
|
||||
});
|
||||
|
||||
@@ -104,3 +116,89 @@ describe('DeveloperOptionsPanel — Sentry test row', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeveloperOptionsPanel — App logs row', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
// Force production so the staging Sentry row stays hidden and we
|
||||
// assert against the App logs row in isolation.
|
||||
hoisted.appEnvironment = 'production';
|
||||
});
|
||||
|
||||
test('renders nothing when not running under Tauri', async () => {
|
||||
hoisted.isTauri.mockReturnValue(false);
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
expect(screen.queryByText(/App logs/i)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Open logs folder/i })).toBeNull();
|
||||
});
|
||||
|
||||
test('shows the resolved log path on mount', async () => {
|
||||
hoisted.invoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'logs_folder_path') return Promise.resolve('/tmp/openhuman/logs');
|
||||
return Promise.resolve();
|
||||
});
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/tmp/openhuman/logs')).toBeInTheDocument();
|
||||
});
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('logs_folder_path');
|
||||
});
|
||||
|
||||
test('invokes reveal_logs_folder on click', async () => {
|
||||
hoisted.invoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'logs_folder_path') return Promise.resolve(null);
|
||||
if (cmd === 'reveal_logs_folder') return Promise.resolve();
|
||||
return Promise.resolve();
|
||||
});
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open logs folder/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('reveal_logs_folder');
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the reveal error message in the live region', async () => {
|
||||
hoisted.invoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'logs_folder_path') return Promise.resolve(null);
|
||||
if (cmd === 'reveal_logs_folder')
|
||||
return Promise.reject(new Error('log directory not initialized'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open logs folder/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/log directory not initialized/i)).toBeInTheDocument();
|
||||
});
|
||||
const live = screen.getByRole('status');
|
||||
expect(live).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
test('surfaces the path-resolve error when logs_folder_path rejects', async () => {
|
||||
hoisted.invoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'logs_folder_path') return Promise.reject(new Error('boom'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/boom/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user