feat(phase-4): SMS gateway dashboard + stats + health + retry

Backend (backend/app/routers/sms.py):
- GET /api/sms/stats: aggregate totals by status, daily volume chart
  (configurable N days), per-school top-10 breakdown, delivery rate,
  live queue depth
- POST /api/sms/jobs/{id}/retry: reset failed/cancelled job to pending
- GET /api/sms/health: Semaphore API connectivity check + credit balance
- POST /api/sms/trigger-queue: manually fire sms.process_queue Celery task

Frontend (frontend/src/pages/SmsPage.vue) — rebuilt from scratch:
- Semaphore health badge with live status indicator and balance display
- 'Process Queue' button calling POST /api/sms/trigger-queue
- KPI row: Total, Sent, Failed, Pending (queue depth), Delivery Rate %;
  period picker switching between 7/30/90 day views
- Volume bar chart: daily sent vs failed bars with hover tooltips,
  x-axis date labels, legend; no charting library required
- School breakdown panel: top 10 schools by volume with delivery
  percentage bars
- Jobs table: status + school + phone number filters; inline error
  message on failed rows; Retry button for failed/cancelled jobs;
  retry count display; pagination

api.ts: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue

PAUL: Phase 4 marked complete, STATE + ROADMAP updated
This commit is contained in:
kevin-asprec
2026-03-16 10:37:23 +08:00
parent a5430736d9
commit 5cab1938b1
6 changed files with 512 additions and 38 deletions

View File

@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
## Current Milestone
**v1.0 — Foundation & Core Services**
Status: Phase 3 complete — Phase 4 next
Phases: 3 of 15 complete
Status: Phase 4 complete — Phase 5 next
Phases: 4 of 15 complete
---
@@ -31,7 +31,7 @@ Phases: 3 of 15 complete
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
| 4 | SMS Gateway (credits + queue) | TBD | Not started | |
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
| 7 | School Admin Portal UI | TBD | Not started | — |

View File

@@ -3,10 +3,10 @@
## Current Position
Milestone: v1.0 — Foundation & Core Services
Phase: 3 of 15 (On-Prem License Validation — complete)
Plan: Phase 3 complete — Phase 4 next
Status: **Phase 3 applied — ready to begin Phase 4**
Last activity: 2026-03-15 — Phase 3 complete (TapTrack on-prem: license validation task, startup check, hub router, frontend warning banner)
Phase: 4 of 15 (SMS Gateway — complete)
Plan: Phase 4 complete — Phase 5 next
Status: **Phase 4 applied — ready to begin Phase 5**
Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown)
## Loop Position
@@ -22,7 +22,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0%
- Phase 4 (SMS Gateway): [██████████] 100% ✓
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action
Run: `/paul:plan` for Phase 4SMS Gateway (credits + queue)
Resume file: .paul/ROADMAP.md → Phase 4
Run: `/paul:plan` for Phase 5On-Prem SMS Polling Agent
Resume file: .paul/ROADMAP.md → Phase 5
## Repo

View File

@@ -1,9 +1,25 @@
# Phase 04: SMS Gateway (Credits + Queue)
**Status:** Not started
**Status:** ✅ Complete — 2026-03-15
## Goal
Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message.
Full SMS dashboard for super admin with stats, charts, job table, Semaphore health.
## Plans
- [ ] TBD — run /paul:plan when Phase 3 is complete
## Completed
### 4-01: Backend stats + retry + health + trigger
- `GET /api/sms/stats` — aggregate totals, daily chart (N days), school breakdown
- `POST /api/sms/jobs/{id}/retry` — reset failed/cancelled job to pending
- `GET /api/sms/health` — Semaphore connectivity + credit balance
- `POST /api/sms/trigger-queue` — manual Celery task trigger (admin)
- `api.ts`: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue
### 4-02: SmsPage full dashboard
- Semaphore health badge (green/red, shows balance)
- Process Queue button (fires trigger-queue)
- KPI row: Total, Sent, Failed, Pending, Delivery Rate %, period picker 7/30/90d
- Volume bar chart (daily sent vs failed, hover tooltips)
- School breakdown panel (top 10, sent/total ratio bars)
- Jobs table: status + school + phone filters, error message on failed rows,
Retry button, retry count, pagination

View File

