Fix LogsPage missing after clone by correcting frontend .gitignore
This commit is contained in:
parent
efa5080897
commit
38eb2a11d5
2 changed files with 279 additions and 2 deletions
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
# Logs
|
# Logs (root-level only — do not use bare "logs" or src/features/logs is ignored)
|
||||||
logs
|
/logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|
|
||||||
277
frontend/src/features/logs/LogsPage.tsx
Normal file
277
frontend/src/features/logs/LogsPage.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { logsApi, type LogType, type LogsResponse } from '../../lib/api'
|
||||||
|
|
||||||
|
const LOG_TYPES: Array<{ value: LogType; label: string }> = [
|
||||||
|
{ value: 'outbound', label: 'Outbound' },
|
||||||
|
{ value: 'inbound', label: 'Inbound' },
|
||||||
|
{ value: 'sms', label: 'SMS' },
|
||||||
|
{ value: 'otp', label: 'OTP' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const DEFAULT_RANGE_DAYS = 7
|
||||||
|
const MAX_RANGE_DAYS = 31
|
||||||
|
|
||||||
|
function formatDateInput(date: Date) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const copy = new Date(date)
|
||||||
|
copy.setHours(0, 0, 0, 0)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date: Date) {
|
||||||
|
const copy = new Date(date)
|
||||||
|
copy.setHours(23, 59, 59, 999)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string) {
|
||||||
|
return new Date(value).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhone(value: string) {
|
||||||
|
const digits = value.replace(/\D/g, '')
|
||||||
|
if (digits.length === 12 && digits.startsWith('91')) {
|
||||||
|
return `+91 ${digits.slice(2, 7)} ${digits.slice(7)}`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string) {
|
||||||
|
if (['sent', 'delivered', 'accepted', 'ended', 'active'].includes(status)) return 'success'
|
||||||
|
if (['failed', 'no_answer'].includes(status)) return 'failed'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRange() {
|
||||||
|
const to = new Date()
|
||||||
|
const from = new Date()
|
||||||
|
from.setDate(from.getDate() - DEFAULT_RANGE_DAYS)
|
||||||
|
return { from: formatDateInput(from), to: formatDateInput(to) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsPage() {
|
||||||
|
const initialRange = useMemo(() => defaultRange(), [])
|
||||||
|
const [logType, setLogType] = useState<LogType>('outbound')
|
||||||
|
const [fromDate, setFromDate] = useState(initialRange.from)
|
||||||
|
const [toDate, setToDate] = useState(initialRange.to)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [data, setData] = useState<LogsResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const rangeError = useMemo(() => {
|
||||||
|
const from = startOfDay(new Date(fromDate))
|
||||||
|
const to = endOfDay(new Date(toDate))
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
return 'Enter valid dates.'
|
||||||
|
}
|
||||||
|
if (from > to) {
|
||||||
|
return 'Start date must be before end date.'
|
||||||
|
}
|
||||||
|
const spanDays = Math.ceil((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
if (spanDays > MAX_RANGE_DAYS) {
|
||||||
|
return `Maximum range is ${MAX_RANGE_DAYS} days.`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [fromDate, toDate])
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
if (rangeError) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const fromIso = startOfDay(new Date(fromDate)).toISOString()
|
||||||
|
const toIso = endOfDay(new Date(toDate)).toISOString()
|
||||||
|
const response = await logsApi.list({
|
||||||
|
type: logType,
|
||||||
|
from: fromIso,
|
||||||
|
to: toIso,
|
||||||
|
page,
|
||||||
|
})
|
||||||
|
setData(response)
|
||||||
|
} catch (reason) {
|
||||||
|
setData(null)
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to load logs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [fromDate, logType, page, rangeError, toDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [loadLogs])
|
||||||
|
|
||||||
|
function changeType(nextType: LogType) {
|
||||||
|
setLogType(nextType)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDateChange(kind: 'from' | 'to', value: string) {
|
||||||
|
if (kind === 'from') setFromDate(value)
|
||||||
|
else setToDate(value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
switch (logType) {
|
||||||
|
case 'sms':
|
||||||
|
return ['From', 'To', 'Template', 'Status', 'Time']
|
||||||
|
case 'otp':
|
||||||
|
return ['To', 'OTP type', 'Project', 'Status', 'Time']
|
||||||
|
case 'inbound':
|
||||||
|
return ['From', 'To', 'Status', 'Time']
|
||||||
|
default:
|
||||||
|
return ['From', 'To', 'Socket', 'Status', 'Time']
|
||||||
|
}
|
||||||
|
}, [logType])
|
||||||
|
|
||||||
|
const rows = data?.items ?? []
|
||||||
|
const totalPages = data?.total_pages ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="logs-page">
|
||||||
|
<div className="account-heading">
|
||||||
|
<span className="eyebrow">Test & observe</span>
|
||||||
|
<h1>Logs</h1>
|
||||||
|
<p>Browse outbound and inbound calls, SMS sends, and OTP deliveries. Default view covers the last week, up to one month.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="panel logs-toolbar">
|
||||||
|
<div className="logs-toolbar-row">
|
||||||
|
<label className="logs-field">
|
||||||
|
<span>Log type</span>
|
||||||
|
<select value={logType} onChange={(event) => changeType(event.target.value as LogType)}>
|
||||||
|
{LOG_TYPES.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>{item.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="logs-field">
|
||||||
|
<span>From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
max={toDate}
|
||||||
|
onChange={(event) => applyDateChange('from', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="logs-field">
|
||||||
|
<span>To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={toDate}
|
||||||
|
min={fromDate}
|
||||||
|
max={formatDateInput(new Date())}
|
||||||
|
onChange={(event) => applyDateChange('to', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="logs-toolbar-meta">
|
||||||
|
<span>{PAGE_SIZE} records per page</span>
|
||||||
|
{data && (
|
||||||
|
<span>
|
||||||
|
Showing page {data.page} of {Math.max(totalPages, 1)} · {data.total} total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(rangeError || error) && (
|
||||||
|
<p className="auth-error">{rangeError || error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="panel logs-table-panel">
|
||||||
|
{loading ? (
|
||||||
|
<p className="explorer-loading">Loading logs…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="logs-empty">No {logType} logs in this time range.</p>
|
||||||
|
) : (
|
||||||
|
<div className="logs-table">
|
||||||
|
<div className={`logs-table-head logs-cols-${columns.length}`}>
|
||||||
|
{columns.map((column) => <span key={column}>{column}</span>)}
|
||||||
|
</div>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div className={`logs-table-row logs-cols-${columns.length}`} key={row.id}>
|
||||||
|
{logType === 'sms' && (
|
||||||
|
<>
|
||||||
|
<strong>{row.from}</strong>
|
||||||
|
<span>{formatPhone(row.to ?? '')}</span>
|
||||||
|
<span>{row.template}</span>
|
||||||
|
<span className={`logs-status ${statusClass(row.status ?? '')}`}>{row.status}</span>
|
||||||
|
<span>{formatTimestamp(row.time)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{logType === 'otp' && (
|
||||||
|
<>
|
||||||
|
<span>{formatPhone(row.to ?? '')}</span>
|
||||||
|
<span>{row.otp_type?.replace(/_/g, ' ')}</span>
|
||||||
|
<span>{row.project}</span>
|
||||||
|
<span className={`logs-status ${statusClass(row.status ?? '')}`}>{row.status}</span>
|
||||||
|
<span>{formatTimestamp(row.time)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{logType === 'inbound' && (
|
||||||
|
<>
|
||||||
|
<span>{formatPhone(row.from ?? '')}</span>
|
||||||
|
<span>{formatPhone(row.to ?? '')}</span>
|
||||||
|
<span className={`logs-status ${statusClass(row.status ?? '')}`}>{row.status}</span>
|
||||||
|
<span>{formatTimestamp(row.time)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{logType === 'outbound' && (
|
||||||
|
<>
|
||||||
|
<span>{formatPhone(row.from ?? '')}</span>
|
||||||
|
<span>{formatPhone(row.to ?? '')}</span>
|
||||||
|
<span>{row.socket_name || '—'}</span>
|
||||||
|
<span className={`logs-status ${statusClass(row.status ?? '')}`}>{row.status}</span>
|
||||||
|
<span>{formatTimestamp(row.time)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="logs-pagination">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} /> Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((current) => current + 1)}
|
||||||
|
>
|
||||||
|
Next <ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue