mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
fix: show desktop server startup diagnostics
This commit is contained in:
parent
139dcbc4c7
commit
2961a49fc3
@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, VecDeque},
|
||||||
io::{Error as IoError, ErrorKind, Read, Write},
|
io::{Error as IoError, ErrorKind, Read, Write},
|
||||||
net::{SocketAddr, TcpListener, TcpStream},
|
net::{SocketAddr, TcpListener, TcpStream},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
@ -7,7 +7,7 @@ use std::{
|
|||||||
str,
|
str,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicU32, Ordering},
|
atomic::{AtomicU32, Ordering},
|
||||||
Mutex,
|
Arc, Mutex,
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@ -24,6 +24,8 @@ use tauri_plugin_shell::{
|
|||||||
ShellExt,
|
ShellExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SERVER_STARTUP_LOG_LIMIT: usize = 80;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ServerState(Mutex<ServerStatus>);
|
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> {
|
fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
// 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上
|
// 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上
|
||||||
// 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ +
|
// 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ +
|
||||||
@ -589,6 +617,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
|||||||
&port.to_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
|
let (mut rx, child) = sidecar
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|err| format!("spawn server sidecar: {err}"))?;
|
.map_err(|err| format!("spawn server sidecar: {err}"))?;
|
||||||
@ -598,18 +629,33 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
|||||||
match event {
|
match event {
|
||||||
CommandEvent::Stdout(line) => {
|
CommandEvent::Stdout(line) => {
|
||||||
let line = String::from_utf8_lossy(&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) => {
|
CommandEvent::Stderr(line) => {
|
||||||
let line = String::from_utf8_lossy(&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 })
|
Ok(ServerRuntime { url, child })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
|||||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||||
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||||
import { TabBar } from './TabBar'
|
import { TabBar } from './TabBar'
|
||||||
|
import { StartupErrorView } from './StartupErrorView'
|
||||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@ -73,18 +74,7 @@ export function AppShell() {
|
|||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
|
|
||||||
if (startupError) {
|
if (startupError) {
|
||||||
return (
|
return <StartupErrorView error={startupError} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
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 Shell ──────────────────────────────────────
|
||||||
'app.serverFailed': 'Local server failed to start',
|
'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...',
|
'app.launching': 'Launching local workspace...',
|
||||||
|
|
||||||
// ─── Error Codes ──────────────────────────────────────
|
// ─── Error Codes ──────────────────────────────────────
|
||||||
|
|||||||
@ -852,6 +852,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// ─── 应用外壳 ──────────────────────────────────────
|
// ─── 应用外壳 ──────────────────────────────────────
|
||||||
'app.serverFailed': '本地服务启动失败',
|
'app.serverFailed': '本地服务启动失败',
|
||||||
|
'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。',
|
||||||
|
'app.startupError': '启动错误',
|
||||||
|
'app.serverLogs': '服务日志',
|
||||||
|
'app.copyDiagnostics': '复制诊断信息',
|
||||||
|
'app.copiedDiagnostics': '已复制',
|
||||||
'app.launching': '正在启动本地工作区...',
|
'app.launching': '正在启动本地工作区...',
|
||||||
|
|
||||||
// ─── Error Codes ──────────────────────────────────────
|
// ─── Error Codes ──────────────────────────────────────
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user