Refactor store dwell alert management API and dwell engine

- Updated argument parsing in manage_api.py to include new threshold parameters.
- Enhanced _config_payload to include thresholds and webhook configurations.
- Modified _build_summary to track queue metrics and adjust alert reporting.
- Refactored DwellEngine to utilize queue thresholds for alerting and reporting.
- Added queue metrics calculations and status change tracking in dwell_engine.py.
- Updated notifier.py to support posting JSON events to webhooks.
- Adjusted example configuration to reflect new threshold parameters.
- Enhanced Docker entrypoint script for better process management.
- Updated tests to cover new queue metrics and thresholds.
- Improved ManagedServiceDetail and ManagedServices Vue components to display queue metrics.
This commit is contained in:
2026-05-09 11:35:55 +08:00
parent be5014c582
commit ea618fd674
26 changed files with 1605 additions and 117 deletions

View File

@@ -5,15 +5,13 @@
<el-button link @click="router.push('/managed-services')">
返回列表
</el-button>
<h3 class="page-title">{{ service?.display_name || route.params.id }}</h3>
<h3 class="page-title">
{{ service?.display_name || route.params.id }}
</h3>
</div>
<div class="toolbar-actions">
<el-button @click="loadDetail" :loading="loading">刷新</el-button>
<el-button
type="warning"
@click="handleRestart"
:loading="restarting"
>
<el-button type="warning" @click="handleRestart" :loading="restarting">
重启容器
</el-button>
<el-button type="primary" @click="openEdit">编辑 RTSP</el-button>
@@ -96,6 +94,36 @@
<div v-if="service?.summary">
<p class="summary-headline">{{ service.summary.headline }}</p>
<div v-if="hasQueueMetrics" class="queue-summary-panel">
<div class="queue-summary-card queue-summary-card--level">
<span class="queue-summary-label">排队等级</span>
<div class="queue-summary-main">
<el-tag
:type="queueLevelType(queueSummaryMetrics.queue_level)"
>
{{ queueLevelText(queueSummaryMetrics.queue_level) }}
</el-tag>
</div>
</div>
<div class="queue-summary-card">
<span class="queue-summary-label">超 5 分钟人数</span>
<span class="queue-summary-value">
{{ queueSummaryMetrics.over_threshold_count ?? 0 }}
</span>
</div>
<div class="queue-summary-card">
<span class="queue-summary-label">低于 5 分钟人数</span>
<span class="queue-summary-value">
{{ queueSummaryMetrics.under_threshold_count ?? 0 }}
</span>
</div>
<div class="queue-summary-card">
<span class="queue-summary-label">状态变化</span>
<span class="queue-summary-value queue-summary-value--text">
{{ queueChangeText(queueSummaryMetrics.status_change) }}
</span>
</div>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item
v-for="item in summaryEntries"
@@ -150,6 +178,32 @@
</template>
</el-table-column>
<el-table-column label="排队等级" width="120">
<template #default="{ row }">
<el-tag :type="queueLevelType(row.queue_level)">
{{ queueLevelText(row.queue_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="over_threshold_count"
label="超 5 分钟人数"
width="130"
/>
<el-table-column
prop="under_threshold_count"
label="低于 5 分钟人数"
width="130"
/>
<el-table-column label="状态变化" min-width="120">
<template #default="{ row }">
{{ queueChangeText(row.status_change) }}
</template>
</el-table-column>
<template v-if="isStoreDwell">
<el-table-column
prop="active_customer_count"
@@ -181,7 +235,9 @@
<el-table-column prop="total_people" label="人流总数" width="110" />
<el-table-column label="年龄分布" min-width="180">
<template #default="{ row }">
<span class="break-all">{{ formatCounts(row.age_counts) }}</span>
<span class="break-all">{{
formatCounts(row.age_counts)
}}</span>
</template>
</el-table-column>
<el-table-column label="男女人数" min-width="160">
@@ -394,7 +450,11 @@ const previewFile = async (file) => {
previewLines.value = [];
try {
const res = await getManagedServicePreview(serviceId.value, file.path, 2000);
const res = await getManagedServicePreview(
serviceId.value,
file.path,
2000,
);
previewLines.value = res.lines || [];
} catch (error) {
ElMessage.error(error.message || "加载文件预览失败");
@@ -404,14 +464,23 @@ const previewFile = async (file) => {
};
const downloadFile = (file) => {
window.open(getManagedServiceDownloadUrl(serviceId.value, file.path), "_blank");
window.open(
getManagedServiceDownloadUrl(serviceId.value, file.path),
"_blank",
);
};
const summaryEntries = computed(() => {
const metrics = service.value?.summary?.metrics || {};
return Object.entries(metrics)
.filter(
([key]) => key !== "recent_window_stats" && key !== "all_window_stats",
([key]) =>
key !== "recent_window_stats" &&
key !== "all_window_stats" &&
key !== "queue_level" &&
key !== "over_threshold_count" &&
key !== "under_threshold_count" &&
key !== "status_change",
)
.map(([key, value]) => ({
key,
@@ -419,6 +488,20 @@ const summaryEntries = computed(() => {
}));
});
const queueSummaryMetrics = computed(
() => service.value?.summary?.metrics || {},
);
const hasQueueMetrics = computed(() => {
const metrics = queueSummaryMetrics.value;
if (!metrics || typeof metrics !== "object") {
return false;
}
return ["queue_level", "over_threshold_count", "under_threshold_count"].some(
(key) => key in metrics,
);
});
const recentWindowStats = computed(() => {
const stats = service.value?.summary?.metrics?.recent_window_stats;
return Array.isArray(stats) ? stats : [];
@@ -483,6 +566,35 @@ const formatCounts = (value) => {
return entries.map(([key, count]) => `${key}: ${count}`).join(", ");
};
const queueLevelText = (value) => {
const map = {
crowded: "人多",
normal: "正常",
few: "人少",
};
return map[value] || value || "-";
};
const queueLevelType = (value) => {
const map = {
crowded: "danger",
normal: "warning",
few: "success",
};
return map[value] || "info";
};
const queueChangeText = (value) => {
const map = {
initial: "初始窗口",
unchanged: "无变化",
queue_increased: "人数变多",
queue_decreased: "人数变少",
queue_normalized: "人数变正常",
};
return map[value] || value || "-";
};
const formatTime = (value) => {
if (!value) {
return "-";
@@ -578,6 +690,50 @@ watch(serviceId, loadDetail, { immediate: true });
font-weight: 600;
}
.queue-summary-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.queue-summary-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
background: linear-gradient(180deg, #fbfbf5 0%, #f6f7f1 100%);
}
.queue-summary-card--level {
justify-content: space-between;
}
.queue-summary-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.queue-summary-main {
display: flex;
align-items: center;
min-height: 32px;
}
.queue-summary-value {
color: var(--el-text-color-primary);
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.queue-summary-value--text {
font-size: 15px;
line-height: 1.4;
}
.error-tags {
display: flex;
flex-direction: column;
@@ -617,4 +773,16 @@ watch(serviceId, loadDetail, { immediate: true });
.break-all {
word-break: break-all;
}
@media (max-width: 1200px) {
.queue-summary-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.queue-summary-panel {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -46,13 +46,47 @@
<span class="section-label">摘要</span>
<div class="summary-text">
{{
service.summary?.headline ||
service.result_error ||
"暂无摘要"
service.summary?.headline || service.result_error || "暂无摘要"
}}
</div>
</div>
<div
v-if="hasQueueMetrics(service.summary?.metrics)"
class="service-section queue-section"
>
<span class="section-label">排队概览</span>
<div class="queue-metrics-grid">
<div class="queue-metric-item queue-metric-item--highlight">
<span class="queue-metric-label">排队等级</span>
<el-tag
size="small"
:type="queueLevelType(service.summary.metrics.queue_level)"
>
{{ queueLevelText(service.summary.metrics.queue_level) }}
</el-tag>
</div>
<div class="queue-metric-item">
<span class="queue-metric-label"> 5 分钟</span>
<span class="queue-metric-value">
{{ service.summary.metrics.over_threshold_count ?? 0 }}
</span>
</div>
<div class="queue-metric-item">
<span class="queue-metric-label">低于 5 分钟</span>
<span class="queue-metric-value">
{{ service.summary.metrics.under_threshold_count ?? 0 }}
</span>
</div>
<div class="queue-metric-item">
<span class="queue-metric-label">状态变化</span>
<span class="queue-metric-value queue-metric-value--text">
{{ queueChangeText(service.summary.metrics.status_change) }}
</span>
</div>
</div>
</div>
<div
v-if="
service.config_error ||
@@ -236,6 +270,44 @@ const formatTime = (value) => {
return date.toLocaleString();
};
const hasQueueMetrics = (metrics) => {
if (!metrics || typeof metrics !== "object") {
return false;
}
return ["queue_level", "over_threshold_count", "under_threshold_count"].some(
(key) => key in metrics,
);
};
const queueLevelText = (value) => {
const map = {
crowded: "人多",
normal: "正常",
few: "人少",
};
return map[value] || value || "-";
};
const queueLevelType = (value) => {
const map = {
crowded: "danger",
normal: "warning",
few: "success",
};
return map[value] || "info";
};
const queueChangeText = (value) => {
const map = {
initial: "初始窗口",
unchanged: "无变化",
queue_increased: "人数变多",
queue_decreased: "人数变少",
queue_normalized: "人数变正常",
};
return map[value] || value || "-";
};
onMounted(loadServices);
</script>
@@ -297,6 +369,49 @@ onMounted(loadServices);
word-break: break-word;
}
.queue-section {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: linear-gradient(180deg, #fcfcf7 0%, #f7f9f4 100%);
}
.queue-metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.queue-metric-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
}
.queue-metric-item--highlight {
border: 1px solid rgba(140, 120, 56, 0.16);
}
.queue-metric-label {
color: #909399;
font-size: 12px;
}
.queue-metric-value {
color: #303133;
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.queue-metric-value--text {
font-size: 14px;
line-height: 1.4;
}
.service-errors {
display: flex;
gap: 8px;
@@ -309,4 +424,10 @@ onMounted(loadServices);
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.queue-metrics-grid {
grid-template-columns: 1fr;
}
}
</style>