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
|
||||
# Logs (root-level only — do not use bare "logs" or src/features/logs is ignored)
|
||||
/logs
|
||||
*.log
|
||||
npm-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