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

เครื่องมือเครือข่ายครบในคำสั่งเดียว: 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 ip | IP สาธารณะของเรา + 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
เช็คพอร์ตเฉพาะเครื่องที่เป็นของเราหรือได้รับอนุญาต ความเร็วที่วัดได้ผ่าน Claude Code มี overhead จึงต่ำกว่าแอป speedtest เล็กน้อย
hooks/register.tsx 359 lines1/**
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 */
359hooks/grid.tsx 154 lines1/**
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, '&').replace(/</g, '<').replace(/>/g, '>')
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