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

@@ -57,9 +57,13 @@ export const updateLicense = (id: string, data: object) => api.put(`/licenses/${
export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)
// ── SMS ───────────────────────────────────────────────────────────────────────
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
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">
<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">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<!-- 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>
<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">
<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>
<!-- 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>
<!-- 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'
const jobs = ref<any[]>([])
const loading = ref(false)
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>