fix: complete caller analytics reliability remediation
This commit is contained in:
@@ -11,11 +11,11 @@ const rateColumns = [
|
||||
{ key: 'connectedAnswerRate', label: '实时已接通应答率', render: r => percent(r.connectedAnswerRate) }
|
||||
];
|
||||
const countColumns = [{ key: 'totalCalls', label: '总呼叫数' }, { key: 'connectedCalls', label: '接通数' }, { key: 'notConnectedCalls', label: '未接通数' }, { key: 'answeredCalls', label: '应答数' }];
|
||||
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
||||
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
||||
export function CallerAnalyticsPage() {
|
||||
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
|
||||
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendorGateways: [] });
|
||||
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendors: [], vendorGateways: [] });
|
||||
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
|
||||
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
||||
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
|
||||
@@ -44,7 +44,7 @@ export function CallerAnalyticsPage() {
|
||||
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
||||
return () => c.abort();
|
||||
}, [target, data, filters, detailSkip]);
|
||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorId:'', vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
||||
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
||||
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
||||
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
||||
@@ -63,7 +63,7 @@ export function CallerAnalyticsPage() {
|
||||
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
||||
{select('客户', 'customerId', options.customers)}
|
||||
{select('客户网关', 'customerGatewayId', options.customerGateways.filter(g => !draft.customerId || g.customerId === draft.customerId))}
|
||||
{draft.view === 'landing' ? select('落地网关', 'vendorGatewayId', options.vendorGateways) : null}
|
||||
{draft.view === 'landing' ? <>{select('供应商', 'vendorId', options.vendors)}{select('落地网关', 'vendorGatewayId', options.vendorGateways)}</> : null}
|
||||
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
|
||||
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
|
||||
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
|
||||
@@ -75,8 +75,8 @@ export function CallerAnalyticsPage() {
|
||||
<Button type="submit">查询</Button>
|
||||
</form></Panel>
|
||||
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
||||
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。采集运行状态不代表窗口数据完整性已核验,端到端延迟尚未测定。</Alert> : null}
|
||||
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.coverageStatus === 'CONTINUOUS' ? '连续采集覆盖' : '数据覆盖不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 接通未知 ${metrics.unknownCalls} · 时长未知 ${metrics.durationUnknownCalls} · 暂定应答 ${metrics.provisionalAnsweredCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通。口径 {data.metricVersion},算法 {data.analysisRevision},采集截至 {data.dataThrough ? formatDateTime(data.dataThrough) : '未知'},延迟 {data.lagMs == null ? '未知' : `${data.lagMs}ms`}。</Alert> : null}
|
||||
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
||||
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
||||
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
||||
@@ -84,7 +84,7 @@ export function CallerAnalyticsPage() {
|
||||
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
|
||||
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
|
||||
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls ? '证据不足' : '观察'}</Badge>}
|
||||
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'接通未知'}, {key:'durationUnknownCalls',label:'时长未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'} title={r.alert?.scope}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls || r.durationUnknownCalls ? '证据不足' : '观察'}</Badge>}
|
||||
]} />
|
||||
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
|
||||
</Panel>
|
||||
@@ -95,9 +95,9 @@ export function CallerAnalyticsPage() {
|
||||
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
||||
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
|
||||
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
|
||||
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : '未达接通').join(' / '),
|
||||
final:legs.map(l => l.finalCode || '进行中').join(' / '), duration:`${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.activeCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
||||
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'最终结果'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
||||
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : l.talkMs > 0 ? '可信正时长补计' : '未达接通').join(' / '),
|
||||
final:legs.map(l => l.finalCode ? `初始 INVITE ${l.finalCode}` : '初始 INVITE 进行中').join(' / '), termination:legs.map(l => l.terminationReason || '未结束').join(' / '), duration:r.counts.durationUnknownCalls ? '证据不足' : `${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.provisionalAnsweredCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
||||
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'初始 INVITE 结果'},{key:'termination',label:'结束原因'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
||||
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
|
||||
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
||||
</Drawer> : null}
|
||||
|
||||
Reference in New Issue
Block a user