SLOPSHOPPER

status-page-th

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

newpanecommandtoastnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-page-th
│ ┃ 📡 Status ✕ › fix the failing auth test and add an audit log call │ ┃ ทุกระบบทำงานปกติ │ ⏺ 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 │ │ › /statuspage │ ⎿ status-page-th: ยังไม่มี service เพิ่มด้วย /statuspage add <ชื่อ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 📡 Status
ทุกระบบทำงานปกติ
README

Mod 21: Status Page TH

ทำหน้าสถานะระบบแบบ 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. เช็ค service ด้วย HTTP พร้อม timeout (Promise.race กับ $.clock.sleep)
  2. คำนวณ uptime และสถานะรวม
  3. สร้างหน้า HTML หน้าเดียว และ escape ทุกข้อความที่มาจากผู้ใช้
  4. แผงสถานะสดพร้อมแถบประวัติ
  5. คำสั่ง /statuspage และการเช็คอัตโนมัติด้วย $.clock.every

หมายเหตุ

ใช้ชื่อ /statuspage เพราะ /status เป็นคำสั่งเดิมของ Claude Code การเช็คอัตโนมัติทำงานเฉพาะตอนเปิด Claude Code อยู่ ถ้าต้องการเฝ้าตลอด 24 ชม. ให้ใช้บริการ uptime monitor หรือ GitHub Actions

Source 1 files
hooks/register.tsx 331 lines
1/**
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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