feat: complete query result controls
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
parent
ec23258cc9
commit
8e01acd35c
@ -1,4 +1,4 @@
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
@ -8,13 +8,13 @@ const types=['A','AAAA','CNAME','MX','NS','TXT','SOA','CAA'];
|
||||
const statusLabel:Record<string,string>={success:'成功',no_record:'无记录',failed:'失败',timeout:'超时'};
|
||||
|
||||
function App(){
|
||||
const [input,setInput]=useState('example.com'),[kind,setKind]=useState('A'),[domain,setDomain]=useState(''),[queryId,setQueryId]=useState(''),[results,setResults]=useState<Result[]>([]),[running,setRunning]=useState(false),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[dark,setDark]=useState(()=>localStorage.theme==='dark');
|
||||
const [input,setInput]=useState('example.com'),[kind,setKind]=useState('A'),[domain,setDomain]=useState(''),[queryId,setQueryId]=useState(''),[results,setResults]=useState<Result[]>([]),[running,setRunning]=useState(false),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[region,setRegion]=useState('all'),[completedAt,setCompletedAt]=useState(''),[dark,setDark]=useState(()=>localStorage.theme==='dark');
|
||||
useEffect(()=>{document.documentElement.dataset.theme=dark?'dark':'light';localStorage.theme=dark?'dark':'light'},[dark]);
|
||||
useEffect(()=>{if(!queryId)return;const stream=new EventSource(`/api/dns/query/${queryId}/events`);stream.addEventListener('result',e=>setResults(old=>[...old,JSON.parse((e as MessageEvent).data)]));stream.addEventListener('complete',()=>{setRunning(false);stream.close()});stream.onerror=()=>{setRunning(false);stream.close()};return()=>stream.close()},[queryId]);
|
||||
const query=async()=>{setError('');setResults([]);setQueryId('');setRunning(true);try{const res=await fetch('/api/dns/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:input,type:kind})});const data=await res.json();if(!res.ok)throw new Error(data.error||'查询失败');setDomain(data.domain);setQueryId(data.queryId)}catch(e){setError(e instanceof Error?e.message:'查询失败');setRunning(false)}};
|
||||
useEffect(()=>{if(!queryId)return;const stream=new EventSource(`/api/dns/query/${queryId}/events`);stream.addEventListener('result',e=>{const item=JSON.parse((e as MessageEvent).data) as Result;item.records??=[];setResults(old=>[...old,item])});stream.addEventListener('complete',()=>{setRunning(false);setCompletedAt(new Date().toLocaleString('zh-CN',{hour12:false}));stream.close()});stream.onerror=()=>{setRunning(false);stream.close()};return()=>stream.close()},[queryId]);
|
||||
const query=async()=>{setError('');setResults([]);setQueryId('');setCompletedAt('');setFilter('all');setRegion('all');setRunning(true);try{const res=await fetch('/api/dns/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:input,type:kind})});const data=await res.json();if(!res.ok)throw new Error(data.error||'查询失败');setDomain(data.domain);setQueryId(data.queryId)}catch(e){setError(e instanceof Error?e.message:'查询失败');setRunning(false)}};
|
||||
const signatures=results.filter(x=>x.status==='success').map(x=>x.records.map(r=>r.value).sort().join('|'));
|
||||
const consistent=signatures.length>1&&new Set(signatures).size===1;
|
||||
const filtered=filter==='all'?results:results.filter(x=>x.status===filter);
|
||||
const filtered=results.filter(x=>(filter==='all'||x.status===filter)&&(region==='all'||x.node.region===region));
|
||||
const copyAll=()=>navigator.clipboard.writeText(results.flatMap(x=>x.records.map(r=>`${x.node.name}\t${r.value}\tTTL ${r.ttl}`)).join('\n'));
|
||||
const success=results.filter(x=>x.status==='success').length;
|
||||
return <main>
|
||||
@ -23,10 +23,10 @@ function App(){
|
||||
<div className="search"><input value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>e.key==='Enter'&&query()} placeholder="输入域名,如 example.com" aria-label="域名"/><select value={kind} onChange={e=>setKind(e.target.value)} aria-label="记录类型">{types.map(x=><option key={x}>{x}</option>)}</select><button onClick={query} disabled={running}>{running?'查询中…':'全球查询 →'}</button></div>{error&&<p className="error">{error}</p>}
|
||||
<div className="quick"><span>快速尝试</span>{['google.com','cloudflare.com','github.com'].map(x=><button key={x} onClick={()=>setInput(x)}>{x}</button>)}</div>
|
||||
</section>
|
||||
{(queryId||running)&&<section className="results"><div className="resultHead"><div><span className="live">● {running?'实时查询中':'查询完成'}</span><h2>{domain} <b>{kind}</b></h2></div><button onClick={copyAll}>复制全部</button></div>
|
||||
{(queryId||running)&&<section className="results"><div className="resultHead"><div><span className="live">● {running?'实时查询中':'查询完成'}</span><h2>{domain} <b>{kind}</b></h2>{completedAt&&<small className="finished">完成于 {completedAt}</small>}</div><div className="actions"><button onClick={query} disabled={running}>重新查询</button><button onClick={copyAll}>复制全部</button></div></div>
|
||||
<div className="stats"><Stat label="查询节点" value={`${results.length}/4`}/><Stat label="成功" value={String(success)} tone="green"/><Stat label="异常" value={String(results.length-success)} tone="red"/><Stat label="结果一致" value={results.length<2?'—':consistent?'是':'否'} tone={consistent?'green':'amber'}/></div>
|
||||
<div className="toolbar">{[['all','全部'],['success','成功'],['no_record','无记录'],['failed','失败']].map(([v,l])=><button className={filter===v?'active':''} onClick={()=>setFilter(v)} key={v}>{l}</button>)}</div>
|
||||
<div className="table"><div className="tr th"><span>区域 / 解析器</span><span>状态</span><span>DNS 结果</span><span>TTL</span><span>响应时间</span></div>{filtered.map((r,i)=><div className="tr" key={`${r.node.name}-${i}`}><span data-label="区域 / 解析器"><strong>{r.node.name}</strong><small>{r.node.region} · {r.node.country}</small></span><span data-label="状态"><i className={`dot ${r.status}`}></i>{statusLabel[r.status]||r.status}</span><span data-label="DNS 结果" className="values">{r.records.length?r.records.map(x=><code key={x.value}>{x.value}</code>):<span className="muted">{r.error||'—'}</span>}</span><span data-label="TTL">{r.records[0]?.ttl??'—'}</span><span data-label="响应时间">{r.responseTime?`${r.responseTime} ms`:'—'}</span></div>)}{running&&Array.from({length:Math.max(0,4-results.length)},(_,i)=><div className="tr loading" key={i}><span><b className="skeleton wide"></b></span><span><b className="skeleton"></b></span><span><b className="skeleton wide"></b></span><span>—</span><span>—</span></div>)}</div>
|
||||
<div className="toolbar"><div>{[['all','全部'],['success','成功'],['no_record','无记录'],['failed','失败']].map(([v,l])=><button className={filter===v?'active':''} onClick={()=>setFilter(v)} key={v}>{l}</button>)}</div><select value={region} onChange={e=>setRegion(e.target.value)} aria-label="地区筛选"><option value="all">全部地区</option>{[...new Set(results.map(x=>x.node.region))].map(x=><option key={x}>{x}</option>)}</select></div>
|
||||
<div className="table"><div className="tr th"><span>区域 / 解析器</span><span>状态</span><span>DNS 结果</span><span>TTL</span><span>响应时间</span></div>{filtered.map((r,i)=><div className="tr" key={`${r.node.name}-${i}`}><span data-label="区域 / 解析器"><strong>{r.node.name}</strong><small>{r.node.region} · {r.node.country}</small></span><span data-label="状态"><i className={`dot ${r.status}`}></i>{statusLabel[r.status]||r.status}</span><span data-label="DNS 结果" className="values">{r.records.length?<>{r.records.map(x=><code key={x.value}>{x.value}</code>)}<button className="copyOne" onClick={()=>navigator.clipboard.writeText(r.records.map(x=>x.value).join('\n'))}>复制</button></>:<span className="muted">{r.error||'—'}</span>}</span><span data-label="TTL">{r.records[0]?.ttl??'—'}</span><span data-label="响应时间">{r.responseTime?`${r.responseTime} ms`:'—'}</span></div>)}{running&&Array.from({length:Math.max(0,4-results.length)},(_,i)=><div className="tr loading" key={i}><span><b className="skeleton wide"></b></span><span><b className="skeleton"></b></span><span><b className="skeleton wide"></b></span><span>—</span><span>—</span></div>)}</div>
|
||||
<p className="notice">首版节点代表不同公共 DNS 服务商的 Anycast 解析结果,不等同于真实地理探针。</p>
|
||||
</section>}
|
||||
{!queryId&&!running&&<section className="features"><article><b>01</b><h3>多解析器并行</h3><p>同时请求全球主流 DNS 服务,快速发现缓存差异。</p></article><article><b>02</b><h3>实时返回</h3><p>结果通过 SSE 逐条到达,无需等待所有节点完成。</p></article><article><b>03</b><h3>隐私优先</h3><p>无需注册,查询数据仅在内存中短暂保留。</p></article></section>}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
:root{font-family:'DM Sans',sans-serif;color:#15221c;background:#f5f7f3;line-height:1.5;--card:#fff;--line:#dce3dd;--muted:#65736b;--ink:#15221c;--green:#1d684c;--lime:#d8f56b}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 10%,#eaf4d3 0,transparent 28%),#f5f7f3;color:var(--ink)}[data-theme=dark]{--card:#17221d;--line:#33423a;--muted:#91a097;--ink:#eef5ef;background:#101713;color-scheme:dark}button,input,select{font:inherit}button{cursor:pointer}main{max-width:1180px;margin:auto;padding:0 28px}header{height:88px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;gap:12px;align-items:center}.brand div{display:flex;flex-direction:column}.brand strong{font:700 18px 'Space Grotesk'}.brand small{font-size:11px;color:var(--muted);letter-spacing:.06em}.logo{display:grid;place-items:center;width:38px;height:38px;background:var(--green);color:white;border-radius:11px;font:700 20px 'Space Grotesk'}.theme{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;width:40px;height:40px}.hero{text-align:center;padding:82px 0 64px}.eyebrow{font:600 11px 'Space Grotesk';letter-spacing:.22em;color:var(--green)}h1{font:600 clamp(48px,7vw,82px)/.98 'Space Grotesk';letter-spacing:-.055em;margin:18px 0 24px}h1 em{font-style:normal;color:var(--green)}.hero>p{color:var(--muted);font-size:17px}.search{max-width:850px;margin:38px auto 17px;background:var(--card);padding:8px;border:1px solid var(--line);border-radius:16px;box-shadow:0 18px 45px #173b2720;display:flex}.search input{flex:1;min-width:0;border:0;background:transparent;padding:0 18px;color:var(--ink);outline:0}.search select{border:0;border-left:1px solid var(--line);background:transparent;color:var(--ink);padding:0 20px}.search button{border:0;background:var(--green);color:white;border-radius:10px;padding:15px 25px;font-weight:600}.search button:disabled{opacity:.6}.quick{display:flex;gap:9px;align-items:center;justify-content:center;color:var(--muted);font-size:12px}.quick button,.toolbar button{border:1px solid var(--line);border-radius:20px;padding:6px 12px;background:var(--card);color:var(--muted)}.error{color:#b53f3f!important}.features{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.features article{padding:40px 36px;border-right:1px solid var(--line)}.features article:last-child{border:0}.features b{color:var(--green);font:600 12px 'Space Grotesk'}.features h3{font:600 20px 'Space Grotesk';margin:20px 0 8px}.features p{font-size:14px;color:var(--muted)}.results{padding:40px 0 20px}.resultHead{display:flex;align-items:end;justify-content:space-between}.resultHead h2{font:600 32px 'Space Grotesk';margin:8px 0}.resultHead h2 b{font-size:13px;background:var(--lime);color:#26320d;border-radius:5px;padding:5px 8px;vertical-align:middle}.resultHead>button{border:1px solid var(--line);background:var(--card);color:var(--ink);padding:10px 16px;border-radius:9px}.live{font-size:11px;color:var(--green);letter-spacing:.08em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:25px 0}.stats>div{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px}.stats small{display:block;color:var(--muted)}.stats strong{font:600 28px 'Space Grotesk'}.green{color:#2e966d}.red{color:#c15454}.amber{color:#bd8b27}.toolbar{display:flex;gap:8px;margin-bottom:12px}.toolbar .active{background:var(--ink);color:var(--card)}.table{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden}.tr{display:grid;grid-template-columns:1.25fr .7fr 2fr .5fr .7fr;gap:15px;padding:17px 20px;border-bottom:1px solid var(--line);align-items:center;font-size:13px}.tr:last-child{border:0}.th{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.tr>span:first-child{display:flex;flex-direction:column}.tr small{color:var(--muted)}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:7px;background:#d3a337}.dot.success{background:#2e966d}.dot.failed,.dot.timeout{background:#c15454}.values{display:flex;flex-direction:column;align-items:flex-start;overflow:hidden}.values code{max-width:100%;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}.muted,.notice{color:var(--muted)}.notice{text-align:center;font-size:12px}.skeleton{display:block;width:75px;height:12px;border-radius:4px;background:linear-gradient(90deg,var(--line),#edf1ed,var(--line));background-size:200%;animation:pulse 1.3s infinite}.skeleton.wide{width:130px}@keyframes pulse{to{background-position:-200%}}footer{padding:40px;text-align:center;color:var(--muted);font-size:12px}
|
||||
@media(max-width:720px){main{padding:0 16px}.hero{padding:55px 0 40px}.search{flex-wrap:wrap}.search input{height:50px;width:70%}.search select{width:30%}.search button{width:100%}.quick{flex-wrap:wrap}.features{grid-template-columns:1fr}.features article{border-right:0;border-bottom:1px solid var(--line)}.stats{grid-template-columns:1fr 1fr}.th{display:none}.tr{display:block;padding:17px}.tr>span{display:flex!important;justify-content:space-between;margin:7px 0}.tr>span:before{content:attr(data-label);color:var(--muted);font-size:11px}.values{align-items:flex-end}.resultHead h2{font-size:24px}}
|
||||
.actions{display:flex;gap:8px}.actions button,.copyOne{border:1px solid var(--line);background:var(--card);color:var(--ink);padding:10px 16px;border-radius:9px}.actions button:disabled{opacity:.5}.finished{color:var(--muted)}.toolbar{justify-content:space-between}.toolbar>div{display:flex;gap:8px}.toolbar select{border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--ink);padding:6px 12px}.values{position:relative;padding-right:45px}.copyOne{position:absolute;right:0;top:50%;transform:translateY(-50%);padding:4px 7px;font-size:11px}
|
||||
@media(max-width:720px){.toolbar{align-items:flex-start;flex-direction:column}.toolbar>div{flex-wrap:wrap}.resultHead{align-items:flex-start}.actions{flex-direction:column}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user