SLOPSHOPPER

network-tool-th

เครื่องมือเครือข่าย: ping, traceroute, DNS, WHOIS, IP ของเรา, HTTP header, speed test, เช็คพอร์ต และ Wi-Fi

newpanecommandprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · network-tool-th
│ ┃ network-pulse-th ✕ › fix the failing auth test and add an audit log call │ ┃ /net pulse google.com │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /net │ ⎿ network-tool-th: 🌐 Network Tool TH (Mod 26) │ ⎿ network-tool-th: ======================================== │ ⎿ network-tool-th: /net ping <host> ping 4 ครั้ง │ ⎿ network-tool-th: /net trace <host> เส้นทางไปถึงปลายท │ ⎿ network-tool-th: /net dns <domain> [A|AAAA|MX|TXT|NS|CNAME|all] │ ⎿ network-tool-th: /net whois <domain> วันจด วันหมดอายุ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · network-pulse-th
/net pulse google.com
README

Mod 26: Network Tool TH

เครื่องมือเครือข่ายครบในคำสั่งเดียว: ping และ traceroute ด้วยคำสั่งของระบบ, DNS lookup ผ่าน DNS-over-HTTPS, WHOIS ผ่าน RDAP (วันจดทะเบียน วันหมดอายุ ผู้รับจด), IP สาธารณะพร้อม ISP, ตรวจ HTTP header และ security header, วัดความเร็วดาวน์โหลด, เช็คพอร์ต และดูสัญญาณ Wi-Fi ใช้ได้ทั้ง Windows, macOS และ Linux

คำสั่ง

คำสั่งทำอะไร
/net ping <host>ping 4 ครั้ง
/net trace <host>เส้นทางไปปลายทาง (สูงสุด 15 hop)
`/net dns <domain> [A\AAAA\MX\TXT\NS\CNAME\all]`ระเบียน DNS
/net whois <domain>วันจด วันหมดอายุ ผู้รับจด (รองรับ .co.th)
/net ipIP สาธารณะของเรา + ISP + จังหวัด
/net http <url>status เวลาโหลด และ security header
/net speedวัดความเร็วดาวน์โหลด
/net port <host> <พอร์ต,...>เช็คพอร์ต (สูงสุด 10)
/net wifiสัญญาณ Wi-Fi ที่ต่ออยู่
/net historyคำสั่งที่ใช้ไป
/net pulse <host> / pulse stopชีพจรเครือข่าย: ping ต่อเนื่อง กราฟ latency สดในแผง + min/avg/max/jitter/loss

ติดตั้ง

/plugin install network-tool-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. ping/traceroute ข้ามระบบ และตรวจชื่อโฮสต์ก่อนส่งให้คำสั่งระบบ
  2. DNS-over-HTTPS และ RDAP (JSON อ่านง่ายกว่า whois เดิม)
  3. อ่าน header และเช็ค security header
  4. วัดความเร็ว (โหลดหลายรอบเพราะ $.http.fetch รับได้ครั้งละ 4 MB), เช็คพอร์ตด้วย TcpClient/nc, อ่านสัญญาณ Wi-Fi
  5. คำสั่ง /net

หมายเหตุ

เช็คพอร์ตเฉพาะเครื่องที่เป็นของเราหรือได้รับอนุญาต ความเร็วที่วัดได้ผ่าน Claude Code มี overhead จึงต่ำกว่าแอป speedtest เล็กน้อย

