9ea777d7b7
Frontend now renders three-tier status (passed/warn/failed) instead of
two. Warn rows get a yellow exclamation-mark dot, expand by default to
show the hint, and contribute to a new "(with warnings)" suffix on the
"All checks passed" header.
Test catalog gains "voice-quality" and "voice-srv" rows replacing the
single "stun" row, in the same position (after udp, before api). RU
descriptions explain what each test actually probes.
useDrover's lastSummary now reports {total, failed, warnings} so the
Classic header can pick the right tier color.
App.go counts StatusWarn as passed in the final {passed, failed} summary
emitted via "check:done" — warn is a soft pass per spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
511 lines
22 KiB
React
511 lines
22 KiB
React
// Classic.jsx — Variant 1: Classic devtool.
|
|
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
|
|
// Sober palette: neutral grays + one teal accent for primary action / success.
|
|
|
|
import * as React from 'react'
|
|
import {
|
|
useDrover,
|
|
BrandMark, IconGear, IconMin, IconClose, IconChevron, IconCopy,
|
|
IconArrowUp, IconArrowDown, IconSun, IconMoon, StatusDot,
|
|
fmtBytes, fmtUptime, fmtTime,
|
|
} from './shared.jsx'
|
|
import { WindowMinimise, Quit } from '../../wailsjs/runtime/runtime'
|
|
import { Version as GoVersion } from '../../wailsjs/go/gui/App'
|
|
|
|
const ClassicTheme = {
|
|
// dark
|
|
d: {
|
|
bg: '#1c1d20',
|
|
chrome: '#15161a',
|
|
panel: '#22242a',
|
|
panelAlt: '#1a1c20',
|
|
border: '#34373d',
|
|
borderSoft:'#2a2c32',
|
|
text: '#dde0e6',
|
|
dim: '#8a8f99',
|
|
dimmer: '#5b6068',
|
|
accent: '#3ea99f', // teal
|
|
accentDim: '#2a7d76',
|
|
danger: '#d96565',
|
|
warn: '#d9a155',
|
|
pass: '#5cba8b',
|
|
skip: '#7c8088',
|
|
inputBg: '#15161a',
|
|
btnBg: '#2c2f36',
|
|
btnBgH: '#373b43',
|
|
primaryBg: '#3ea99f',
|
|
primaryFg: '#0c1a18',
|
|
},
|
|
l: {
|
|
bg: '#f3f4f6',
|
|
chrome: '#e8eaef',
|
|
panel: '#ffffff',
|
|
panelAlt: '#f8f9fb',
|
|
border: '#d8dbe1',
|
|
borderSoft:'#e6e8ec',
|
|
text: '#1c1f24',
|
|
dim: '#5c6168',
|
|
dimmer: '#8a8f97',
|
|
accent: '#2a7d76',
|
|
accentDim: '#bdded9',
|
|
danger: '#c0463f',
|
|
warn: '#a8731e',
|
|
pass: '#2f8c5a',
|
|
skip: '#7c8088',
|
|
inputBg: '#ffffff',
|
|
btnBg: '#ffffff',
|
|
btnBgH: '#f1f2f5',
|
|
primaryBg: '#2a7d76',
|
|
primaryFg: '#ffffff',
|
|
},
|
|
};
|
|
|
|
export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) {
|
|
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
|
|
const D = useDrover(initial);
|
|
const [version, setVersion] = React.useState('');
|
|
React.useEffect(() => { GoVersion().then(setVersion).catch(() => {}); }, []);
|
|
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip, warn: t.warn };
|
|
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
|
|
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
|
const isActive = D.phase === 'active';
|
|
const allChecked = D.phase === 'checked' || D.phase === 'active';
|
|
const failed = D.lastSummary?.failed ?? 0;
|
|
|
|
return (
|
|
<div style={{
|
|
width: '100vw', height: '100vh', background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
|
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
|
|
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
|
|
}}>
|
|
{/* ─── title bar ─── */}
|
|
<ClassicTitleBar t={t} version={version} mode={mode} onToggleMode={onToggleMode} />
|
|
|
|
{/* ─── content ─── */}
|
|
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
|
|
|
|
{/* Form */}
|
|
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
<Field t={t} label="Host" style={{ flex: 1 }}>
|
|
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
|
placeholder="95.165.72.59 или example.com"
|
|
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
|
style={inputStyle(t, fontMono)} />
|
|
</Field>
|
|
<Field t={t} label="Port" style={{ width: 92 }}>
|
|
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
|
placeholder="12334" inputMode="numeric"
|
|
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
|
style={inputStyle(t, fontMono)} />
|
|
</Field>
|
|
</div>
|
|
|
|
<Checkbox t={t} checked={D.form.auth}
|
|
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
|
|
Authentication
|
|
</Checkbox>
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
|
|
<Field t={t} label="Login" style={{ flex: 1 }}>
|
|
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
|
|
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
|
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
|
style={inputStyle(t, fontMono, !D.form.auth)} />
|
|
</Field>
|
|
<Field t={t} label="Password" style={{ flex: 1 }}>
|
|
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
|
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
|
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
|
style={inputStyle(t, fontMono, !D.form.auth)} />
|
|
</Field>
|
|
</div>
|
|
|
|
{D.phase === 'checking' ? (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<PrimaryBtn t={t} onClick={D.runCheck} disabled style={{ flex: 1 }}>
|
|
Checking…
|
|
</PrimaryBtn>
|
|
<ClassicCancelBtn t={t} onClick={D.cancelCheck} />
|
|
</div>
|
|
) : (
|
|
<PrimaryBtn t={t} onClick={D.runCheck} disabled={isActive}>
|
|
Check connection
|
|
</PrimaryBtn>
|
|
)}
|
|
|
|
{/* Status */}
|
|
<div style={{ height: 18 }} />
|
|
<SectionLabel t={t}>Status</SectionLabel>
|
|
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
|
|
|
|
{/* Action buttons */}
|
|
<div style={{ height: 14 }} />
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
|
|
<ClassicStopBtn t={t} D={D} />
|
|
</div>
|
|
|
|
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
|
|
|
<div style={{ height: 14 }} />
|
|
</div>
|
|
|
|
{/* Logs collapsible */}
|
|
<ClassicLogs t={t} D={D} fontMono={fontMono} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── pieces ─────────────────────────────────────────────────────────────────
|
|
function ClassicTitleBar({ t, version, mode, onToggleMode }) {
|
|
// Cell height = full title-bar height so the hover background fills
|
|
// edge-to-edge (no thin sliver of chrome above the red close highlight).
|
|
// alignSelf:'stretch' on the wrapper keeps it pinned to the top/bottom
|
|
// of the flex row even though parent uses alignItems:'center' for text.
|
|
const cellStyle = {
|
|
width: 38, height: '100%', display:'flex', alignItems:'center', justifyContent:'center',
|
|
color: t.dim, cursor:'pointer',
|
|
};
|
|
return (
|
|
<div style={{
|
|
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
|
|
display:'flex', alignItems:'stretch',
|
|
// CSS Wails recognises for the OS title-bar drag region. The
|
|
// close/min cells below override it with --wails-draggable: no-drag
|
|
// so clicks land on the buttons, not the drag handler.
|
|
['--wails-draggable']: 'drag',
|
|
userSelect:'none',
|
|
}}>
|
|
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
|
|
<BrandMark size={14} color={t.accent}/>
|
|
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
|
|
{version && <span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v{version}</span>}
|
|
</div>
|
|
<div style={{ display:'flex', alignItems:'stretch', ['--wails-draggable']: 'no-drag' }}>
|
|
<div style={cellStyle}
|
|
title={mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
onClick={(e) => onToggleMode && onToggleMode(e)}>
|
|
{mode === 'dark' ? <IconSun color={t.dim}/> : <IconMoon color={t.dim}/>}
|
|
</div>
|
|
<div style={cellStyle} title="Minimize" onClick={() => WindowMinimise()}>
|
|
<IconMin color={t.dim}/>
|
|
</div>
|
|
<div style={cellStyle} title="Close"
|
|
onClick={() => Quit()}
|
|
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
<IconClose color={t.dim}/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({ children, t }) {
|
|
return <div style={{
|
|
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
|
|
color: t.dim, marginBottom: 8,
|
|
}}>{children}</div>;
|
|
}
|
|
|
|
function Field({ children, label, t, style }) {
|
|
return (
|
|
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
|
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function inputStyle(t, fontMono, disabled) {
|
|
return {
|
|
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
|
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
|
|
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
|
|
transition: 'border-color .12s, box-shadow .12s',
|
|
};
|
|
}
|
|
|
|
function Checkbox({ checked, onChange, children, t }) {
|
|
return (
|
|
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
|
|
<span style={{
|
|
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
|
|
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
|
|
transition: 'background .12s, border-color .12s',
|
|
}}>
|
|
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
|
</span>
|
|
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function PrimaryBtn({ t, onClick, disabled, children, style }) {
|
|
return (
|
|
<button onClick={onClick} disabled={disabled}
|
|
style={{
|
|
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
|
|
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
|
|
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
|
|
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
|
|
transition: 'background .12s', ...style,
|
|
}}>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── status panel ──────────────────────────────────────────────────────────
|
|
function ClassicStatus({ t, D, palette, fontMono }) {
|
|
const idle = D.phase === 'idle';
|
|
if (idle) {
|
|
return (
|
|
<div style={{
|
|
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
|
|
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
|
|
}}>
|
|
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
|
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
|
|
{/* header */}
|
|
<div style={{
|
|
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
|
|
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
|
|
}}>
|
|
{D.phase === 'checking'
|
|
? <>
|
|
<StatusDot state="running" palette={palette} size={12}/>
|
|
<span>Running diagnostics…</span>
|
|
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
|
{Object.keys(D.results).length}/{D.tests.length}
|
|
</span>
|
|
</>
|
|
: D.lastSummary?.failed === 0
|
|
? (D.lastSummary?.warnings > 0
|
|
? <span style={{ color: t.warn, fontWeight: 600 }}>All checks passed (with warnings).</span>
|
|
: <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>)
|
|
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
|
</div>
|
|
{/* tests */}
|
|
<div>
|
|
{D.tests.map((test, i) => {
|
|
const r = D.results[test.id];
|
|
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
|
const isLast = i === D.tests.length - 1;
|
|
return (
|
|
<div key={test.id} style={{
|
|
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
|
|
padding: '6px 12px',
|
|
}}>
|
|
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
|
|
<StatusDot state={state} palette={palette} size={12}/>
|
|
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
|
|
{test.label}
|
|
</span>
|
|
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
|
color: state === 'failed' ? t.danger : state === 'warn' ? t.warn : state === 'skipped' ? t.skip : t.dim }}>
|
|
{r?.metric || (state === 'running' ? '...' : '')}
|
|
</span>
|
|
{(r?.result === 'failed' || r?.result === 'warn') && (
|
|
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
|
|
<IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{(r?.result === 'failed' || r?.result === 'warn') && r.expanded && (
|
|
<div className="drv-fadein" style={{
|
|
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
|
|
background: mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.9),
|
|
border: `1px solid ${mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.78)}`,
|
|
fontSize: 11.5, color: t.text,
|
|
}}>
|
|
{r.error
|
|
? <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
|
: (r.hint && <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.hint}</div>)}
|
|
{r.error && <div style={{ color: t.dim }}>{r.hint}</div>}
|
|
{r.rawHex && (
|
|
<div style={{
|
|
fontFamily: fontMono, fontSize: 10.5, color: t.dimmer,
|
|
marginTop: 4, padding: '4px 6px',
|
|
background: t.panelAlt, borderRadius: 2,
|
|
overflowX: 'auto', whiteSpace: 'nowrap',
|
|
}}>
|
|
{r.rawHex.length > 64 ? r.rawHex.slice(0, 64) + '…' : r.rawHex}
|
|
</div>
|
|
)}
|
|
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
|
<button onClick={() => navigator.clipboard?.writeText(
|
|
`[${test.label}] ${r.error} — ${r.metric}` + (r.rawHex ? ` — raw=${r.rawHex}` : ''))}
|
|
style={smallBtn(t, fontMono)}>
|
|
<IconCopy color={t.dim}/> copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function iconBtnStyle(t) {
|
|
return {
|
|
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
|
|
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
|
|
borderRadius: 2,
|
|
};
|
|
}
|
|
function smallBtn(t, fontMono) {
|
|
return {
|
|
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
|
|
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
|
|
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
|
|
};
|
|
}
|
|
|
|
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
|
|
function mode_mix(fg, bg, amt) {
|
|
const a = hexToRgb(fg), b = hexToRgb(bg);
|
|
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
|
|
}
|
|
function hexToRgb(h) {
|
|
const v = h.replace('#','');
|
|
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
|
|
}
|
|
|
|
// ─── start/stop ────────────────────────────────────────────────────────────
|
|
function ClassicStartBtn({ t, D, fontMono }) {
|
|
const phase = D.phase;
|
|
const summary = D.lastSummary;
|
|
const allFailed = summary && summary.failed === D.tests.length;
|
|
const checkedOk = phase === 'checked' && !allFailed;
|
|
const active = phase === 'active';
|
|
const warning = active && (summary?.failed || 0) > 0;
|
|
|
|
if (active) {
|
|
return (
|
|
<div style={{
|
|
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
|
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
|
|
border: `1px solid ${warning ? t.warn : t.pass}`,
|
|
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
|
|
}}>
|
|
<span className="drv-pulsedot" style={{
|
|
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
|
|
}}/>
|
|
Active{warning ? ' · UDP fallback' : ''}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
|
|
Start proxying
|
|
</PrimaryBtn>
|
|
);
|
|
}
|
|
|
|
function ClassicCancelBtn({ t, onClick }) {
|
|
const [hover, setHover] = React.useState(false);
|
|
return (
|
|
<button onClick={onClick}
|
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
width: 92, padding: '9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
|
background: t.btnBg, color: hover ? t.danger : t.text,
|
|
border: `1px solid ${hover ? t.danger : t.border}`, cursor: 'pointer',
|
|
transition: 'color .12s, border-color .12s',
|
|
}}>
|
|
Cancel
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ClassicStopBtn({ t, D }) {
|
|
const enabled = D.phase === 'active';
|
|
return (
|
|
<button onClick={D.stopProxy} disabled={!enabled}
|
|
style={{
|
|
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
|
background: t.btnBg, color: enabled ? t.text : t.dimmer,
|
|
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
|
|
}}>
|
|
Stop
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ClassicLiveStats({ t, stats, fontMono }) {
|
|
const cell = (icon, val) => (
|
|
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
|
{icon}<span>{val}</span>
|
|
</div>
|
|
);
|
|
return (
|
|
<div style={{
|
|
marginTop: 8, padding: '6px 10px', borderRadius: 3,
|
|
background: t.panel, border: `1px solid ${t.borderSoft}`,
|
|
display:'flex', justifyContent:'space-between', alignItems:'center',
|
|
}}>
|
|
{cell(<IconArrowUp color={t.dim}/>, fmtBytes(stats.up))}
|
|
{cell(<IconArrowDown color={t.dim}/>, fmtBytes(stats.down))}
|
|
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
|
|
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
|
|
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, fmtUptime(stats.uptimeS))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── logs ──────────────────────────────────────────────────────────────────
|
|
function ClassicLogs({ t, D, fontMono }) {
|
|
return (
|
|
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
|
|
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
|
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
|
|
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
|
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
|
|
}}>
|
|
<IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
|
<span style={{ textTransform:'uppercase' }}>Logs</span>
|
|
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
|
</button>
|
|
{D.logsOpen && (
|
|
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
|
|
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
|
|
<button style={smallBtn(t, fontMono)}
|
|
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
|
|
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
|
|
<button style={smallBtn(t, fontMono)}>open log file</button>
|
|
</div>
|
|
<div className="drv-log" style={{
|
|
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
|
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
|
background: t.panelAlt,
|
|
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
|
{D.logs.map((l, i) => (
|
|
<div key={i}>
|
|
<span style={{ color: t.dimmer }}>{fmtTime(l.t)}</span>
|
|
{' '}
|
|
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
|
{' '}
|
|
<span>{l.msg}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default export so App.jsx can `import ClassicWindow from './components/Classic'`.
|
|
export default ClassicWindow;
|