From ec23258cc9df33eac1062c5576d940e053fca27b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 17:42:44 +0000 Subject: [PATCH] feat: launch DNS query MVP Co-authored-by: multica-agent --- .dockerignore | 5 ++ .gitignore | 3 ++ Dockerfile | 26 ++++++++++ README.md | 38 ++++++++++++++- cmd/server/main.go | 104 ++++++++++++++++++++++++++++++++++++++++ cmd/server/main_test.go | 5 ++ compose.yaml | 16 +++++++ deploy/entrypoint.sh | 17 +++++++ deploy/nginx.conf | 13 +++++ go.mod | 4 ++ web/index.html | 1 + web/package.json | 1 + web/src/main.tsx | 37 ++++++++++++++ web/src/styles.css | 3 ++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 1 + 16 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 compose.yaml create mode 100644 deploy/entrypoint.sh create mode 100644 deploy/nginx.conf create mode 100644 go.mod create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d494cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +node_modules +web/dist +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1837e92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +web/node_modules/ +web/dist/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9eb6b2b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:24.5.0-alpine3.22 AS web +WORKDIR /src/web +COPY web/package.json web/tsconfig.json web/index.html ./ +COPY web/src ./src +RUN npm install --no-audit --no-fund && npm run build + +FROM golang:1.24.5-alpine3.22 AS api +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +RUN CGO_ENABLED=0 go test ./... && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server ./cmd/server + +FROM nginx:1.29.0-alpine3.22 +RUN apk add --no-cache curl && rm -f /etc/nginx/conf.d/default.conf && \ + sed -i 's#pid /run/nginx.pid;#pid /tmp/nginx.pid;#' /etc/nginx/nginx.conf && \ + addgroup -S app && adduser -S -G app app && \ + chown -R app:app /var/cache/nginx /var/run /var/log/nginx /etc/nginx/conf.d +COPY --from=web /src/web/dist /usr/share/nginx/html +COPY --from=api /server /usr/local/bin/server +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/entrypoint.sh /entrypoint.sh +RUN chmod 755 /entrypoint.sh +USER app +EXPOSE 8080 +HEALTHCHECK --interval=20s --timeout=3s --start-period=5s --retries=3 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index f940a66..2b174ad 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ -# dnsquery +# DNS Global +无需注册的轻量 DNS 传播检查工具。React 前端通过 SSE 实时展示 Go API 并行查询 Cloudflare、Google、AdGuard 和 AliDNS 的结果。 + +> 当前节点是公共 DNS 服务商的 Anycast 解析器,只能反映不同服务商的缓存差异,不代表真实地理位置。生产版可将后端 `nodes` 替换为各区域自建探针。 + +## 快速启动 + +```bash +docker compose up --build +``` + +打开 。停止服务: + +```bash +docker compose down +``` + +## API + +```bash +curl -X POST http://localhost:8080/api/dns/query \ + -H 'Content-Type: application/json' \ + -d '{"domain":"example.com","type":"A"}' +``` + +随后连接返回的查询 ID:`GET /api/dns/query/{queryId}/events`。 + +支持 `A`、`AAAA`、`CNAME`、`MX`、`NS`、`TXT`、`SOA` 和 `CAA`。输入 URL 时会自动提取域名。查询任务仅在内存保留五分钟;POST 接口按来源 IP 限制为每分钟 30 次。 + +## 验证 + +```bash +docker build -t dnsquery:test . +docker compose up -d +curl --fail http://localhost:8080/healthz +docker compose down +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0802589 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "sort" + "strings" + "sync" + "syscall" + "time" +) + +var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "NS": true, "TXT": true, "SOA": true, "CAA": true} + +type resolver struct{ Name, Region, Country, City, URL string } +type record struct { Value string `json:"value"`; TTL uint32 `json:"ttl"` } +type result struct { Node resolverView `json:"node"`; Status string `json:"status"`; Records []record `json:"records"`; ResponseTime int64 `json:"responseTime"`; Error string `json:"error,omitempty"` } +type resolverView struct { Name string `json:"name"`; Region string `json:"region"`; Country string `json:"country"`; City string `json:"city"` } +type query struct { ID string `json:"queryId"`; Domain string `json:"domain"`; Type string `json:"type"`; Status string `json:"status"`; CreatedAt time.Time `json:"createdAt"`; Results []result `json:"results,omitempty"`; Subscribers map[chan result]struct{} `json:"-"`; mu sync.RWMutex } +type store struct { mu sync.RWMutex; queries map[string]*query } + +var nodes = []resolver{ + {"Cloudflare", "North America", "United States", "Global Anycast", "https://cloudflare-dns.com/dns-query"}, + {"Google", "North America", "United States", "Global Anycast", "https://dns.google/resolve"}, + {"AdGuard", "Europe", "Cyprus", "Global Anycast", "https://dns.adguard-dns.com/resolve"}, + {"AliDNS", "Asia", "China", "Global Anycast", "https://dns.alidns.com/resolve"}, +} + +func main() { + s := &store{queries: make(map[string]*query)} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json"); io.WriteString(w, `{"status":"ok"}`) }) + mux.HandleFunc("POST /api/dns/query", s.create) + mux.HandleFunc("GET /api/dns/query/{id}", s.get) + mux.HandleFunc("GET /api/dns/query/{id}/events", s.events) + + server := &http.Server{Addr: env("ADDR", ":8080"), Handler: security(rateLimit(mux)), ReadHeaderTimeout: 5 * time.Second} + go s.cleanup() + go func() { slog.Info("server started", "addr", server.Addr); if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { slog.Error("server stopped", "error", err); os.Exit(1) } }() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM); defer stop(); <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second); defer cancel(); _ = server.Shutdown(shutdown) +} + +func (s *store) create(w http.ResponseWriter, r *http.Request) { + var body struct { Domain string `json:"domain"`; Type string `json:"type"` } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { problem(w, 400, "请求格式无效"); return } + domain, err := normalizeDomain(body.Domain); kind := strings.ToUpper(strings.TrimSpace(body.Type)) + if err != nil { problem(w, 400, err.Error()); return }; if !allowedTypes[kind] { problem(w, 400, "不支持的记录类型"); return } + q := &query{ID: newID(), Domain: domain, Type: kind, Status: "running", CreatedAt: time.Now().UTC(), Subscribers: make(map[chan result]struct{})} + s.mu.Lock(); s.queries[q.ID] = q; s.mu.Unlock(); go s.run(q) + writeJSON(w, 202, q) +} + +func (s *store) get(w http.ResponseWriter, r *http.Request) { q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return }; q.mu.RLock(); defer q.mu.RUnlock(); writeJSON(w, 200, q) } + +func (s *store) events(w http.ResponseWriter, r *http.Request) { + q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return } + flusher, ok := w.(http.Flusher); if !ok { problem(w, 500, "不支持事件流"); return } + w.Header().Set("Content-Type", "text/event-stream"); w.Header().Set("Cache-Control", "no-cache"); w.Header().Set("X-Accel-Buffering", "no") + ch := make(chan result, len(nodes)); q.mu.Lock(); existing := append([]result(nil), q.Results...); done := q.Status == "completed"; if !done { q.Subscribers[ch] = struct{}{} }; q.mu.Unlock() + defer func() { q.mu.Lock(); delete(q.Subscribers, ch); q.mu.Unlock() }() + for _, item := range existing { sendEvent(w, "result", item) }; if done { sendEvent(w, "complete", map[string]any{"count": len(existing)}); flusher.Flush(); return }; flusher.Flush() + for { select { case item, open := <-ch: if !open { sendEvent(w, "complete", map[string]any{"count": len(nodes)}); flusher.Flush(); return }; sendEvent(w, "result", item); flusher.Flush(); case <-r.Context().Done(): return } } +} + +func (s *store) run(q *query) { var wg sync.WaitGroup; for _, n := range nodes { wg.Add(1); go func() { defer wg.Done(); item := resolve(q.Domain, q.Type, n); q.mu.Lock(); q.Results = append(q.Results, item); for ch := range q.Subscribers { ch <- item }; q.mu.Unlock() }() }; wg.Wait(); q.mu.Lock(); q.Status = "completed"; for ch := range q.Subscribers { close(ch); delete(q.Subscribers, ch) }; q.mu.Unlock() } + +func resolve(domain, kind string, n resolver) result { + view := resolverView{n.Name, n.Region, n.Country, n.City}; started := time.Now(); ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second); defer cancel() + u, _ := url.Parse(n.URL); params := u.Query(); params.Set("name", domain); params.Set("type", kind); u.RawQuery = params.Encode() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil); req.Header.Set("Accept", "application/dns-json"); resp, err := http.DefaultClient.Do(req); elapsed := time.Since(started).Milliseconds() + if err != nil { status := "failed"; if errors.Is(ctx.Err(), context.DeadlineExceeded) { status = "timeout" }; return result{Node: view, Status: status, ResponseTime: elapsed, Error: "解析器请求失败"} }; defer resp.Body.Close() + if resp.StatusCode != 200 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)} } + var payload struct { Status int `json:"Status"`; Answer []struct { Data string `json:"data"`; TTL uint32 `json:"TTL"` } `json:"Answer"` } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload) != nil || payload.Status != 0 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("DNS 状态 %d", payload.Status)} } + if len(payload.Answer) == 0 { return result{Node: view, Status: "no_record", ResponseTime: elapsed, Records: []record{}} } + records := make([]record, 0, len(payload.Answer)); for _, a := range payload.Answer { records = append(records, record{strings.TrimSuffix(a.Data, "."), a.TTL}) }; sort.Slice(records, func(i,j int) bool{return records[i].Value253 || net.ParseIP(host)!=nil || host=="localhost" || !validLabels(host) { return "", errors.New("请输入合法的公网域名") }; return host,nil } +func validLabels(host string) bool { labels:=strings.Split(host,"."); if len(labels)<2{return false}; for _,l:=range labels { if len(l)<1||len(l)>63||l[0]=='-'||l[len(l)-1]=='-'{return false}; for _,c:=range l {if !(c>='a'&&c<='z'||c>='0'&&c<='9'||c=='-'){return false}} }; return true } +func (s *store) find(id string)*query{s.mu.RLock();defer s.mu.RUnlock();return s.queries[id]} +func (s *store) cleanup(){ticker:=time.NewTicker(time.Minute);defer ticker.Stop();for range ticker.C{s.mu.Lock();for id,q:=range s.queries{if time.Since(q.CreatedAt)>5*time.Minute{delete(s.queries,id)}};s.mu.Unlock()}} +func sendEvent(w io.Writer,name string,data any){b,_:=json.Marshal(data);fmt.Fprintf(w,"event: %s\ndata: %s\n\n",name,b)} +func writeJSON(w http.ResponseWriter,status int,v any){w.Header().Set("Content-Type","application/json");w.WriteHeader(status);_ = json.NewEncoder(w).Encode(v)} +func problem(w http.ResponseWriter,status int,msg string){writeJSON(w,status,map[string]string{"error":msg})} +func newID()string{b:=make([]byte,6);_,_=rand.Read(b);return "dns_"+hex.EncodeToString(b)} +func env(k,d string)string{if v:=os.Getenv(k);v!=""{return v};return d} + +func security(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){w.Header().Set("X-Content-Type-Options","nosniff");w.Header().Set("X-Frame-Options","DENY");w.Header().Set("Referrer-Policy","same-origin");next.ServeHTTP(w,r)})} +var visitors sync.Map +func rateLimit(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){if r.Method=="POST" {ip,_,_:=net.SplitHostPort(r.RemoteAddr);now:=time.Now();raw,_:=visitors.LoadOrStore(ip,&struct{sync.Mutex;start time.Time;n int}{start:now});v:=raw.(*struct{sync.Mutex;start time.Time;n int});v.Lock();if now.Sub(v.start)>time.Minute{v.start=now;v.n=0};v.n++;blocked:=v.n>30;v.Unlock();if blocked{problem(w,429,"请求过于频繁,请稍后重试");return}};next.ServeHTTP(w,r)})} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..daa472b --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,5 @@ +package main + +import "testing" + +func TestNormalizeDomain(t *testing.T){tests:=[]struct{in,want string;ok bool}{{"https://WWW.Example.com/path","www.example.com",true},{"example.com/","example.com",true},{"localhost","",false},{"127.0.0.1","",false},{"bad_domain.com","",false}};for _,tt:=range tests{got,err:=normalizeDomain(tt.in);if (err==nil)!=tt.ok||got!=tt.want{t.Errorf("normalizeDomain(%q)=(%q,%v)",tt.in,got,err)}}} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fb35a2b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + dnsquery: + build: . + ports: + - "8080:8080" + restart: unless-stopped + init: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..e09f3b3 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +ADDR=:8081 /usr/local/bin/server & +api_pid=$! +/usr/sbin/nginx -g 'daemon off;' & +web_pid=$! + +shutdown() { + kill -TERM "$api_pid" 2>/dev/null || true + kill -QUIT "$web_pid" 2>/dev/null || true + wait "$api_pid" 2>/dev/null || true + wait "$web_pid" 2>/dev/null || true +} + +trap shutdown INT TERM EXIT +wait "$api_pid" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..64d33bd --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + location /api/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 30s; } + location = /healthz { proxy_pass http://127.0.0.1:8081; } + location / { try_files $uri $uri/ /index.html; } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2822268 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module dnsquery + +go 1.24 + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4ed3a44 --- /dev/null +++ b/web/index.html @@ -0,0 +1 @@ +DNS Global
diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..882bd05 --- /dev/null +++ b/web/package.json @@ -0,0 +1 @@ +{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest"},"devDependencies":{"@types/react":"latest","@types/react-dom":"latest"}} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..32c19a3 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,37 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; + +type RecordValue={value:string;ttl:number}; +type Result={node:{name:string;region:string;country:string;city:string};status:string;records:RecordValue[];responseTime:number;error?:string}; +const types=['A','AAAA','CNAME','MX','NS','TXT','SOA','CAA']; +const statusLabel:Record={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([]),[running,setRunning]=useState(false),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[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)}}; + 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 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
+
D
DNS Global全球 DNS 传播检查
+
LIVE DNS LOOKUP

让每一次解析
清晰可见

通过多个公共 DNS 解析器并行查询,实时对比域名记录是否一致。

+
setInput(e.target.value)} onKeyDown={e=>e.key==='Enter'&&query()} placeholder="输入域名,如 example.com" aria-label="域名"/>
{error&&

{error}

} +
快速尝试{['google.com','cloudflare.com','github.com'].map(x=>)}
+
+ {(queryId||running)&&
● {running?'实时查询中':'查询完成'}

{domain} {kind}

+
+
{[['all','全部'],['success','成功'],['no_record','无记录'],['failed','失败']].map(([v,l])=>)}
+
区域 / 解析器状态DNS 结果TTL响应时间
{filtered.map((r,i)=>
{r.node.name}{r.node.region} · {r.node.country}{statusLabel[r.status]||r.status}{r.records.length?r.records.map(x=>{x.value}):{r.error||'—'}}{r.records[0]?.ttl??'—'}{r.responseTime?`${r.responseTime} ms`:'—'}
)}{running&&Array.from({length:Math.max(0,4-results.length)},(_,i)=>
)}
+

首版节点代表不同公共 DNS 服务商的 Anycast 解析结果,不等同于真实地理探针。

+
} + {!queryId&&!running&&
01

多解析器并行

同时请求全球主流 DNS 服务,快速发现缓存差异。

02

实时返回

结果通过 SSE 逐条到达,无需等待所有节点完成。

03

隐私优先

无需注册,查询数据仅在内存中短暂保留。

} +
DNS Global · Lightweight propagation checker
+
+} +function Stat({label,value,tone=''}:{label:string,value:string,tone?:string}){return
{label}{value}
} +createRoot(document.getElementById('root')!).render(); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..daf90f9 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,3 @@ +@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}} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..8de38b5 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"],"references":[]}