Source 2 files
hooks/register.tsx 359 lines
1/**
2 * ============================================================================
3 * MOD 26: Network Tool TH - เครื่องมือเครือข่ายครบในคำสั่งเดียว (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: ping, traceroute, nslookup, whois และ speedtest ที่ Dev/IT ใช้ทุกวัน
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ping / traceroute ด้วยคำสั่งของระบบ (Windows, macOS, Linux)
9 * 2. DNS lookup ผ่าน DNS-over-HTTPS ของ Cloudflare (ได้ผลเหมือนกันทุกระบบ)
10 * 3. WHOIS ผ่าน RDAP (มาตรฐานใหม่แทน whois): วันจดทะเบียน วันหมดอายุ ผู้รับจด
11 * 4. IP สาธารณะของเรา + ISP/จังหวัด, ตรวจ HTTP header และ security header
12 * 5. วัดความเร็วดาวน์โหลด, เช็คพอร์ต (ทีละไม่เกิน 10 พอร์ต), ดูสัญญาณ Wi-Fi
13 * 6. /net pulse <host> - ชีพจรเครือข่าย: ping ต่อเนื่อง วาดกราฟ latency สดในแผง พร้อม min/avg/max/jitter/loss
14 *
15 * จรรยาบรรณ: เช็คพอร์ตเฉพาะเครื่องที่เป็นของเราหรือได้รับอนุญาตเท่านั้น
16 */
17
18import type { EngineInterface, Register } from 'claude-code'
19import { Grid, drawGrid, rgb } from './grid'
20
21let isWindows = false
22let isMac = false
23let history: { at: number; command: string }[] = []
24
25// ชื่อโฮสต์/IP ที่ยอมรับ (ตัวอักษร ตัวเลข จุด ขีด และ : ของ IPv6) กันการส่งค่าแปลกๆ ให้คำสั่งระบบ
26function validHost(h: string | undefined): h is string {
27  return Boolean(h) && /^[A-Za-z0-9.:-]{1,253}$/.test(h ?? '') && !(h ?? '').startsWith('-')
28}
29
30async function run($: EngineInterface, argv: string[], timeoutMs = 60_000, env?: Record<string, string>): Promise<string> {
31  const r = await $.process.run(argv, { timeoutMs, env })
32  return (r.stdout + (r.exitCode !== 0 && r.stderr ? `\n${r.stderr}` : '')).replace(/\r/g, '').trim()
33}
34
35async function json($: EngineInterface, url: string, headers: Record<string, string> = {}): Promise<Record<string, unknown>> {
36  const res = await $.http.fetch(url, { headers: { 'User-Agent': 'claude-code-network-tool-th', ...headers } })
37  if (!res.ok) throw new Error(`HTTP ${res.status}`)
38  return JSON.parse(res.text) as Record<string, unknown>
39}
40
41// ==========================================================================
42// บทที่ 0: ชีพจรเครือข่าย - ping ต่อเนื่องหนึ่งโปรเซส อ่านทีละบรรทัดผ่าน $.process.spawn
43// ==========================================================================
44const PULSE_PANE = 'network-pulse-th'
45let pulse: { host: string; ms: (number | null)[]; running: boolean } | undefined
46
47// "time=12ms" (Windows) / "time=12.3 ms" (macOS/Linux) / หมดเวลา = null (packet loss)
48function parsePing(line: string): number | null | undefined {
49  const t = /time[=<]([\d.]+)\s*ms/i.exec(line)
50  if (t) return Number(t[1])
51  if (/timed out|unreachable|100% packet loss|no answer/i.test(line)) return null
52  return undefined
53}
54
55function startPulse($: EngineInterface, host: string): void {
56  const state = { host, ms: [] as (number | null)[], running: true }
57  pulse = state
58  const argv = isWindows ? ['ping', '-t', host] : ['ping', host]
59  const proc = $.process.spawn({ argv })
60  let buf = ''
61  void (async () => {
62    for await (const chunk of proc) {
63      if (!state.running) break // ออกจากลูป = ปิด ping ให้เอง
64      buf += chunk.text
65      let i = buf.indexOf('\n')
66      while (i >= 0) {
67        const v = parsePing(buf.slice(0, i))
68        buf = buf.slice(i + 1)
69        if (v !== undefined) {
70          state.ms = [...state.ms, v].slice(-120)
71          $.ui.invalidate('ui.render')
72        }
73        i = buf.indexOf('\n')
74      }
75    }
76  })().catch(() => undefined)
77}
78
79function pulseStats(ms: (number | null)[]): { min: number; avg: number; max: number; jitter: number; loss: number } {
80  const ok = ms.filter((v): v is number => v !== null)
81  const avg = ok.length ? ok.reduce((s, v) => s + v, 0) / ok.length : 0
82  const jitter = ok.length > 1 ? ok.slice(1).reduce((s, v, i) => s + Math.abs(v - (ok[i] ?? v)), 0) / (ok.length - 1) : 0
83  return { min: ok.length ? Math.min(...ok) : 0, avg, max: ok.length ? Math.max(...ok) : 0, jitter, loss: ms.length ? ((ms.length - ok.length) / ms.length) * 100 : 0 }
84}
85
86// กราฟแท่ง: สูงตาม latency (สเกลตามค่าสูงสุด) สีเขียว < 50ms < เหลือง < 150ms < แดง, ขาด = แท่งม่วงเต็ม
87function pulseGrid(ms: (number | null)[], width: number): Grid {
88  const h = 10
89  const g = new Grid(width, h, rgb(12, 14, 22))
90  const vals = ms.slice(-width)
91  const top = Math.max(50, ...vals.filter((v): v is number => v !== null)) * 1.1
92  const levels = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
93  vals.forEach((v, i) => {
94    const x = width - vals.length + i
95    if (v === null) {
96      for (let y = 0; y < h; y++) g.set(x, y, '│', rgb(170, 90, 220))
97      return
98    }
99    const color = v < 50 ? rgb(90, 210, 110) : v < 150 ? rgb(250, 200, 60) : rgb(240, 80, 80)
100    const filled = (v / top) * h * 8
101    for (let row = 0; row < h; row++) {
102      const lv = Math.max(0, Math.min(8, Math.round(filled - (h - 1 - row) * 8)))
103      if (lv > 0) g.set(x, row, levels[lv] ?? '█', color)
104    }
105  })
106  g.text(0, 0, `${Math.round(top)}ms`, rgb(110, 110, 130))
107  return g
108}
109
110// ==========================================================================
111// บทที่ 1: ping และ traceroute - แต่ละระบบใช้ flag ไม่เหมือนกัน
112// ==========================================================================
113async function ping($: EngineInterface, host: string): Promise<string> {
114  const out = await run($, isWindows ? ['ping', '-n', '4', host] : ['ping', '-c', '4', host], 30_000)
115  // เอาเฉพาะบรรทัดสรุปท้ายๆ
116  const lines = out.split('\n').filter(Boolean)
117  return lines.slice(-4).join('\n')
118}
119
120async function trace($: EngineInterface, host: string): Promise<string> {
121  return run($, isWindows ? ['tracert', '-d', '-h', '15', '-w', '1000', host] : ['traceroute', '-n', '-m', '15', '-w', '1', host], 120_000)
122}
123
124// ==========================================================================
125// บทที่ 2: DNS ผ่าน HTTPS และ WHOIS ผ่าน RDAP (ข้อมูลเป็น JSON อ่านง่าย)
126// ==========================================================================
127const DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME']
128
129async function dns($: EngineInterface, name: string, type: string): Promise<string> {
130  const body = await json($, `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, { Accept: 'application/dns-json' })
131  const answers = (body.Answer as { data: string; TTL: number }[] | undefined) ?? []
132  if (body.Status === 3) return `❌ ไม่มีโดเมน ${name} (NXDOMAIN)`
133  return answers.length ? answers.map(a => `  ${type.padEnd(5)} ${a.data}  (TTL ${a.TTL}s)`).join('\n') : `  (ไม่มีระเบียน ${type})`
134}
135
136async function whois($: EngineInterface, domain: string): Promise<string> {
137  // rdap.org ส่งต่อ (redirect) ไปเซิร์ฟเวอร์ของแต่ละ TLD; ถ้า fetch ไม่ตามให้ ตามเองจาก header location
138  let res = await $.http.fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`)
139  const location = res.headers.location ?? res.headers.Location
140  if (res.status >= 300 && res.status < 400 && location) res = await $.http.fetch(location)
141  if (res.status === 404) return `❌ ไม่พบข้อมูล ${domain} (อาจยังไม่มีคนจด หรือ TLD นี้ไม่รองรับ RDAP)`
142  if (!res.ok) throw new Error(`HTTP ${res.status}`)
143  const d = JSON.parse(res.text) as {
144    events?: { eventAction: string; eventDate: string }[]
145    entities?: { roles?: string[]; vcardArray?: [string, [string, unknown, string, string][]] }[]
146    nameservers?: { ldhName: string }[]
147    status?: string[]
148  }
149  const event = (a: string) => d.events?.find(e => e.eventAction === a)?.eventDate?.slice(0, 10) ?? '-'
150  const registrar = d.entities?.find(e => e.roles?.includes('registrar'))?.vcardArray?.[1]?.find(v => v[0] === 'fn')?.[3] ?? '-'
151  const expires = d.events?.find(e => e.eventAction === 'expiration')?.eventDate
152  const days = expires ? Math.round((new Date(expires).getTime() - (await $.clock.now())) / 86_400_000) : undefined
153  return [
154    `🌐 ${domain.toLowerCase()}`,
155    `  จดทะเบียน   ${event('registration')}`,
156    `  หมดอายุ     ${event('expiration')}${days !== undefined ? ` (${days < 0 ? 'หมดแล้ว' : `อีก ${days} วัน`}${days >= 0 && days < 30 ? ' ⚠️ ใกล้หมด' : ''})` : ''}`,
157    `  แก้ไขล่าสุด ${event('last changed')}`,
158    `  ผู้รับจด     ${registrar}`,
159    `  Name server ${d.nameservers?.map(n => n.ldhName.toLowerCase()).join(', ') || '-'}`,
160  ].join('\n')
161}
162
163// ==========================================================================
164// บทที่ 3: IP ของเรา, HTTP header และ security header
165// ==========================================================================
166async function myIp($: EngineInterface): Promise<string> {
167  const v4 = (await json($, 'https://api.ipify.org?format=json').catch(() => ({ ip: '-' }))) as { ip?: string }
168  const geo = (await json($, 'https://ipwho.is/').catch(() => ({}))) as { ip?: string; country?: string; region?: string; city?: string; connection?: { isp?: string } }
169  return [`🌍 IPv4: ${v4.ip ?? '-'}`, geo.ip && geo.ip !== v4.ip ? `   IP ที่ออกเน็ต: ${geo.ip}` : '', `   ${geo.city ?? ''} ${geo.region ?? ''} ${geo.country ?? ''}`.trim(), geo.connection?.isp ? `   ISP: ${geo.connection.isp}` : ''].filter(Boolean).join('\n')
170}
171
172const SECURITY_HEADERS: [string, string][] = [
173  ['strict-transport-security', 'บังคับ HTTPS (HSTS)'],
174  ['content-security-policy', 'กัน XSS (CSP)'],
175  ['x-frame-options', 'กันเว็บอื่นฝังหน้าเรา (clickjacking)'],
176  ['x-content-type-options', 'กัน browser เดาชนิดไฟล์'],
177  ['referrer-policy', 'คุมข้อมูล referrer'],
178]
179
180async function httpCheck($: EngineInterface, input: string): Promise<string> {
181  const url = /^https?:\/\//i.test(input) ? input : `https://${input}`
182  const start = await $.clock.now()
183  const res = await $.http.fetch(url, { headers: { 'User-Agent': 'claude-code-network-tool-th' } })
184  const ms = (await $.clock.now()) - start
185  const h = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k.toLowerCase(), v]))
186  const sec = SECURITY_HEADERS.map(([k, label]) => `  ${h[k] ? '✅' : '⚠️'} ${label}`)
187  return [
188    `📨 ${url}  HTTP ${res.status}  ${ms} ms  ${(res.text.length / 1024).toFixed(0)} KB`,
189    `  server: ${h.server ?? '-'} | content-type: ${h['content-type'] ?? '-'} | cache-control: ${h['cache-control'] ?? '-'}`,
190    'Security headers:',
191    ...sec,
192  ].join('\n')
193}
194
195// ==========================================================================
196// บทที่ 4: วัดความเร็ว, เช็คพอร์ต, Wi-Fi
197// ==========================================================================
198async function speed($: EngineInterface): Promise<string> {
199  // latency: ขอไฟล์ 0 ไบต์ 3 ครั้ง เอาค่าน้อยสุด
200  const pings: number[] = []
201  for (let i = 0; i < 3; i++) {
202    const t = await $.clock.now()
203    await $.http.fetch('https://speed.cloudflare.com/__down?bytes=0')
204    pings.push((await $.clock.now()) - t)
205  }
206  // $.http.fetch รับได้ครั้งละไม่เกิน 4 MB จึงโหลด 3 MB จำนวน 3 รอบ
207  let received = 0
208  const t = await $.clock.now()
209  for (let i = 0; i < 3; i++) received += (await $.http.fetch('https://speed.cloudflare.com/__down?bytes=3000000')).text.length
210  const seconds = ((await $.clock.now()) - t) / 1000
211  const mbps = (received * 8) / seconds / 1_000_000
212  return `⚡ ดาวน์โหลด ~${mbps.toFixed(1)} Mbps (${(received / 1_000_000).toFixed(0)} MB ใน ${seconds.toFixed(1)} วิ) | latency ${Math.min(...pings)} ms\n   (วัดผ่าน Claude Code มี overhead ค่าจริงอาจสูงกว่านี้เล็กน้อย)`
213}
214
215async function portCheck($: EngineInterface, host: string, ports: number[]): Promise<string> {
216  const rows: string[] = []
217  for (const port of ports) {
218    let open = false
219    if (isWindows) {
220      const out = await run($, ['powershell', '-NoProfile', '-NonInteractive', '-Command', '$c = New-Object Net.Sockets.TcpClient; try { $ok = $c.ConnectAsync($env:NT_HOST, [int]$env:NT_PORT).Wait(3000); if ($ok -and $c.Connected) { "open" } else { "closed" } } catch { "closed" } finally { $c.Close() }'], 15_000, { NT_HOST: host, NT_PORT: String(port) })
221      open = out.trim() === 'open'
222    } else {
223      const r = await $.process.run(['nc', '-z', '-w', '3', host, String(port)], { timeoutMs: 10_000 }).catch(() => undefined)
224      open = r?.exitCode === 0
225    }
226    rows.push(`  ${open ? '🟢 เปิด' : '⚪ ปิด'}  ${host}:${port}`)
227  }
228  return [`🔌 เช็คพอร์ต ${host}`, ...rows].join('\n')
229}
230
231async function wifi($: EngineInterface): Promise<string> {
232  if (isWindows) {
233    const out = await run($, ['netsh', 'wlan', 'show', 'interfaces'])
234    const pick = (k: string) => new RegExp(`^\\s*${k}\\s*:\\s*(.+)$`, 'mi').exec(out)?.[1]?.trim()
235    const ssid = pick('SSID')
236    if (!ssid) return '📶 ไม่ได้ต่อ Wi-Fi (หรือเครื่องไม่มี Wi-Fi)'
237    return `📶 ${ssid} | สัญญาณ ${pick('Signal') ?? '-'} | ${pick('Radio type') ?? ''} | รับ ${pick('Receive rate \\(Mbps\\)') ?? '-'} Mbps`
238  }
239  if (isMac) return run($, ['networksetup', '-getairportnetwork', 'en0'])
240  const out = await run($, ['nmcli', '-t', '-f', 'active,ssid,signal', 'dev', 'wifi'])
241  const active = out.split('\n').find(l => l.startsWith('yes:'))
242  return active ? `📶 ${active.split(':')[1]} | สัญญาณ ${active.split(':')[2]}%` : '📶 ไม่ได้ต่อ Wi-Fi'
243}
244
245const HELP = `🌐 Network Tool TH (Mod 26)
246========================================
247/net ping <host>              ping 4 ครั้ง
248/net trace <host>             เส้นทางไปถึงปลายทาง (สูงสุด 15 hop)
249/net dns <domain> [A|AAAA|MX|TXT|NS|CNAME|all]
250/net whois <domain>           วันจด วันหมดอายุ ผู้รับจด (RDAP)
251/net ip                       IP สาธารณะของเรา + ISP
252/net http <url>               status เวลาโหลด และ security header
253/net speed                    วัดความเร็วดาวน์โหลด
254/net port <host> <พอร์ต,...>   เช็คพอร์ต (สูงสุด 10) เช่น /net port localhost 3000,5432
255/net wifi                     สัญญาณ Wi-Fi ที่ต่ออยู่
256/net history
257/net pulse <host> | pulse stop   กราฟ latency สด (ping ต่อเนื่อง)
258⚠️ เช็คพอร์ตเฉพาะเครื่องที่เป็นของเราหรือได้รับอนุญาต`
259
260export const register: Register = on => {
261  on('session.start', async ($, e, next) => {
262    isWindows = /^[A-Za-z]:/.test(e.cwd)
263    isMac = !isWindows && e.cwd.startsWith('/Users/')
264    history = []
265    await $.command.register({ name: 'net', description: 'เครื่องมือเครือข่าย: ping dns whois ip speed port (/net help)', argumentHint: 'ping|trace|dns|whois|ip|http|speed|port|wifi' })
266    return next(e)
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PULSE_PANE }, async ($, e) => {
270    const els = $.ui.resolve(e)
271    const { Box, Text } = els
272    const p = pulse
273    if (!p) return <Text dimColor>/net pulse google.com</Text>
274    const s = pulseStats(p.ms)
275    const last = p.ms.at(-1)
276    return (
277      <Box flexDirection="column">
278        <Text bold>{`📈 ${p.host} · ล่าสุด ${last === undefined ? '-' : last === null ? 'หมดเวลา' : `${last} ms`}`}</Text>
279        {drawGrid(els, e.surface, pulseGrid(p.ms, Math.max(20, Math.min(70, e.props.bodyColumns))), 'pulse', `latency ของ ${p.host}`) as never}
280        <Text dimColor>{`min ${s.min.toFixed(0)} · avg ${s.avg.toFixed(0)} · max ${s.max.toFixed(0)} ms · jitter ${s.jitter.toFixed(1)} · loss ${s.loss.toFixed(0)}% (${p.ms.length} ครั้ง)`}</Text>
281      </Box>
282    )
283  })
284
285  on('ui.close', async ($, e, next) => {
286    if (e.id === PULSE_PANE && pulse) pulse.running = false
287    return next(e)
288  })
289
290  // ==========================================================================
291  // บทที่ 5: คำสั่ง /net
292  // ==========================================================================
293  on('command.run', { command: 'net' }, async ($, e) => {
294    const [sub = 'help', a1, a2] = e.args.trim().split(/\s+/).filter(Boolean)
295    history = [...history, { at: await $.clock.now(), command: e.args.trim() }].slice(-30)
296    try {
297      switch (sub) {
298        case 'ping':
299          return { text: validHost(a1) ? `📡 ping ${a1}\n${await ping($, a1)}` : 'วิธีใช้: /net ping google.com' }
300        case 'trace':
301          return { text: validHost(a1) ? `🗺️ traceroute ${a1}\n${await trace($, a1)}` : 'วิธีใช้: /net trace google.com' }
302        case 'dns': {
303          if (!validHost(a1)) return { text: 'วิธีใช้: /net dns example.com [A|MX|TXT|all]' }
304          const types = !a2 || a2.toLowerCase() === 'all' ? DNS_TYPES : [a2.toUpperCase()].filter(t => DNS_TYPES.includes(t))
305          if (types.length === 0) return { text: `ประเภทที่รองรับ: ${DNS_TYPES.join(', ')}` }
306          const parts = await Promise.all(types.map(t => dns($, a1, t)))
307          const missing = parts.find(p => p.startsWith('❌'))
308          if (missing) return { text: missing } // ไม่มีโดเมนนี้ บอกครั้งเดียวพอ
309          return { text: [`🔎 DNS ${a1}`, ...parts.filter(p => !p.includes('(ไม่มีระเบียน') || types.length === 1)].join('\n') }
310        }
311        case 'whois':
312          return { text: validHost(a1) && a1.includes('.') ? await whois($, a1) : 'วิธีใช้: /net whois example.com' }
313        case 'ip':
314          return { text: await myIp($) }
315        case 'http':
316          return { text: a1 ? await httpCheck($, a1) : 'วิธีใช้: /net http https://example.com' }
317        case 'speed':
318          return { text: await speed($) }
319        case 'port': {
320          const ports = (a2 ?? '').split(',').map(Number).filter(p => Number.isInteger(p) && p > 0 && p < 65536)
321          if (!validHost(a1) || ports.length === 0) return { text: 'วิธีใช้: /net port localhost 3000,5432' }
322          if (ports.length > 10) return { text: '❌ เช็คได้ครั้งละไม่เกิน 10 พอร์ต' }
323          return { text: await portCheck($, a1, ports) }
324        }
325        case 'wifi':
326          return { text: await wifi($) }
327        case 'pulse': {
328          if (a1 === 'stop') {
329            if (pulse) pulse.running = false
330            await $.ui.close({ id: PULSE_PANE }).catch(() => undefined)
331            const s = pulse ? pulseStats(pulse.ms) : undefined
332            return { text: s ? `⏹️ หยุด ping ${pulse?.host} · avg ${s.avg.toFixed(0)} ms · loss ${s.loss.toFixed(0)}%` : 'ไม่ได้ ping อยู่' }
333          }
334          if (!validHost(a1)) return { text: 'วิธีใช้: /net pulse google.com  (หยุด: /net pulse stop)' }
335          if (pulse) pulse.running = false
336          startPulse($, a1)
337          await $.ui.open({ id: PULSE_PANE, title: `📈 ${a1}` })
338          return { text: `📈 เริ่ม ping ${a1} ต่อเนื่อง กราฟอยู่ในแผง (/net pulse stop เพื่อหยุด)` }
339        }
340        case 'history':
341          return { text: history.map(h => `  ${new Date(h.at).toLocaleTimeString('th-TH')}  /net ${h.command}`).join('\n') || 'ยังไม่มีประวัติ' }
342        default:
343          return { text: HELP }
344      }
345    } catch (err) {
346      return { text: `❌ ${sub} ไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
347    }
348  })
349}
350
351/**
352 * ========================================================================
353 * การบ้าน / ต่อยอด
354 * ========================================================================
355 * 1. เฝ้าดูว่าเน็ตหลุด: ping ทุก 1 นาทีด้วย $.clock.every แล้วเตือนเมื่อหาย (ต่อกับ Mod 22)
356 * 2. เตือนโดเมนใกล้หมดอายุ: เก็บรายชื่อโดเมนใน $.store แล้วเช็ค whois ทุกวัน
357 * 3. ตรวจใบรับรอง SSL: วันหมดอายุของ certificate (ต้องใช้ openssl หรือ API ภายนอก)
358 */
359
hooks/grid.tsx 154 lines
1/**
2 * grid.tsx - ตารางตัวอักษรมีสี วาดได้ทุกหน้าจอ
3 * terminal: ใช้ Raster (ช่องสีจริง เร็ว) / desktop, vscode, mobile: แปลงเป็น SVG
4 * ไฟล์นี้ไม่รับ $ (ตัวตรวจของ engine ไม่ให้ส่ง $ ข้ามไฟล์) รับแค่ element ที่ resolve มาแล้ว
5 */
6
7export const DEFAULT = 0x01000000 // สีเริ่มต้นของ terminal
8
9export class Grid {
10  readonly w: number
11  readonly h: number
12  readonly ch: string[]
13  readonly fg: number[]
14  readonly bg: number[]
15
16  constructor(w: number, h: number, bg = DEFAULT) {
17    this.w = w
18    this.h = h
19    this.ch = new Array<string>(w * h).fill(' ')
20    this.fg = new Array<number>(w * h).fill(DEFAULT)
21    this.bg = new Array<number>(w * h).fill(bg)
22  }
23
24  set(x: number, y: number, ch: string, fg = DEFAULT, bg?: number): void {
25    const xi = Math.round(x)
26    const yi = Math.round(y)
27    if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
28    const i = yi * this.w + xi
29    this.ch[i] = ch
30    this.fg[i] = fg
31    if (bg !== undefined) this.bg[i] = bg
32  }
33
34  paint(x: number, y: number, bg: number): void {
35    const xi = Math.round(x)
36    const yi = Math.round(y)
37    if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
38    this.bg[yi * this.w + xi] = bg
39  }
40
41  text(x: number, y: number, s: string, fg = DEFAULT, bg?: number): void {
42    ;[...s].forEach((c, i) => this.set(x + i, y, c, fg, bg))
43  }
44}
45
46// สี RGB เป็นตัวเลข 0xRRGGBB
47export function rgb(r: number, g: number, b: number): number {
48  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
49  return (c(r) << 16) | (c(g) << 8) | c(b)
50}
51
52export function hsl(h: number, s: number, l: number): number {
53  const a = s * Math.min(l, 1 - l)
54  const f = (n: number) => {
55    const k = (n + h / 30) % 12
56    return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
57  }
58  return rgb(f(0) * 255, f(8) * 255, f(4) * 255)
59}
60
61// ==========================================================================
62// Raster ต้องการ base64 ของ u32 little-endian [codePoint, fg, bg] ต่อช่อง
63// ==========================================================================
64const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66function base64(bytes: Uint8Array): string {
67  let out = ''
68  for (let i = 0; i < bytes.length; i += 3) {
69    const a = bytes[i] ?? 0
70    const b = bytes[i + 1] ?? 0
71    const c = bytes[i + 2] ?? 0
72    out += B64[a >> 2] ?? ''
73    out += B64[((a & 3) << 4) | (b >> 4)] ?? ''
74    out += i + 1 < bytes.length ? (B64[((b & 15) << 2) | (c >> 6)] ?? '') : '='
75    out += i + 2 < bytes.length ? (B64[c & 63] ?? '') : '='
76  }
77  return out
78}
79
80export function rasterCells(g: Grid): string {
81  const bytes = new Uint8Array(g.w * g.h * 12)
82  const view = new DataView(bytes.buffer)
83  for (let i = 0; i < g.w * g.h; i++) {
84    view.setUint32(i * 12, (g.ch[i] ?? ' ').codePointAt(0) ?? 32, true)
85    view.setUint32(i * 12 + 4, g.fg[i] ?? DEFAULT, true)
86    view.setUint32(i * 12 + 8, g.bg[i] ?? DEFAULT, true)
87  }
88  return base64(bytes)
89}
90
91// ==========================================================================
92// SVG: รวมช่องที่สีเดียวกันติดกันเป็นก้อนเดียว ให้ไฟล์เล็ก (จำกัด 131,072 ตัวอักษร)
93// ==========================================================================
94const CW = 8.4
95const CH = 16
96
97function hex(c: number, fallback: string): string {
98  return c === DEFAULT ? fallback : `#${c.toString(16).padStart(6, '0')}`
99}
100
101function esc(s: string): string {
102  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
103}
104
105export function svgOf(g: Grid, background = '#0b0e14', foreground = '#d8dee9'): string {
106  const parts: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(g.w * CW)}" height="${g.h * CH}" viewBox="0 0 ${g.w * CW} ${g.h * CH}"><rect width="100%" height="100%" fill="${background}"/>`]
107  for (let y = 0; y < g.h; y++) {
108    // พื้นหลัง
109    let x = 0
110    while (x < g.w) {
111      const c = g.bg[y * g.w + x] ?? DEFAULT
112      let end = x + 1
113      while (end < g.w && g.bg[y * g.w + end] === c) end++
114      if (c !== DEFAULT) parts.push(`<rect x="${(x * CW).toFixed(1)}" y="${y * CH}" width="${((end - x) * CW).toFixed(1)}" height="${CH}" fill="${hex(c, background)}"/>`)
115      x = end
116    }
117    // ตัวอักษร
118    x = 0
119    while (x < g.w) {
120      const i = y * g.w + x
121      if ((g.ch[i] ?? ' ') === ' ') {
122        x++
123        continue
124      }
125      const c = g.fg[i] ?? DEFAULT
126      let end = x + 1
127      while (end < g.w && g.fg[y * g.w + end] === c && (g.ch[y * g.w + end] ?? ' ') !== ' ') end++
128      const run = g.ch.slice(i, y * g.w + end).join('')
129      parts.push(`<text x="${(x * CW).toFixed(1)}" y="${y * CH + 12}" fill="${hex(c, foreground)}" textLength="${((end - x) * CW).toFixed(1)}" lengthAdjust="spacingAndGlyphs">${esc(run)}</text>`)
130      x = end
131    }
132  }
133  parts.push('</svg>')
134  const svg = parts.join('')
135  return svg.replace('<svg ', '<svg font-family="Consolas, Menlo, monospace" font-size="14" xml:space="preserve" ')
136}
137
138// ==========================================================================
139// วาดตามหน้าจอ: els = $.ui.resolve(e), surface = e.surface
140// ==========================================================================
141// eslint-disable-next-line @typescript-eslint/no-explicit-any
142export function drawGrid(els: any, surface: string, g: Grid, key: string, alt: string): unknown {
143  if (surface === 'terminal' && els.Raster) {
144    const Raster = els.Raster
145    return <Raster key={key} columns={g.w} rows={g.h} cells={rasterCells(g)} />
146  }
147  if (els.Svg) {
148    const Svg = els.Svg
149    return <Svg source={svgOf(g)} alt={alt} />
150  }
151  const Text = els.Text
152  return <Text>{alt}</Text>
153}
154