fix: optimize summary count query and refine row keys for external api logs
This commit is contained in:
@@ -85,13 +85,15 @@ def get_external_api_log_summary(
|
|||||||
slow_ms: Optional[int] = Query(None),
|
slow_ms: Optional[int] = Query(None),
|
||||||
hours: int = Query(24, ge=1, le=720),
|
hours: int = Query(24, ge=1, le=720),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return aggregated summary grouped by direction + target + method + path.
|
"""Return aggregated summary grouped by direction + target + method + path.
|
||||||
|
|
||||||
- hours: time window (default 24, max 720). None means no time filter.
|
- hours: time window (default 24, max 720).
|
||||||
- limit: max rows returned (default 50, max 200).
|
- limit: max rows returned (default 50, max 200).
|
||||||
|
- offset: pagination offset (default 0).
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
|
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
|
||||||
@@ -107,7 +109,7 @@ def get_external_api_log_summary(
|
|||||||
ExternalApiLog.method,
|
ExternalApiLog.method,
|
||||||
ExternalApiLog.path,
|
ExternalApiLog.path,
|
||||||
).order_by(func.sum(ExternalApiLog.duration_ms).desc())
|
).order_by(func.sum(ExternalApiLog.duration_ms).desc())
|
||||||
q = q.limit(limit)
|
q = q.offset(offset).limit(limit)
|
||||||
rows = q.all()
|
rows = q.all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -129,6 +131,42 @@ def get_external_api_log_summary(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary/count")
|
||||||
|
def count_external_api_log_summary(
|
||||||
|
slow_ms: Optional[int] = Query(None),
|
||||||
|
hours: int = Query(24, ge=1, le=720),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the number of distinct aggregated summary groups.
|
||||||
|
|
||||||
|
Uses the same grouping as /summary, returns the count of groups
|
||||||
|
(not the count of raw log rows).
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
|
||||||
|
|
||||||
|
q = db.query(
|
||||||
|
ExternalApiLog.direction,
|
||||||
|
ExternalApiLog.target_type,
|
||||||
|
ExternalApiLog.target_id,
|
||||||
|
ExternalApiLog.target_name,
|
||||||
|
ExternalApiLog.method,
|
||||||
|
ExternalApiLog.path,
|
||||||
|
)
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
q = q.filter(ExternalApiLog.created_at >= cutoff)
|
||||||
|
q = q.group_by(
|
||||||
|
ExternalApiLog.direction,
|
||||||
|
ExternalApiLog.target_type,
|
||||||
|
ExternalApiLog.target_id,
|
||||||
|
ExternalApiLog.target_name,
|
||||||
|
ExternalApiLog.method,
|
||||||
|
ExternalApiLog.path,
|
||||||
|
)
|
||||||
|
return {"total": q.count()}
|
||||||
|
|
||||||
|
|
||||||
def _summary_cols(slow_threshold: int) -> list:
|
def _summary_cols(slow_threshold: int) -> list:
|
||||||
return [
|
return [
|
||||||
ExternalApiLog.direction,
|
ExternalApiLog.direction,
|
||||||
|
|||||||
@@ -625,13 +625,13 @@ def test_route_summary_hours_window(session):
|
|||||||
|
|
||||||
# hours=1 → only recent
|
# hours=1 → only recent
|
||||||
result = get_external_api_log_summary(
|
result = get_external_api_log_summary(
|
||||||
slow_ms=None, hours=1, limit=50, db=session, _=None,
|
slow_ms=None, hours=1, limit=50, offset=0, db=session, _=None,
|
||||||
)
|
)
|
||||||
assert len(result) == 1 and result[0].path == "/recent"
|
assert len(result) == 1 and result[0].path == "/recent"
|
||||||
|
|
||||||
# hours=72 → both
|
# hours=72 → both
|
||||||
result = get_external_api_log_summary(
|
result = get_external_api_log_summary(
|
||||||
slow_ms=None, hours=72, limit=50, db=session, _=None,
|
slow_ms=None, hours=72, limit=50, offset=0, db=session, _=None,
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
@@ -648,7 +648,7 @@ def test_route_summary_limit(session):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
result = get_external_api_log_summary(
|
result = get_external_api_log_summary(
|
||||||
slow_ms=None, hours=24, limit=3, db=session, _=None,
|
slow_ms=None, hours=24, limit=3, offset=0, db=session, _=None,
|
||||||
)
|
)
|
||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
|
|
||||||
@@ -670,8 +670,94 @@ def test_route_summary_slow_threshold(session):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
result = get_external_api_log_summary(
|
result = get_external_api_log_summary(
|
||||||
slow_ms=1000, hours=24, limit=50, db=session, _=None,
|
slow_ms=1000, hours=24, limit=50, offset=0, db=session, _=None,
|
||||||
)
|
)
|
||||||
slow_paths = [r for r in result if r.slow_count > 0]
|
slow_paths = [r for r in result if r.slow_count > 0]
|
||||||
assert len(slow_paths) == 1
|
assert len(slow_paths) == 1
|
||||||
assert slow_paths[0].path == "/slow"
|
assert slow_paths[0].path == "/slow"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_summary_offset_pagination(session):
|
||||||
|
"""get_external_api_log_summary respects offset parameter."""
|
||||||
|
from app.routers.external_api_logs import get_external_api_log_summary
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for i in range(10):
|
||||||
|
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||||
|
method="GET", path=f"/path-{i}", url_host="h",
|
||||||
|
status_code=200, success=True, duration_ms=50 + i, created_at=now))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Page 1: first 4
|
||||||
|
page1 = get_external_api_log_summary(
|
||||||
|
slow_ms=None, hours=24, limit=4, offset=0, db=session, _=None,
|
||||||
|
)
|
||||||
|
assert len(page1) == 4
|
||||||
|
assert page1[0].path == "/path-9" # sorted by sum(duration_ms) desc
|
||||||
|
|
||||||
|
# Page 2: next 4
|
||||||
|
page2 = get_external_api_log_summary(
|
||||||
|
slow_ms=None, hours=24, limit=4, offset=4, db=session, _=None,
|
||||||
|
)
|
||||||
|
assert len(page2) == 4
|
||||||
|
assert page2[0].path == "/path-5"
|
||||||
|
|
||||||
|
# Page 3: last 2
|
||||||
|
page3 = get_external_api_log_summary(
|
||||||
|
slow_ms=None, hours=24, limit=4, offset=8, db=session, _=None,
|
||||||
|
)
|
||||||
|
assert len(page3) == 2
|
||||||
|
|
||||||
|
# Beyond end
|
||||||
|
page4 = get_external_api_log_summary(
|
||||||
|
slow_ms=None, hours=24, limit=4, offset=20, db=session, _=None,
|
||||||
|
)
|
||||||
|
assert len(page4) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_summary_count(session):
|
||||||
|
"""count_external_api_log_summary returns group count, not raw row count."""
|
||||||
|
from app.routers.external_api_logs import count_external_api_log_summary
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# 3 calls to same path (should be 1 group)
|
||||||
|
for i in range(3):
|
||||||
|
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||||
|
method="GET", path="/grouped", url_host="h",
|
||||||
|
status_code=200, success=True, duration_ms=100, created_at=now))
|
||||||
|
# 2 calls to different path (another group)
|
||||||
|
for i in range(2):
|
||||||
|
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||||
|
method="POST", path="/other", url_host="h",
|
||||||
|
status_code=201, success=True, duration_ms=200, created_at=now))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Should be 2 groups, not 5 raw rows
|
||||||
|
result = count_external_api_log_summary(
|
||||||
|
slow_ms=None, hours=24, db=session, _=None,
|
||||||
|
)
|
||||||
|
assert result == {"total": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_summary_count_respects_hours(session):
|
||||||
|
"""count_external_api_log_summary respects hours filter."""
|
||||||
|
from app.routers.external_api_logs import count_external_api_log_summary
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||||
|
method="GET", path="/recent", url_host="h",
|
||||||
|
status_code=200, success=True, duration_ms=50,
|
||||||
|
created_at=now - timedelta(minutes=30)))
|
||||||
|
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||||
|
method="GET", path="/old", url_host="h",
|
||||||
|
status_code=200, success=True, duration_ms=50,
|
||||||
|
created_at=now - timedelta(hours=48)))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# hours=1 → 1 group
|
||||||
|
r1 = count_external_api_log_summary(slow_ms=None, hours=1, db=session, _=None)
|
||||||
|
assert r1 == {"total": 1}
|
||||||
|
|
||||||
|
# hours=72 → 2 groups
|
||||||
|
r2 = count_external_api_log_summary(slow_ms=None, hours=72, db=session, _=None)
|
||||||
|
assert r2 == {"total": 2}
|
||||||
|
|||||||
@@ -547,8 +547,10 @@ export const externalApiLogsApi = {
|
|||||||
status_code?: number
|
status_code?: number
|
||||||
path?: string
|
path?: string
|
||||||
}) => api.get<{ total: number }>('/api/external-api-logs/count', { params }),
|
}) => api.get<{ total: number }>('/api/external-api-logs/count', { params }),
|
||||||
summary: (params?: { slow_ms?: number }) =>
|
summary: (params?: { slow_ms?: number; hours?: number; limit?: number; offset?: number }) =>
|
||||||
api.get<ExternalApiLogSummaryItem[]>('/api/external-api-logs/summary', { params }),
|
api.get<ExternalApiLogSummaryItem[]>('/api/external-api-logs/summary', { params }),
|
||||||
|
summaryCount: (params?: { slow_ms?: number; hours?: number }) =>
|
||||||
|
api.get<{ total: number }>('/api/external-api-logs/summary/count', { params }),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ——— Auth Capture ———
|
// ——— Auth Capture ———
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="code-font">
|
<tbody class="code-font">
|
||||||
<tr v-for="row in summary" :key="row.path + row.method + row.direction" class="hover-row">
|
<tr v-for="row in summary" :key="`${row.path}_${row.method}_${row.direction}_${row.target_type}_${row.target_id ?? row.target_name ?? ''}`" class="hover-row">
|
||||||
<td>
|
<td>
|
||||||
<span class="direction-tag" :class="row.direction">
|
<span class="direction-tag" :class="row.direction">
|
||||||
<svg v-if="row.direction === 'upstream'" class="direction-icon" viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px; display: inline-block; vertical-align: middle;"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
|
<svg v-if="row.direction === 'upstream'" class="direction-icon" viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px; display: inline-block; vertical-align: middle;"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
|
||||||
@@ -90,6 +90,35 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 慢接口汇总分页器 -->
|
||||||
|
<div class="pagination-container" v-if="summaryTotalRecords > 0">
|
||||||
|
<div class="pagination-left">
|
||||||
|
<span>共 <span class="highlight-text">{{ summaryTotalRecords }}</span> 个分组</span>
|
||||||
|
<div class="page-size-selector">
|
||||||
|
<span>每页</span>
|
||||||
|
<select v-model="summaryPageSize" @change="handleSummaryPageSizeChange" class="page-size-select">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span>条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-right">
|
||||||
|
<button :disabled="summaryOffset === 0 || summaryLoading" @click="summaryPrevPage" class="page-btn">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
<span>上一页</span>
|
||||||
|
</button>
|
||||||
|
<span class="page-indicator">第 {{ summaryCurrentPage }} 页</span>
|
||||||
|
<button :disabled="!summaryHasNextPage || summaryLoading" @click="summaryNextPage" class="page-btn">
|
||||||
|
<span>下一页</span>
|
||||||
|
<el-icon><ArrowRight /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 接口调用记录 -->
|
<!-- 接口调用记录 -->
|
||||||
@@ -347,6 +376,14 @@ const pageSize = ref(10)
|
|||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const totalRecords = ref(0)
|
const totalRecords = ref(0)
|
||||||
const currentPage = computed(() => Math.floor(offset.value / pageSize.value) + 1)
|
const currentPage = computed(() => Math.floor(offset.value / pageSize.value) + 1)
|
||||||
|
|
||||||
|
// Slow summary pagination state (independent from list pagination)
|
||||||
|
const summaryOffset = ref(0)
|
||||||
|
const summaryPageSize = ref(10)
|
||||||
|
const summaryTotalRecords = ref(0)
|
||||||
|
const summaryHasNextPage = computed(() => summaryOffset.value + summaryPageSize.value < summaryTotalRecords.value)
|
||||||
|
const summaryCurrentPage = computed(() => Math.floor(summaryOffset.value / summaryPageSize.value) + 1)
|
||||||
|
|
||||||
const slowThreshold = 3000
|
const slowThreshold = 3000
|
||||||
|
|
||||||
// In-app Notification System
|
// In-app Notification System
|
||||||
@@ -489,13 +526,36 @@ async function loadList(silent = false) {
|
|||||||
async function loadSummary(silent = false) {
|
async function loadSummary(silent = false) {
|
||||||
if (!silent) summaryLoading.value = true
|
if (!silent) summaryLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await externalApiLogsApi.summary({ slow_ms: slowThreshold })
|
const [res, countRes] = await Promise.all([
|
||||||
|
externalApiLogsApi.summary({
|
||||||
|
slow_ms: slowThreshold,
|
||||||
|
limit: summaryPageSize.value,
|
||||||
|
offset: summaryOffset.value,
|
||||||
|
}),
|
||||||
|
externalApiLogsApi.summaryCount({ slow_ms: slowThreshold }),
|
||||||
|
])
|
||||||
summary.value = res.data
|
summary.value = res.data
|
||||||
|
summaryTotalRecords.value = countRes.data.total
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) summaryLoading.value = false
|
if (!silent) summaryLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summaryPrevPage() {
|
||||||
|
summaryOffset.value = Math.max(0, summaryOffset.value - summaryPageSize.value)
|
||||||
|
loadSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryNextPage() {
|
||||||
|
summaryOffset.value += summaryPageSize.value
|
||||||
|
loadSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSummaryPageSizeChange() {
|
||||||
|
summaryOffset.value = 0
|
||||||
|
loadSummary()
|
||||||
|
}
|
||||||
|
|
||||||
function handleFilterChange() {
|
function handleFilterChange() {
|
||||||
offset.value = 0
|
offset.value = 0
|
||||||
loadList()
|
loadList()
|
||||||
|
|||||||
Reference in New Issue
Block a user