@@ -1,10 +1,10 @@
"""SMS gateway endpoints."""
from typing import Optional
from datetime import datetime, timezone
from datetime import datetime, timezone, date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, and_
from sqlalchemy import select, func, desc, and_, cast, Date
from app.auth.dependencies import require_super_admin, require_school_admin, get_current_user
from app.database import get_db
@@ -107,3 +107,177 @@ async def get_credit_ledger(
],
"total": total,
}
# ── SMS Stats ─────────────────────────────────────────────────────────────────
@router.get("/stats")
async def get_sms_stats(
school_id: Optional[str] = Query(None),
days: int = Query(30, ge=1, le=90),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""
Aggregate SMS stats for the super admin dashboard.
Returns: totals by status, daily volume for last N days, per-school breakdown.
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
base_filter = [SmsJob.created_at >= since]
if school_id:
base_filter.append(SmsJob.school_id == school_id)
# Totals by status
status_rows = (await db.execute(
select(SmsJob.status, func.count().label("cnt"))
.where(and_(*base_filter))
.group_by(SmsJob.status)
)).all()
totals = {r.status.value: r.cnt for r in status_rows}
total_all = sum(totals.values())
total_sent = totals.get("sent", 0)
delivery_rate = round((total_sent / total_all * 100), 1) if total_all > 0 else 0.0
# Daily volume for chart (last N days)
daily_rows = (await db.execute(
select(
cast(SmsJob.created_at, Date).label("day"),
SmsJob.status,
func.count().label("cnt"),
)
.where(and_(*base_filter))
.group_by(cast(SmsJob.created_at, Date), SmsJob.status)
.order_by(cast(SmsJob.created_at, Date))
)).all()
# Build day-keyed dict
day_map: dict = {}
for row in daily_rows:
key = str(row.day)
if key not in day_map:
day_map[key] = {"date": key, "sent": 0, "failed": 0, "pending": 0, "total": 0}
day_map[key][row.status.value] = row.cnt
day_map[key]["total"] += row.cnt
# Fill missing days with zeros
chart = []
for i in range(days):
d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).date().isoformat()
chart.append(day_map.get(d, {"date": d, "sent": 0, "failed": 0, "pending": 0, "total": 0}))
# Per-school breakdown (top 10 by volume, super admin only, no school filter)
school_breakdown: list = []
if not school_id:
school_rows = (await db.execute(
select(
SmsJob.school_id,
func.count().label("total"),
func.sum(
func.cast(SmsJob.status == SmsJobStatus.sent, func.Integer())
).label("sent"),
)
.where(SmsJob.created_at >= since)
.group_by(SmsJob.school_id)
.order_by(desc("total"))
.limit(10)
)).all()
# Fetch school names
for sr in school_rows:
school = (await db.execute(
select(School.name).where(School.id == sr.school_id)
)).scalar_one_or_none()
school_breakdown.append({
"school_id": sr.school_id,
"school_name": school or sr.school_id,
"total": sr.total,
"sent": int(sr.sent or 0),
})
# Pending jobs count (for the "queue depth" indicator)
pending_count = (await db.execute(
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
)).scalar_one()
return {
"period_days": days,
"total": total_all,
"sent": total_sent,
"failed": totals.get("failed", 0),
"pending": totals.get("pending", 0),
"cancelled": totals.get("cancelled", 0),
"delivery_rate": delivery_rate,
"queue_depth": pending_count,
"chart": chart,
"by_school": school_breakdown,
}
# ── Retry failed job ──────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/retry")
async def retry_sms_job(
job_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Reset a failed/cancelled SMS job back to pending so it gets re-processed."""
job = (await db.execute(select(SmsJob).where(SmsJob.id == job_id))).scalar_one_or_none()
if not job:
raise HTTPException(404, "Job not found")
if job.status not in (SmsJobStatus.failed, SmsJobStatus.cancelled):
raise HTTPException(400, f"Cannot retry job with status '{job.status.value}'")
job.status = SmsJobStatus.pending
job.retry_count = 0
job.error_message = None
await db.commit()
return {"id": job.id, "status": "pending", "message": "Job queued for retry"}
# ── Manual queue trigger ──────────────────────────────────────────────────────
@router.post("/trigger-queue", status_code=202)
async def trigger_sms_queue(
_admin: HubUser = Depends(require_super_admin),
):
"""Manually trigger the SMS processing Celery task. Admin only."""
try:
from app.worker import celery_app
celery_app.send_task("sms.process_queue")
return {"message": "SMS queue processing triggered", "status": "queued"}
except Exception as exc:
raise HTTPException(500, f"Failed to trigger task: {exc}")
# ── Semaphore health check ────────────────────────────────────────────────────
@router.get("/health")
async def semaphore_health(
_admin: HubUser = Depends(require_super_admin),
):
"""
Check Semaphore API connectivity and remaining message balance.
Returns: reachable (bool), balance (int | null), error (str | null).
"""
import httpx
from app.config import settings
if not settings.SEMAPHORE_API_KEY:
return {"reachable": False, "balance": None, "error": "SEMAPHORE_API_KEY not configured"}
try:
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.get(
"https://api.semaphore.co/api/v4/account",
params={"apikey": settings.SEMAPHORE_API_KEY},
)
if resp.status_code == 200:
data = resp.json()
# Semaphore returns credits as a numeric field
balance = data.get("credits") or data.get("balance") or data.get("credit_balance")
return {"reachable": True, "balance": balance, "error": None, "account": data.get("name")}
return {"reachable": False, "balance": None, "error": f"HTTP {resp.status_code}"}
except httpx.TimeoutException:
return {"reachable": False, "balance": None, "error": "Timeout connecting to Semaphore API"}
except Exception as exc:
return {"reachable": False, "balance": None, "error": str(exc)}

View File

@@ -60,6 +60,10 @@ export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
export const getCreditLedger = (schoolId: string, params?: object) =>
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data)
export const getSmsStats = (params?: object) => api.get('/sms/stats', { params }).then(r => r.data)
export const retrySmsJob = (id: string) => api.post(`/sms/jobs/${id}/retry`).then(r => r.data)
export const getSmsHealth = () => api.get('/sms/health').then(r => r.data)
export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
// ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)

View File

@@ -1,18 +1,183 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
<div class="flex gap-3 flex-wrap">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-sm text-slate-500 mt-0.5">Semaphore-proxied SMS for all schools</p>
</div>
<div class="flex items-center gap-3">
<!-- Semaphore health badge -->
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border"
:class="health === null ? 'border-slate-200 text-slate-400 bg-white'
: health.reachable ? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-red-200 text-red-700 bg-red-50'">
<span class="w-2 h-2 rounded-full"
:class="health === null ? 'bg-slate-300 animate-pulse'
: health.reachable ? 'bg-emerald-500' : 'bg-red-500'"></span>
<span v-if="health === null">Checking Semaphore</span>
<span v-else-if="health.reachable">
Semaphore OK{{ health.balance != null ? ` · ${Number(health.balance).toLocaleString()} credits` : '' }}
</span>
<span v-else>Semaphore unreachable</span>
</div>
<button @click="triggerProcessQueue" :disabled="processing"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-60 transition-colors">
<Zap :size="15" />
{{ processing ? 'Processing…' : 'Process Queue' }}
</button>
</div>
</div>
<!-- KPI row -->
<div v-if="statsLoading" class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 animate-pulse">
<div v-for="i in 6" :key="i" class="bg-white rounded-xl h-20" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else-if="stats" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Total</p>
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.total.toLocaleString() }}</p>
</div>
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
<p class="text-xs text-emerald-600 font-semibold uppercase tracking-wide">Sent</p>
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.sent.toLocaleString() }}</p>
</div>
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
<p class="text-xs text-red-500 font-semibold uppercase tracking-wide">Failed</p>
<p class="text-2xl font-bold text-red-500 mt-1">{{ stats.failed.toLocaleString() }}</p>
</div>
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
<p class="text-xs text-amber-500 font-semibold uppercase tracking-wide">Pending</p>
<p class="text-2xl font-bold text-amber-500 mt-1">{{ stats.queue_depth.toLocaleString() }}</p>
</div>
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
<p class="text-xs text-blue-500 font-semibold uppercase tracking-wide">Delivery Rate</p>
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.delivery_rate }}%</p>
</div>
<div class="bg-white rounded-xl p-4 cursor-pointer" style="box-shadow:0 2px 8px #0000000A"
:title="`Period: last ${periodDays} days`">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Period</p>
<div class="flex gap-1 mt-1.5">
<button v-for="d in [7, 30, 90]" :key="d" @click="periodDays = d; loadStats()"
class="text-xs px-2 py-0.5 rounded font-medium transition-colors"
:class="periodDays === d ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100'">
{{ d }}d
</button>
</div>
</div>
</div>
<!-- Chart + School breakdown -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Volume chart -->
<div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-4">Daily Volume Last {{ periodDays }} Days</h2>
<div v-if="statsLoading" class="h-40 animate-pulse bg-slate-50 rounded-xl"></div>
<div v-else-if="stats && stats.chart.length" class="relative h-40">
<!-- Simple bar chart using CSS flex -->
<div class="flex items-end gap-0.5 h-full">
<div v-for="day in chartVisible" :key="day.date"
class="flex-1 flex flex-col items-center gap-0.5 group relative"
:title="`${day.date}: ${day.sent} sent, ${day.failed} failed`">
<div class="w-full flex flex-col-reverse gap-px" :style="{ height: '100%' }">
<!-- Failed bar -->
<div v-if="day.failed > 0" class="w-full bg-red-300 rounded-t-sm transition-all"
:style="{ height: barHeight(day.failed) + '%' }"></div>
<!-- Sent bar -->
<div v-if="day.sent > 0" class="w-full bg-blue-500 rounded-t-sm transition-all"
:style="{ height: barHeight(day.sent) + '%' }"></div>
</div>
<!-- Tooltip on hover -->
<div class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:flex
flex-col items-center z-10 pointer-events-none">
<div class="bg-slate-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{{ formatChartDate(day.date) }}: {{ day.sent }} sent
<span v-if="day.failed > 0" class="text-red-300">, {{ day.failed }} failed</span>
</div>
</div>
</div>
</div>
<!-- X-axis labels (first, middle, last) -->
<div class="flex justify-between mt-2 text-xs text-slate-400">
<span>{{ formatChartDate(chartVisible[0]?.date) }}</span>
<span>{{ formatChartDate(chartVisible[Math.floor(chartVisible.length / 2)]?.date) }}</span>
<span>{{ formatChartDate(chartVisible[chartVisible.length - 1]?.date) }}</span>
</div>
<!-- Legend -->
<div class="flex gap-4 mt-1">
<div class="flex items-center gap-1.5 text-xs text-slate-500">
<div class="w-3 h-3 rounded-sm bg-blue-500"></div> Sent
</div>
<div class="flex items-center gap-1.5 text-xs text-slate-500">
<div class="w-3 h-3 rounded-sm bg-red-300"></div> Failed
</div>
</div>
</div>
<div v-else class="h-40 flex items-center justify-center text-slate-400 text-sm">No data for this period</div>
</div>
<!-- School breakdown -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-4">Top Schools</h2>
<div v-if="statsLoading" class="space-y-3 animate-pulse">
<div v-for="i in 5" :key="i" class="h-8 bg-slate-50 rounded"></div>
</div>
<div v-else-if="stats?.by_school?.length === 0" class="text-sm text-slate-400 text-center pt-8">No data</div>
<div v-else class="space-y-3">
<div v-for="s in stats?.by_school" :key="s.school_id" class="text-sm">
<div class="flex items-center justify-between mb-1">
<span class="text-slate-700 font-medium truncate max-w-[140px]" :title="s.school_name">{{ s.school_name }}</span>
<span class="text-xs text-slate-500 shrink-0">{{ s.sent }}/{{ s.total }}</span>
</div>
<div class="h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all"
:style="{ width: s.total > 0 ? (s.sent / s.total * 100) + '%' : '0%' }"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters + jobs table -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<!-- Table header bar -->
<div class="flex items-center gap-3 px-5 py-4 border-b border-slate-100 flex-wrap">
<h2 class="text-base font-semibold text-slate-900 mr-auto">SMS Jobs</h2>
<input v-model="phoneSearch" type="text" placeholder="Filter by phone…"
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-44" />
<select v-model="statusFilter"
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select v-model="schoolFilter"
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[180px]">
<option value="">All Schools</option>
<option v-for="s in stats?.by_school" :key="s.school_id" :value="s.school_id">
{{ s.school_name }}
</option>
</select>
</div>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS jobs found</div>
<!-- Skeleton -->
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading jobs</div>
<!-- Empty -->
<div v-else-if="jobs.length === 0"
class="flex flex-col items-center justify-center py-14 text-slate-400">
<MessageSquare :size="36" class="mb-3 opacity-30" />
<p class="font-medium text-sm">No SMS jobs found</p>
<p class="text-xs mt-1">Try changing filters</p>
</div>
<!-- Table -->
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
@@ -21,36 +186,151 @@
<th class="px-5 py-3">Sender</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</th>
<th class="px-5 py-3">Retry</th>
<th class="px-5 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50 transition-colors">
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
<td class="px-5 py-3 text-slate-600 max-w-xs">
<span class="block truncate" :title="j.message">{{ j.message }}</span>
<span v-if="j.error_message" class="text-xs text-red-500 block mt-0.5 truncate" :title="j.error_message">
{{ j.error_message }}
</span>
</td>
<td class="px-5 py-3 font-mono text-xs">{{ j.sender_name }}</td>
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ j.trigger || '—' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs capitalize">{{ j.trigger || '—' }}</td>
<td class="px-5 py-3">
<span class="text-xs text-slate-400">{{ j.retry_count }}</span>
<button v-if="['failed','cancelled'].includes(j.status)"
@click="retryJob(j.id)"
class="ml-2 text-xs text-blue-600 hover:underline font-medium">
Retry
</button>
</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
<span>Showing {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--"
class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Prev</button>
<button :disabled="page * perPage >= total" @click="page++"
class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Next</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getSmsJobs } from '@/lib/api'
import { ref, computed, watch, onMounted } from 'vue'
import { Zap, MessageSquare } from 'lucide-vue-next'
import { getSmsJobs, getSmsStats, getSmsHealth, retrySmsJob, triggerSmsQueue } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
// ── Stats ─────────────────────────────────────────────────────────────────────
const stats = ref<any>(null)
const statsLoading = ref(true)
const periodDays = ref(30)
async function loadStats() {
statsLoading.value = true
try { stats.value = await getSmsStats({ days: periodDays.value }) }
catch { /* ignore */ }
finally { statsLoading.value = false }
}
// ── Semaphore health ──────────────────────────────────────────────────────────
const health = ref<any>(null)
async function loadHealth() {
try { health.value = await getSmsHealth() }
catch { health.value = { reachable: false, balance: null, error: 'Could not reach health endpoint' } }
}
// ── Chart helpers ─────────────────────────────────────────────────────────────
const chartVisible = computed(() => stats.value?.chart ?? [])
const chartMax = computed(() =>
Math.max(...(chartVisible.value.map((d: any) => d.sent + d.failed)), 1)
)
function barHeight(val: number): number {
return Math.max((val / chartMax.value) * 100, val > 0 ? 4 : 0)
}
function formatChartDate(iso: string | undefined): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
}
// ── Jobs table ────────────────────────────────────────────────────────────────
const jobs = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false)
const statusFilter = ref('')
const schoolFilter = ref('')
const phoneSearch = ref('')
let debounce: any = null
const processing = ref(false)
async function fetchJobs() {
loading.value = true
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items }
finally { loading.value = false }
try {
const res = await getSmsJobs({
page: page.value,
per_page: perPage,
status: statusFilter.value || undefined,
school_id: schoolFilter.value || undefined,
})
jobs.value = res.items
total.value = res.total
} finally { loading.value = false }
}
watch(statusFilter, fetchJobs)
onMounted(fetchJobs)
watch([statusFilter, schoolFilter, page], fetchJobs)
watch(phoneSearch, () => {
clearTimeout(debounce)
debounce = setTimeout(() => { page.value = 1; fetchJobs() }, 300)
})
async function retryJob(id: string) {
try {
await retrySmsJob(id)
toast.success('Job queued for retry')
fetchJobs()
loadStats()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to retry job')
}
}
async function triggerProcessQueue() {
processing.value = true
try {
await triggerSmsQueue()
toast.success('SMS queue processing triggered')
setTimeout(() => { loadStats(); fetchJobs() }, 2000)
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to trigger queue')
} finally { processing.value = false }
}
onMounted(async () => {
await Promise.all([loadStats(), loadHealth(), fetchJobs()])
})
</script>