ทำหน้า Status Page: เช็ค service จริงทาง HTTP, เก็บ uptime, บันทึก incident และส่งออก HTML หลายธีม

ทำหน้าสถานะระบบแบบ status.example.com: ลงทะเบียนเว็บหรือ API แล้วเช็คจริงทาง HTTP เก็บประวัติและ uptime % ตั้งเช็คอัตโนมัติได้ (เด้งเตือนเมื่อสถานะเปลี่ยน) บันทึกและปิดเหตุขัดข้อง (incident) และส่งออกเป็นไฟล์ HTML หน้าเดียว 5 ธีมไปขึ้นเว็บได้เลย
| คำสั่ง | ทำอะไร |
|---|---|
/statuspage | สถานะล่าสุด + เปิดแผง |
/statuspage add <ชื่อ> <url> | เพิ่ม service (เช็คทันที) |
/statuspage check | เช็คทุก service ตอนนี้ |
/statuspage monitor on [นาที] / off | เช็คอัตโนมัติ (ค่าเริ่มต้นทุก 5 นาที) |
/statuspage incident <ข้อความ> [--service ชื่อ] [--major] | เปิด incident |
/statuspage resolve <id> | ปิด incident |
/statuspage export [ธีม] | สร้าง status.html (light, dark, thai, minimal, ocean) |
/statuspage reset confirm | ล้างข้อมูลทั้งหมด |
/plugin install status-page-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ใช้ชื่อ /statuspage เพราะ /status เป็นคำสั่งเดิมของ Claude Code การเช็คอัตโนมัติทำงานเฉพาะตอนเปิด Claude Code อยู่ ถ้าต้องการเฝ้าตลอด 24 ชม. ให้ใช้บริการ uptime monitor หรือ GitHub Actions
hooks/register.tsx 331 lines1/**
2 * ============================================================================
3 * MOD 21: Status Page TH - หน้าสถานะระบบแบบ status.example.com (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: Atlassian Statuspage, Better Stack, Upptime
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ลงทะเบียน service (เว็บ/API) แล้วเช็คจริงทาง HTTP: ขึ้นไหม, ตอบกี่ ms
9 * 2. เก็บประวัติ คำนวณ uptime %, เช็คอัตโนมัติเป็นระยะ และเด้งเตือนเมื่อสถานะเปลี่ยน
10 * 3. บันทึก incident (เหตุขัดข้อง) และปิด incident
11 * 4. ส่งออกเป็นไฟล์ HTML หน้าเดียว 5 ธีม เอาไปขึ้นเว็บ (GitHub Pages, Netlify) ได้เลย
12 * 5. แผงด้านข้างแสดงสถานะสด
13 *
14 * หมายเหตุ: ใช้ชื่อคำสั่ง /statuspage เพราะ /status เป็นคำสั่งเดิมของ Claude Code
15 */
16
17import type { EngineInterface, Register, Timer } from 'claude-code'
18
19interface Service {
20 name: string
21 url: string
22}
23interface Probe {
24 at: number
25 up: boolean
26 status: number // 0 = เชื่อมต่อไม่ได้
27 ms: number
28}
29interface Incident {
30 id: number
31 title: string
32 service?: string
33 level: 'minor' | 'major'
34 startedAt: number
35 resolvedAt?: number
36}
37interface Data {
38 services: Service[]
39 history: Record<string, Probe[]>
40 incidents: Incident[]
41 nextIncident: number
42 title: string
43}
44
45const PANE = 'status-page-th'
46const MAX_HISTORY = 500
47let data: Data = { services: [], history: {}, incidents: [], nextIncident: 0, title: 'สถานะระบบ' }
48let monitor: Timer | undefined
49
50async function save($: EngineInterface): Promise<void> {
51 await $.store.set('statuspage', data)
52}
53
54// ==========================================================================
55// บทที่ 1: เช็ค service หนึ่งตัว - ถือว่า "ขึ้น" เมื่อตอบ 2xx/3xx ภายใน 10 วินาที
56// ==========================================================================
57async function probe($: EngineInterface, s: Service): Promise<Probe> {
58 const start = await $.clock.now()
59 try {
60 const res = await Promise.race([
61 $.http.fetch(s.url, { headers: { 'User-Agent': 'claude-code-status-page-th' } }),
62 $.clock.sleep(10_000).then(() => undefined),
63 ])
64 const ms = (await $.clock.now()) - start
65 return res ? { at: start, up: res.status < 400, status: res.status, ms } : { at: start, up: false, status: 0, ms }
66 } catch {
67 return { at: start, up: false, status: 0, ms: (await $.clock.now()) - start }
68 }
69}
70
71// เช็คทุก service พร้อมกัน แล้วบอกตัวที่สถานะเปลี่ยน (ขึ้น->ล่ม หรือ ล่ม->ขึ้น)
72async function checkAll($: EngineInterface): Promise<string[]> {
73 const changes: string[] = []
74 await Promise.all(
75 data.services.map(async s => {
76 const p = await probe($, s)
77 const list = data.history[s.name] ?? []
78 const before = list.at(-1)
79 data.history[s.name] = [...list, p].slice(-MAX_HISTORY)
80 if (before && before.up !== p.up) changes.push(p.up ? `✅ ${s.name} กลับมาใช้ได้แล้ว` : `🔴 ${s.name} ล่ม (HTTP ${p.status || 'ไม่ตอบ'})`)
81 }),
82 )
83 await save($)
84 $.ui.invalidate('ui.render')
85 return changes
86}
87
88// ==========================================================================
89// บทที่ 2: คำนวณ uptime และสถานะรวม
90// ==========================================================================
91function uptime(name: string): number | undefined {
92 const list = data.history[name] ?? []
93 return list.length ? (list.filter(p => p.up).length / list.length) * 100 : undefined
94}
95
96function overall(): { label: string; color: string } {
97 const last = data.services.map(s => data.history[s.name]?.at(-1))
98 const openMajor = data.incidents.some(i => !i.resolvedAt && i.level === 'major')
99 if (last.some(p => p && !p.up) || openMajor) return { label: 'ระบบมีปัญหาบางส่วน', color: '#d32f2f' }
100 if (data.incidents.some(i => !i.resolvedAt)) return { label: 'กำลังตรวจสอบเหตุขัดข้อง', color: '#f9a825' }
101 return { label: 'ทุกระบบทำงานปกติ', color: '#2e7d32' }
102}
103
104function statusText(): string {
105 if (data.services.length === 0) return 'ยังไม่มี service เพิ่มด้วย /statuspage add <ชื่อ> <url>'
106 const o = overall()
107 const rows = data.services.map(s => {
108 const p = data.history[s.name]?.at(-1)
109 const up = uptime(s.name)
110 return `${p ? (p.up ? '🟢' : '🔴') : '⚪'} ${s.name.padEnd(16)} ${p ? `${p.status || 'ไม่ตอบ'} ${p.ms}ms` : 'ยังไม่เช็ค'}${up !== undefined ? ` uptime ${up.toFixed(2)}%` : ''}`
111 })
112 const open = data.incidents.filter(i => !i.resolvedAt)
113 return [`📡 ${data.title}: ${o.label}`, '----------------------------------------', ...rows, ...(open.length ? ['', '⚠️ เหตุขัดข้องที่ยังไม่ปิด:', ...open.map(i => ` #${i.id} ${i.title}${i.service ? ` (${i.service})` : ''}`)] : [])].join('\n')
114}
115
116// ==========================================================================
117// บทที่ 3: สร้างหน้า HTML หน้าเดียว (escape ทุกข้อความที่มาจากผู้ใช้)
118// ==========================================================================
119const THEMES: Record<string, { bg: string; card: string; text: string; muted: string; up: string; down: string; font: string }> = {
120 light: { bg: '#f6f8fa', card: '#ffffff', text: '#1f2328', muted: '#656d76', up: '#2da44e', down: '#cf222e', font: 'system-ui, sans-serif' },
121 dark: { bg: '#0d1117', card: '#161b22', text: '#e6edf3', muted: '#8b949e', up: '#3fb950', down: '#f85149', font: 'system-ui, sans-serif' },
122 thai: { bg: '#fdf6e3', card: '#fffaf0', text: '#3e2723', muted: '#8d6e63', up: '#2e7d32', down: '#b71c1c', font: '"Sarabun", "Noto Sans Thai", sans-serif' },
123 minimal: { bg: '#ffffff', card: '#ffffff', text: '#111111', muted: '#888888', up: '#111111', down: '#e00000', font: 'Georgia, serif' },
124 ocean: { bg: '#e3f2fd', card: '#ffffff', text: '#0d47a1', muted: '#5472a1', up: '#00897b', down: '#d84315', font: 'system-ui, sans-serif' },
125}
126
127function esc(s: string): string {
128 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
129}
130
131function buildHtml(theme: string, now: number): string {
132 const t = THEMES[theme] ?? THEMES.light!
133 const o = overall()
134 const services = data.services
135 .map(s => {
136 const list = (data.history[s.name] ?? []).slice(-60)
137 const bars = list.map(p => `<i title="${new Date(p.at).toLocaleString('th-TH')} · ${p.status || 'ไม่ตอบ'} · ${p.ms}ms" style="background:${p.up ? t.up : t.down}"></i>`).join('')
138 const up = uptime(s.name)
139 const last = list.at(-1)
140 return `<section class="card"><div class="row"><b>${esc(s.name)}</b><span style="color:${last?.up === false ? t.down : t.up}">${last ? (last.up ? 'ปกติ' : 'ขัดข้อง') : '-'}</span></div><div class="bars">${bars}</div><div class="muted">uptime ${up !== undefined ? up.toFixed(2) : '-'}% · ${list.length} ครั้งล่าสุด</div></section>`
141 })
142 .join('\n')
143 const incidents = [...data.incidents]
144 .reverse()
145 .slice(0, 10)
146 .map(i => `<li><b>${esc(i.title)}</b>${i.service ? ` · ${esc(i.service)}` : ''}<div class="muted">${new Date(i.startedAt).toLocaleString('th-TH')}${i.resolvedAt ? ` → แก้แล้ว ${new Date(i.resolvedAt).toLocaleString('th-TH')}` : ' · ยังไม่ปิด'}</div></li>`)
147 .join('')
148 return `<!doctype html>
149<html lang="th"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
150<title>${esc(data.title)}</title>
151<style>
152body{margin:0;background:${t.bg};color:${t.text};font-family:${t.font}}
153main{max-width:760px;margin:0 auto;padding:24px 16px}
154.banner{background:${o.color};color:#fff;padding:16px 20px;border-radius:10px;font-size:1.2em;font-weight:600}
155.card{background:${t.card};border:1px solid ${t.muted}33;border-radius:10px;padding:14px 16px;margin:12px 0}
156.row{display:flex;justify-content:space-between}
157.bars{display:flex;gap:2px;margin:10px 0 6px;height:28px}.bars i{flex:1;border-radius:2px;min-width:3px}
158.muted{color:${t.muted};font-size:.85em}
159ul{padding-left:18px}li{margin:8px 0}
160</style></head><body><main>
161<h1>${esc(data.title)}</h1>
162<div class="banner">${o.label}</div>
163${services || '<p class="muted">ยังไม่มี service</p>'}
164<h2>เหตุขัดข้องล่าสุด</h2>
165${incidents ? `<ul>${incidents}</ul>` : '<p class="muted">ไม่มีเหตุขัดข้อง</p>'}
166<p class="muted">อัปเดต ${new Date(now).toLocaleString('th-TH')}</p>
167</main></body></html>
168`
169}
170
171const HELP = `📡 Status Page TH (Mod 21)
172========================================
173/statuspage สถานะล่าสุด + เปิดแผง
174/statuspage add <ชื่อ> <url> เพิ่ม service
175/statuspage remove <ชื่อ>
176/statuspage check เช็คทุก service ตอนนี้
177/statuspage monitor on [นาที] เช็คอัตโนมัติ (ค่าเริ่มต้น 5 นาที) | monitor off
178/statuspage incident <ข้อความ> [--service ชื่อ] [--major]
179/statuspage resolve <id> ปิด incident
180/statuspage title <ชื่อหน้า>
181/statuspage reset confirm ล้างข้อมูลทั้งหมด
182/statuspage export [ธีม] สร้าง status.html ธีม: ${Object.keys(THEMES).join(', ')}`
183
184export const register: Register = on => {
185 on('session.start', async ($, e, next) => {
186 const saved = (await $.store.get('statuspage')) as Partial<Data> | undefined
187 data = { services: [], history: {}, incidents: [], nextIncident: 0, title: 'สถานะระบบ', ...saved }
188 await $.command.register({ name: 'statuspage', description: 'หน้าสถานะระบบ: เช็ค service, uptime, incident, ส่งออก HTML (/statuspage help)', argumentHint: 'add|check|monitor|incident|export' })
189 return next(e)
190 })
191
192 // ==========================================================================
193 // บทที่ 4: แผงสถานะสด
194 // ==========================================================================
195 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196 const { Box, Text } = $.ui.resolve(e)
197 const o = overall()
198 return (
199 <Box flexDirection="column">
200 <Box backgroundColor={o.color} paddingX={1}>
201 <Text color="#ffffff" bold>
202 {o.label}
203 </Text>
204 </Box>
205 {data.services.map(s => {
206 const p = data.history[s.name]?.at(-1)
207 const recent = (data.history[s.name] ?? []).slice(-20)
208 return (
209 <Box flexDirection="column">
210 <Text>
211 {p ? (p.up ? '🟢' : '🔴') : '⚪'} {s.name} <Text dimColor>{p ? `${p.ms}ms · ${uptime(s.name)?.toFixed(1)}%` : 'ยังไม่เช็ค'}</Text>
212 </Text>
213 <Text>
214 {recent.map(r => (
215 <Text color={r.up ? '#2e7d32' : '#d32f2f'}>▮</Text>
216 ))}
217 </Text>
218 </Box>
219 )
220 })}
221 {monitor && <Text dimColor>เช็คอัตโนมัติอยู่</Text>}
222 </Box>
223 )
224 })
225
226 // ==========================================================================
227 // บทที่ 5: คำสั่ง /statuspage
228 // ==========================================================================
229 on('command.run', { command: 'statuspage' }, async ($, e) => {
230 const args = e.args.trim()
231 const [sub = '', ...rest] = args.split(/\s+/).filter(Boolean)
232 const now = await $.clock.now()
233
234 switch (sub) {
235 case 'help':
236 return { text: HELP }
237 case 'add': {
238 const [name, rawUrl] = rest
239 if (!name || !rawUrl) return { text: 'วิธีใช้: /statuspage add api https://api.example.com/health' }
240 let url: string
241 try {
242 const parsed = new URL(/^https?:\/\//.test(rawUrl) ? rawUrl : `https://${rawUrl}`)
243 // host ต้องเป็นโดเมนที่มีจุด, localhost หรือ IP (กัน "not" กลายเป็น https://not/)
244 if (!/\.|^localhost$|^\[/.test(parsed.hostname)) throw new Error('bad host')
245 url = parsed.href
246 } catch {
247 return { text: `❌ URL ไม่ถูกต้อง: ${rawUrl}` }
248 }
249 data.services = [...data.services.filter(s => s.name !== name), { name, url }]
250 await save($)
251 const p = await probe($, { name, url })
252 data.history[name] = [...(data.history[name] ?? []), p]
253 await save($)
254 $.ui.invalidate('ui.render')
255 return { text: `➕ เพิ่ม ${name} แล้ว: ${p.up ? '🟢 ใช้ได้' : '🔴 ใช้ไม่ได้'} (HTTP ${p.status || 'ไม่ตอบ'}, ${p.ms}ms)` }
256 }
257 case 'remove':
258 data.services = data.services.filter(s => s.name !== rest[0])
259 delete data.history[rest[0] ?? '']
260 await save($)
261 $.ui.invalidate('ui.render')
262 return { text: `🗑️ ลบ ${rest[0] ?? ''} แล้ว` }
263 case 'check': {
264 if (data.services.length === 0) return { text: HELP }
265 const changes = await checkAll($)
266 return { text: `${statusText()}${changes.length ? `\n\n${changes.join('\n')}` : ''}` }
267 }
268 case 'monitor': {
269 monitor?.cancel()
270 monitor = undefined
271 if (rest[0] === 'off') return { text: '⏹️ หยุดเช็คอัตโนมัติแล้ว' }
272 const minutes = Math.max(1, Number(rest[1]) || 5)
273 monitor = $.clock.every(minutes * 60_000, () => {
274 void checkAll($).then(changes => changes.forEach(c => $.ui.toast(c)))
275 })
276 $.ui.invalidate('ui.render')
277 return { text: `⏱️ เช็คอัตโนมัติทุก ${minutes} นาที ขณะเปิด Claude Code (เปลี่ยนสถานะจะเด้งเตือน)` }
278 }
279 case 'incident': {
280 const service = /--service\s+(\S+)/.exec(args)?.[1]
281 const title = args.replace(/^incident\s*/, '').replace(/--service\s+\S+|--major/g, '').trim()
282 if (!title) return { text: 'วิธีใช้: /statuspage incident API ตอบช้า --service api --major' }
283 const incident: Incident = { id: data.nextIncident++, title, service, level: /--major/.test(args) ? 'major' : 'minor', startedAt: now }
284 data.incidents = [...data.incidents, incident].slice(-100)
285 await save($)
286 $.ui.invalidate('ui.render')
287 return { text: `⚠️ เปิด incident #${incident.id}: ${title}` }
288 }
289 case 'resolve': {
290 const incident = data.incidents.find(i => i.id === Number(rest[0]))
291 if (!incident) return { text: `❌ ไม่พบ incident #${rest[0] ?? ''}` }
292 incident.resolvedAt = now
293 await save($)
294 $.ui.invalidate('ui.render')
295 return { text: `✅ ปิด incident #${incident.id} แล้ว` }
296 }
297 case 'reset':
298 if (rest[0] !== 'confirm') return { text: '⚠️ จะลบ service ประวัติ และ incident ทั้งหมด ยืนยันด้วย /statuspage reset confirm' }
299 monitor?.cancel()
300 monitor = undefined
301 data = { services: [], history: {}, incidents: [], nextIncident: 0, title: 'สถานะระบบ' }
302 await save($)
303 $.ui.invalidate('ui.render')
304 return { text: '🧹 ล้างข้อมูลทั้งหมดแล้ว' }
305 case 'title':
306 data.title = rest.join(' ') || data.title
307 await save($)
308 return { text: `📝 ชื่อหน้า: ${data.title}` }
309 case 'export': {
310 const theme = rest[0] && THEMES[rest[0]] ? rest[0] : 'light'
311 const root = (await $.session.cwd()).replace(/[\\/]$/, '')
312 const path = `${root}${root.includes('\\') ? '\\' : '/'}status.html`
313 await $.fs.write(path, buildHtml(theme, now))
314 return { text: `💾 สร้าง ${path} (ธีม ${theme}) เปิดใน browser หรืออัปขึ้นเว็บได้เลย` }
315 }
316 default:
317 await $.ui.open({ id: PANE, title: '📡 Status' })
318 return { text: statusText() }
319 }
320 })
321}
322
323/**
324 * ========================================================================
325 * การบ้าน / ต่อยอด
326 * ========================================================================
327 * 1. ส่งแจ้งเตือนเข้ามือถือเมื่อ service ล่ม (ต่อกับ Mod 22 Smartwatch Notify หรือ LINE)
328 * 2. วัดเวลาตอบเฉลี่ยรายวันแล้ววาดกราฟ
329 * 3. ตั้ง GitHub Actions ให้ export status.html ทุก 5 นาทีแล้ว deploy อัตโนมัติ
330 */
331