mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: show desktop server startup diagnostics
This commit is contained in:
parent
139dcbc4c7
commit
2961a49fc3
@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, VecDeque},
|
||||
io::{Error as IoError, ErrorKind, Read, Write},
|
||||
net::{SocketAddr, TcpListener, TcpStream},
|
||||
path::PathBuf,
|
||||
@ -7,7 +7,7 @@ use std::{
|
||||
str,
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Mutex,
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@ -24,6 +24,8 @@ use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
};
|
||||
|
||||
const SERVER_STARTUP_LOG_LIMIT: usize = 80;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerState(Mutex<ServerStatus>);
|
||||
|
||||
@ -546,6 +548,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn push_server_startup_log(logs: &Arc<Mutex<VecDeque<String>>>, line: String) {
|
||||
let line = line.trim_end().to_string();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(mut guard) = logs.lock() else {
|
||||
return;
|
||||
};
|
||||
if guard.len() >= SERVER_STARTUP_LOG_LIMIT {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(line);
|
||||
}
|
||||
|
||||
fn format_server_startup_error(message: &str, logs: &Arc<Mutex<VecDeque<String>>>) -> String {
|
||||
let log_text = logs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|guard| guard.iter().cloned().collect::<Vec<_>>().join("\n"))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or_else(|| "No server stdout/stderr was captured before the timeout.".to_string());
|
||||
|
||||
format!("{message}\n\nRecent server logs:\n{log_text}")
|
||||
}
|
||||
|
||||
fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
|
||||
// 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上
|
||||
// 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ +
|
||||
@ -589,6 +617,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
||||
&port.to_string(),
|
||||
]);
|
||||
|
||||
let startup_logs = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let logs_for_task = Arc::clone(&startup_logs);
|
||||
|
||||
let (mut rx, child) = sidecar
|
||||
.spawn()
|
||||
.map_err(|err| format!("spawn server sidecar: {err}"))?;
|
||||
@ -598,18 +629,33 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
println!("[claude-server] {}", line.trim_end());
|
||||
let line = line.trim_end();
|
||||
println!("[claude-server] {line}");
|
||||
push_server_startup_log(&logs_for_task, format!("[stdout] {line}"));
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
eprintln!("[claude-server] {}", line.trim_end());
|
||||
let line = line.trim_end();
|
||||
eprintln!("[claude-server] {line}");
|
||||
push_server_startup_log(&logs_for_task, format!("[stderr] {line}"));
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
let line = format!(
|
||||
"sidecar exited (code={:?}, signal={:?})",
|
||||
payload.code, payload.signal
|
||||
);
|
||||
eprintln!("[claude-server] {line}");
|
||||
push_server_startup_log(&logs_for_task, format!("[exit] {line}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wait_for_server(host, port)?;
|
||||
if let Err(err) = wait_for_server(host, port) {
|
||||
let _ = child.kill();
|
||||
return Err(format_server_startup_error(&err, &startup_logs));
|
||||
}
|
||||
|
||||
Ok(ServerRuntime { url, child })
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||
import { TabBar } from './TabBar'
|
||||
import { StartupErrorView } from './StartupErrorView'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -73,18 +74,7 @@ export function AppShell() {
|
||||
useKeyboardShortcuts()
|
||||
|
||||
if (startupError) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
|
||||
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('app.serverFailed')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{startupError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <StartupErrorView error={startupError} />
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
|
||||
46
desktop/src/components/layout/StartupErrorView.test.tsx
Normal file
46
desktop/src/components/layout/StartupErrorView.test.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { splitStartupError, StartupErrorView } from './StartupErrorView'
|
||||
|
||||
describe('splitStartupError', () => {
|
||||
it('separates the timeout message from captured sidecar logs', () => {
|
||||
const result = splitStartupError(
|
||||
'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds\n\nRecent server logs:\n[stderr] failed to bind\n[exit] sidecar exited (code=1, signal=None)',
|
||||
)
|
||||
|
||||
expect(result.message).toBe(
|
||||
'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds',
|
||||
)
|
||||
expect(result.logs).toContain('[stderr] failed to bind')
|
||||
expect(result.diagnostics).toContain('Recent server logs:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StartupErrorView', () => {
|
||||
it('shows diagnostics and copies the full payload', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
|
||||
expect(screen.getByText('startup failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('[stderr] boom')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'startup failed\n\nRecent server logs:\n[stderr] boom',
|
||||
)
|
||||
})
|
||||
expect(screen.getByText('已复制')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
99
desktop/src/components/layout/StartupErrorView.tsx
Normal file
99
desktop/src/components/layout/StartupErrorView.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { Copy, RefreshCw } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
|
||||
const LOG_MARKER = '\n\nRecent server logs:\n'
|
||||
|
||||
export function splitStartupError(error: string) {
|
||||
const markerIndex = error.indexOf(LOG_MARKER)
|
||||
if (markerIndex === -1) {
|
||||
return {
|
||||
message: error,
|
||||
logs: '',
|
||||
diagnostics: error,
|
||||
}
|
||||
}
|
||||
|
||||
const message = error.slice(0, markerIndex).trim()
|
||||
const logs = error.slice(markerIndex + LOG_MARKER.length).trim()
|
||||
return {
|
||||
message,
|
||||
logs,
|
||||
diagnostics: `${message}\n\nRecent server logs:\n${logs}`,
|
||||
}
|
||||
}
|
||||
|
||||
type StartupErrorViewProps = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export function StartupErrorView({ error }: StartupErrorViewProps) {
|
||||
const t = useTranslation()
|
||||
const { message, logs, diagnostics } = useMemo(() => splitStartupError(error), [error])
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard?.writeText(diagnostics)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
|
||||
<section className="w-full max-w-3xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('app.serverFailed')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('app.serverFailedHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
||||
{t('app.startupError')}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--color-error)]">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{logs ? (
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
||||
{t('app.serverLogs')}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{logs}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Copy className="h-4 w-4" aria-hidden="true" />}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? t('app.copiedDiagnostics') : t('app.copyDiagnostics')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<RefreshCw className="h-4 w-4" aria-hidden="true" />}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -850,6 +850,11 @@ export const en = {
|
||||
|
||||
// ─── App Shell ──────────────────────────────────────
|
||||
'app.serverFailed': 'Local server failed to start',
|
||||
'app.serverFailedHint': 'Please include a screenshot of this diagnostic panel when reporting the issue.',
|
||||
'app.startupError': 'Startup error',
|
||||
'app.serverLogs': 'Server logs',
|
||||
'app.copyDiagnostics': 'Copy diagnostics',
|
||||
'app.copiedDiagnostics': 'Copied',
|
||||
'app.launching': 'Launching local workspace...',
|
||||
|
||||
// ─── Error Codes ──────────────────────────────────────
|
||||
|
||||
@ -852,6 +852,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── 应用外壳 ──────────────────────────────────────
|
||||
'app.serverFailed': '本地服务启动失败',
|
||||
'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。',
|
||||
'app.startupError': '启动错误',
|
||||
'app.serverLogs': '服务日志',
|
||||
'app.copyDiagnostics': '复制诊断信息',
|
||||
'app.copiedDiagnostics': '已复制',
|
||||
'app.launching': '正在启动本地工作区...',
|
||||
|
||||
// ─── Error Codes ──────────────────────────────────────
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user