391 lines
13 KiB
JavaScript
391 lines
13 KiB
JavaScript
const statusView = document.getElementById('status-view');
|
|
const settingsEditor = document.getElementById('settings-editor');
|
|
const resultView = document.getElementById('result-view');
|
|
const dashboardSummary = document.getElementById('dashboard-summary');
|
|
const eventsList = document.getElementById('events-list');
|
|
const eventsEmpty = document.getElementById('events-empty');
|
|
const refreshDashboardButton = document.getElementById('refresh-dashboard');
|
|
const refreshStatusButton = document.getElementById('refresh-status');
|
|
const refreshEventsButton = document.getElementById('refresh-events');
|
|
const reloadSettingsButton = document.getElementById('reload-settings');
|
|
const saveSettingsButton = document.getElementById('save-settings');
|
|
const smsForm = document.getElementById('sms-form');
|
|
const deliveryModeSelect = document.getElementById('sms-delivery-mode');
|
|
const modeHint = document.getElementById('mode-hint');
|
|
const eventSearchInput = document.getElementById('event-search');
|
|
const eventResultFilter = document.getElementById('event-result-filter');
|
|
|
|
const metricEventCount = document.getElementById('metric-event-count');
|
|
const metricManualSendCount = document.getElementById('metric-manual-send-count');
|
|
const metricFailureCount = document.getElementById('metric-failure-count');
|
|
const metricLastPhone = document.getElementById('metric-last-phone');
|
|
|
|
const deliveryModeLabelMap = {
|
|
dispatch: '通知转发',
|
|
real_sms: '真实短信',
|
|
};
|
|
|
|
let latestEvents = [];
|
|
|
|
function getDeliveryModeLabel(mode) {
|
|
return deliveryModeLabelMap[mode] || mode || '未知';
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function formatText(value, fallback = '-') {
|
|
const text = String(value ?? '').trim();
|
|
return text || fallback;
|
|
}
|
|
|
|
function getResultLabel(result) {
|
|
if (result === 'success') {
|
|
return '成功';
|
|
}
|
|
if (result === 'failed') {
|
|
return '失败';
|
|
}
|
|
return formatText(result, '未知');
|
|
}
|
|
|
|
function updateModeHint() {
|
|
const deliveryMode = deliveryModeSelect?.value || 'dispatch';
|
|
if (!modeHint) {
|
|
return;
|
|
}
|
|
if (deliveryMode === 'real_sms') {
|
|
modeHint.textContent = '当前为真实短信:需要已配置 ModemManager 发送能力;若发送失败,将展示错误分类与失败原因。';
|
|
modeHint.classList.add('mode-hint-warning');
|
|
return;
|
|
}
|
|
modeHint.textContent = '当前为通知转发:会按已启用通道分发,并写入事件留痕。';
|
|
modeHint.classList.remove('mode-hint-warning');
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const response = await fetch(url, options);
|
|
const text = await response.text();
|
|
let data;
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch (error) {
|
|
throw new Error(`响应不是合法 JSON: ${text}`);
|
|
}
|
|
if (!response.ok) {
|
|
const failure = new Error(data.detail || data.error || `HTTP ${response.status}`);
|
|
failure.payload = data;
|
|
failure.status = response.status;
|
|
throw failure;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function renderJson(element, payload) {
|
|
element.textContent = JSON.stringify(payload, null, 2);
|
|
}
|
|
|
|
function renderResult(payload, options = {}) {
|
|
const status = options.status || 'success';
|
|
const title = options.title || (status === 'failed' ? '发送失败' : '操作成功');
|
|
const rows = Object.entries(payload || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
|
if (!rows.length) {
|
|
renderJson(resultView, payload || {});
|
|
return;
|
|
}
|
|
resultView.innerHTML = `
|
|
<div class="result-panel">
|
|
<div class="result-title ${escapeHtml(status)}">${escapeHtml(title)}</div>
|
|
<div class="result-grid">
|
|
${rows
|
|
.map(
|
|
([label, value]) => `
|
|
<div class="result-item">
|
|
<span class="result-label">${escapeHtml(label)}</span>
|
|
<span class="result-value">${escapeHtml(formatText(value))}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateMetric(element, value, fallback = '-') {
|
|
element.textContent = value ?? fallback;
|
|
}
|
|
|
|
function renderSummaryItem(label, value) {
|
|
return `
|
|
<article class="summary-item">
|
|
<span class="summary-label">${escapeHtml(label)}</span>
|
|
<strong class="summary-value">${escapeHtml(formatText(value))}</strong>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderDashboard(payload) {
|
|
const { summary = {}, runtime = {} } = payload;
|
|
updateMetric(metricEventCount, summary.event_count ?? 0, '0');
|
|
updateMetric(metricManualSendCount, summary.manual_send_count ?? 0, '0');
|
|
updateMetric(metricFailureCount, summary.failure_count ?? 0, '0');
|
|
updateMetric(metricLastPhone, summary.last_sms_phone_number || '-');
|
|
|
|
dashboardSummary.innerHTML = [
|
|
renderSummaryItem('服务状态', runtime.status || 'unknown'),
|
|
renderSummaryItem('启动时间', runtime.started_at || '-'),
|
|
renderSummaryItem('请求次数', runtime.request_count ?? 0),
|
|
renderSummaryItem('配置文件', runtime.config_path || '未落盘'),
|
|
renderSummaryItem('运行模式', runtime.daemon?.mode || '-'),
|
|
renderSummaryItem('监听地址', `${runtime.daemon?.host || '-'}:${runtime.daemon?.port || '-'}`),
|
|
renderSummaryItem('日志级别', runtime.daemon?.log_level || '-'),
|
|
renderSummaryItem('设备名', runtime.forwarding?.device_name || '-'),
|
|
renderSummaryItem('启用通道', (runtime.notifications?.enabled_channels || []).join(', ') || '无'),
|
|
renderSummaryItem('短信源', runtime.sms_source?.kind || '-'),
|
|
renderSummaryItem('源路径', runtime.sms_source?.path || '-'),
|
|
renderSummaryItem('轮询间隔', runtime.sms_source?.poll_interval_seconds ?? '-'),
|
|
renderSummaryItem('当前事件数', summary.filtered_event_count ?? summary.event_count ?? 0),
|
|
].join('');
|
|
}
|
|
|
|
function getEventSearchText(event) {
|
|
return [
|
|
event.phone_number,
|
|
event.content,
|
|
event.detail,
|
|
event.error_category,
|
|
getDeliveryModeLabel(event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch')),
|
|
(event.channels || []).join(' '),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function getFilteredEvents(events = latestEvents) {
|
|
const keyword = (eventSearchInput?.value || '').trim().toLowerCase();
|
|
const resultFilter = eventResultFilter?.value || 'all';
|
|
return events.filter((event) => {
|
|
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
|
|
if (resultFilter !== 'all' && normalizedResult !== resultFilter) {
|
|
return false;
|
|
}
|
|
if (!keyword) {
|
|
return true;
|
|
}
|
|
return `${normalizedResult} ${getResultLabel(normalizedResult)} ${getEventSearchText(event)}`.includes(keyword);
|
|
});
|
|
}
|
|
|
|
function renderEvents(events = latestEvents) {
|
|
latestEvents = Array.isArray(events) ? events : [];
|
|
const filteredEvents = getFilteredEvents(latestEvents);
|
|
if (!filteredEvents.length) {
|
|
eventsList.innerHTML = '';
|
|
eventsEmpty.style.display = 'block';
|
|
eventsEmpty.textContent = latestEvents.length
|
|
? '没有匹配当前筛选条件的事件,换个关键字或筛选项试试。'
|
|
: '还没有事件,先去下面的短信测试台发一条试试。';
|
|
return;
|
|
}
|
|
|
|
eventsEmpty.style.display = 'none';
|
|
eventsList.innerHTML = filteredEvents
|
|
.map((event) => {
|
|
const channels = (event.channels || []).join(', ') || '无';
|
|
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
|
|
const statusClass = normalizedResult === 'success' ? 'status-success' : 'status-failed';
|
|
const deliveryMode = event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch');
|
|
const deliveryModeLabel = getDeliveryModeLabel(deliveryMode);
|
|
const errorCategory = formatText(event.error_category, '');
|
|
return `
|
|
<article class="event-item ${normalizedResult === 'failed' ? 'event-item-failed' : ''}">
|
|
<div class="event-topline">
|
|
<strong>${escapeHtml(formatText(event.phone_number))}</strong>
|
|
<div class="event-badges">
|
|
<span class="event-mode-badge">${escapeHtml(deliveryModeLabel)}</span>
|
|
<span class="event-status ${statusClass}">${escapeHtml(getResultLabel(normalizedResult))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="event-meta">时间:${escapeHtml(formatText(event.created_at))} · 来源:${escapeHtml(formatText(event.received_at))}</div>
|
|
<div class="event-body">${escapeHtml(formatText(event.content))}</div>
|
|
<div class="event-meta">通知数:${escapeHtml(formatText(event.notification_count, '0'))} · 通道:${escapeHtml(channels)}</div>
|
|
${errorCategory ? `<div class="event-failure-meta"><span class="event-error-badge">错误分类</span><span>${escapeHtml(errorCategory)}</span></div>` : ''}
|
|
<div class="event-detail">${escapeHtml(formatText(event.detail, '无补充说明'))}</div>
|
|
</article>
|
|
`;
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
async function loadStatus() {
|
|
const status = await fetchJson('/api/system/status');
|
|
renderJson(statusView, status);
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const settings = await fetchJson('/api/settings');
|
|
settingsEditor.value = JSON.stringify(settings, null, 2);
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
const payload = await fetchJson('/api/dashboard');
|
|
renderDashboard(payload);
|
|
renderEvents(payload.latest_events || []);
|
|
}
|
|
|
|
async function loadEvents() {
|
|
const payload = await fetchJson('/api/events');
|
|
renderEvents(payload.events || []);
|
|
}
|
|
|
|
function bindEventFilters() {
|
|
eventSearchInput?.addEventListener('input', () => renderEvents(latestEvents));
|
|
eventResultFilter?.addEventListener('change', () => renderEvents(latestEvents));
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([loadDashboard(), loadStatus(), loadSettings()]);
|
|
}
|
|
|
|
async function saveSettings() {
|
|
const parsed = JSON.parse(settingsEditor.value || '{}');
|
|
const payload = await fetchJson('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(parsed),
|
|
});
|
|
renderResult(
|
|
{
|
|
操作: '保存配置',
|
|
状态: '成功',
|
|
说明: '配置已通过校验并写入当前运行实例。',
|
|
},
|
|
{ title: '配置保存成功' }
|
|
);
|
|
await refreshAll();
|
|
}
|
|
|
|
async function sendSms(event) {
|
|
event.preventDefault();
|
|
const formData = new FormData(smsForm);
|
|
const deliveryMode = formData.get('delivery_mode') || 'dispatch';
|
|
const delivery_mode_label = getDeliveryModeLabel(deliveryMode);
|
|
try {
|
|
const payload = await fetchJson('/api/sms/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
phone_number: formData.get('phone_number'),
|
|
content: formData.get('content'),
|
|
delivery_mode: deliveryMode === 'real_sms' ? 'real_sms' : 'dispatch',
|
|
}),
|
|
});
|
|
renderResult(
|
|
{
|
|
操作: '发送测试短信',
|
|
状态: '成功',
|
|
delivery_mode_label,
|
|
手机号: payload.phone_number,
|
|
来源: payload.received_at,
|
|
MessagePath: payload.message_path,
|
|
ModemPath: payload.modem_path,
|
|
},
|
|
{ title: `${delivery_mode_label}发送成功` }
|
|
);
|
|
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
|
|
} catch (error) {
|
|
renderResult(
|
|
{
|
|
操作: '发送测试短信',
|
|
状态: '发送失败',
|
|
delivery_mode_label,
|
|
错误分类: error.payload?.error_category || error.payload?.error || 'unknown',
|
|
失败原因: error.payload?.detail || error.message,
|
|
HTTP状态: error.status || '-',
|
|
},
|
|
{ status: 'failed', title: `${delivery_mode_label}发送失败` }
|
|
);
|
|
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
|
|
}
|
|
}
|
|
|
|
async function boot() {
|
|
try {
|
|
updateModeHint();
|
|
bindEventFilters();
|
|
await refreshAll();
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
refreshDashboardButton.addEventListener('click', async () => {
|
|
try {
|
|
await loadDashboard();
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
refreshStatusButton.addEventListener('click', async () => {
|
|
try {
|
|
await loadStatus();
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
refreshEventsButton.addEventListener('click', async () => {
|
|
try {
|
|
await loadEvents();
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
reloadSettingsButton.addEventListener('click', async () => {
|
|
try {
|
|
await loadSettings();
|
|
renderResult(
|
|
{
|
|
操作: '重载配置',
|
|
状态: '成功',
|
|
说明: '编辑器已同步当前运行配置。',
|
|
},
|
|
{ title: '配置已重载' }
|
|
);
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
saveSettingsButton.addEventListener('click', async () => {
|
|
try {
|
|
await saveSettings();
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
if (deliveryModeSelect) {
|
|
deliveryModeSelect.addEventListener('change', updateModeHint);
|
|
}
|
|
|
|
smsForm.addEventListener('submit', async (event) => {
|
|
try {
|
|
await sendSms(event);
|
|
} catch (error) {
|
|
resultView.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
boot();
|