mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行, 明细列表保留检索;排行榜改为紧凑列表样式。
This commit is contained in:
@@ -21,6 +21,10 @@ sidebar: false
|
|||||||
|
|
||||||
## [unreleased]
|
## [unreleased]
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
|
||||||
|
|
||||||
## [v3.4.0] - 2026-07-18
|
## [v3.4.0] - 2026-07-18
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
+164
@@ -5408,6 +5408,87 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/access-logs/overview": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-observability"
|
||||||
|
],
|
||||||
|
"summary": "获取访问日志概览",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "节点 ID",
|
||||||
|
"name": "node_id",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求 Host",
|
||||||
|
"name": "host",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "统计时间范围(小时)",
|
||||||
|
"name": "hours",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "访问日志概览",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "无权限或不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "内部错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/acme-accounts/default": {
|
"/api/v1/d/acme-accounts/default": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -16148,6 +16229,89 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"observability.AccessLogOverview": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hours": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewSummary"
|
||||||
|
},
|
||||||
|
"top_hosts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewTrends"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewMetricPoint": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bucket_started_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bandwidth_served": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_requests": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_visits": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewTrends": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bandwidth": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visits": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"observability.AccessLogView": {
|
"observability.AccessLogView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -5401,6 +5401,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/access-logs/overview": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-observability"
|
||||||
|
],
|
||||||
|
"summary": "获取访问日志概览",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "节点 ID",
|
||||||
|
"name": "node_id",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求 Host",
|
||||||
|
"name": "host",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "统计时间范围(小时)",
|
||||||
|
"name": "hours",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "访问日志概览",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "无权限或不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "内部错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/acme-accounts/default": {
|
"/api/v1/d/acme-accounts/default": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -16141,6 +16222,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"observability.AccessLogOverview": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hours": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewSummary"
|
||||||
|
},
|
||||||
|
"top_hosts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.DistributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewTrends"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewMetricPoint": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bucket_started_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bandwidth_served": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_requests": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_visits": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observability.AccessLogOverviewTrends": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bandwidth": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visits": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"observability.AccessLogView": {
|
"observability.AccessLogView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1937,6 +1937,60 @@ definitions:
|
|||||||
total_record:
|
total_record:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
observability.AccessLogOverview:
|
||||||
|
properties:
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
hours:
|
||||||
|
type: integer
|
||||||
|
summary:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverviewSummary'
|
||||||
|
top_hosts:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.DistributionItem'
|
||||||
|
type: array
|
||||||
|
top_ips:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.DistributionItem'
|
||||||
|
type: array
|
||||||
|
top_paths:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.DistributionItem'
|
||||||
|
type: array
|
||||||
|
trends:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverviewTrends'
|
||||||
|
type: object
|
||||||
|
observability.AccessLogOverviewMetricPoint:
|
||||||
|
properties:
|
||||||
|
bucket_started_at:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
observability.AccessLogOverviewSummary:
|
||||||
|
properties:
|
||||||
|
bandwidth_served:
|
||||||
|
type: integer
|
||||||
|
total_requests:
|
||||||
|
type: integer
|
||||||
|
total_visits:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
observability.AccessLogOverviewTrends:
|
||||||
|
properties:
|
||||||
|
bandwidth:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
|
||||||
|
type: array
|
||||||
|
requests:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
|
||||||
|
type: array
|
||||||
|
visits:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
observability.AccessLogView:
|
observability.AccessLogView:
|
||||||
properties:
|
properties:
|
||||||
host:
|
host:
|
||||||
@@ -7160,6 +7214,55 @@ paths:
|
|||||||
summary: 获取访问日志 IP 趋势
|
summary: 获取访问日志 IP 趋势
|
||||||
tags:
|
tags:
|
||||||
- openflare-observability
|
- openflare-observability
|
||||||
|
/api/v1/d/access-logs/overview:
|
||||||
|
get:
|
||||||
|
description: 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限
|
||||||
|
parameters:
|
||||||
|
- description: 节点 ID
|
||||||
|
in: query
|
||||||
|
name: node_id
|
||||||
|
type: string
|
||||||
|
- description: 请求 Host
|
||||||
|
in: query
|
||||||
|
name: host
|
||||||
|
type: string
|
||||||
|
- description: 统计时间范围(小时)
|
||||||
|
in: query
|
||||||
|
name: hours
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 访问日志概览
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/observability.AccessLogOverview'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: 参数错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"404":
|
||||||
|
description: 无权限或不存在
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"500":
|
||||||
|
description: 内部错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取访问日志概览
|
||||||
|
tags:
|
||||||
|
- openflare-observability
|
||||||
/api/v1/d/acme-accounts/default:
|
/api/v1/d/acme-accounts/default:
|
||||||
get:
|
get:
|
||||||
description: 返回系统默认 ACME 账号配置,需要管理员权限
|
description: 返回系统默认 ACME 账号配置,需要管理员权限
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { AccessLogTab, SearchDraft } from './access-log-utils';
|
import type { SearchDraft } from './access-log-utils';
|
||||||
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
|
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
|
||||||
|
|
||||||
interface AccessLogFiltersProps {
|
interface AccessLogFiltersProps {
|
||||||
tab: AccessLogTab;
|
|
||||||
draft: SearchDraft;
|
draft: SearchDraft;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
onDraftChange: (draft: SearchDraft) => void;
|
onDraftChange: (draft: SearchDraft) => void;
|
||||||
@@ -25,7 +24,6 @@ interface AccessLogFiltersProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccessLogFilters({
|
export function AccessLogFilters({
|
||||||
tab,
|
|
||||||
draft,
|
draft,
|
||||||
pageSize,
|
pageSize,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
@@ -33,8 +31,6 @@ export function AccessLogFilters({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onReset,
|
onReset,
|
||||||
}: AccessLogFiltersProps) {
|
}: AccessLogFiltersProps) {
|
||||||
const showPath = tab === 'list' || tab === 'folds';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
@@ -81,24 +77,18 @@ export function AccessLogFilters({
|
|||||||
className='h-9 text-xs'
|
className='h-9 text-xs'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showPath ? (
|
<div className='space-y-1.5'>
|
||||||
<div className='space-y-1.5'>
|
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
|
||||||
<p className='text-xs font-medium text-muted-foreground'>
|
<Input
|
||||||
请求路径
|
value={draft.path}
|
||||||
</p>
|
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
|
||||||
<Input
|
onKeyDown={(e) => {
|
||||||
value={draft.path}
|
if (e.key === 'Enter') onSearch();
|
||||||
onChange={(e) =>
|
}}
|
||||||
onDraftChange({ ...draft, path: e.target.value })
|
placeholder='按路径搜索'
|
||||||
}
|
className='h-9 text-xs'
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === 'Enter') onSearch();
|
</div>
|
||||||
}}
|
|
||||||
placeholder='按路径搜索'
|
|
||||||
className='h-9 text-xs'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
|
export type AccessLogTab = 'overview' | 'list';
|
||||||
|
|
||||||
export type SearchDraft = {
|
export type SearchDraft = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -7,8 +7,20 @@ export type SearchDraft = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OverviewRangeHours = 24 | 168 | 360 | 720;
|
||||||
|
|
||||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||||
|
|
||||||
|
export const OVERVIEW_RANGE_OPTIONS: {
|
||||||
|
value: OverviewRangeHours;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: 24, label: '24 小时' },
|
||||||
|
{ value: 168, label: '7 天' },
|
||||||
|
{ value: 360, label: '15 天' },
|
||||||
|
{ value: 720, label: '30 天' },
|
||||||
|
];
|
||||||
|
|
||||||
export const DETAIL_SORT_OPTIONS = [
|
export const DETAIL_SORT_OPTIONS = [
|
||||||
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
||||||
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
||||||
@@ -18,20 +30,6 @@ export const DETAIL_SORT_OPTIONS = [
|
|||||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FOLD_SORT_OPTIONS = [
|
|
||||||
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
|
|
||||||
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
|
|
||||||
{ value: 'request_count:desc', label: '访问次数从高到低' },
|
|
||||||
{ value: 'request_count:asc', label: '访问次数从低到高' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const IP_SORT_OPTIONS = [
|
|
||||||
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
|
|
||||||
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
|
|
||||||
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
|
|
||||||
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function parseSortValue(value: string) {
|
export function parseSortValue(value: string) {
|
||||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||||
return {
|
return {
|
||||||
@@ -46,3 +44,23 @@ export function formatCompactNumber(value: number) {
|
|||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatOverviewRangeHint(hours: number) {
|
||||||
|
if (hours <= 24) return '近 24 小时';
|
||||||
|
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
|
||||||
|
return `近 ${hours} 小时`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOverviewTrendLabel(value: string, hours: number) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0');
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||||
|
if (hours <= 24) {
|
||||||
|
return `${hour}:00`;
|
||||||
|
}
|
||||||
|
return `${month}/${day} ${hour}:00`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { AccessLogList } from '@/lib/services/openflare';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
|
||||||
|
|
||||||
|
function PaginationBar({
|
||||||
|
page,
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||||
|
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={loading || page <= 0}
|
||||||
|
onClick={onPrev}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={loading || !hasMore}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailTab({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
page,
|
||||||
|
detailSort,
|
||||||
|
onDetailSortChange,
|
||||||
|
onRetry,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
isFetching,
|
||||||
|
}: {
|
||||||
|
data?: AccessLogList;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
page: number;
|
||||||
|
detailSort: string;
|
||||||
|
onDetailSortChange: (value: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
isFetching: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||||
|
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||||
|
<p className='text-sm font-medium'>日志明细</p>
|
||||||
|
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
||||||
|
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className='p-4'>
|
||||||
|
<ErrorInline
|
||||||
|
message={error.message || '加载失败'}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<LoadingStateWithBorder />
|
||||||
|
) : (data?.items ?? []).length === 0 ? (
|
||||||
|
<EmptyStateWithBorder title='暂无访问日志' />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className='bg-muted/40'>
|
||||||
|
<TableRow className='border-dashed hover:bg-transparent'>
|
||||||
|
<TableHead className='text-xs'>时间</TableHead>
|
||||||
|
<TableHead className='text-xs'>节点</TableHead>
|
||||||
|
<TableHead className='text-xs'>IP</TableHead>
|
||||||
|
<TableHead className='text-xs'>域名</TableHead>
|
||||||
|
<TableHead className='text-xs'>路径</TableHead>
|
||||||
|
<TableHead className='text-xs'>状态码</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(data?.items ?? []).map((item) => (
|
||||||
|
<TableRow key={item.id} className='border-dashed'>
|
||||||
|
<TableCell className='text-xs'>
|
||||||
|
{formatDateTime(item.logged_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-xs'>
|
||||||
|
{item.node_name || item.node_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-xs font-mono'>
|
||||||
|
{item.remote_addr}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-xs'>{item.host}</TableCell>
|
||||||
|
<TableCell className='text-xs max-w-48 truncate'>
|
||||||
|
{item.path}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant='outline' className='text-[10px]'>
|
||||||
|
{item.status_code}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<PaginationBar
|
||||||
|
page={page}
|
||||||
|
hasMore={data?.has_more ?? false}
|
||||||
|
loading={isFetching}
|
||||||
|
onPrev={onPrevPage}
|
||||||
|
onNext={onNextPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
|
||||||
|
import { RankChart } from '@/components/data/rank-chart';
|
||||||
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import type { AccessLogOverview } from '@/lib/services/openflare';
|
||||||
|
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatOverviewRangeHint,
|
||||||
|
formatOverviewTrendLabel,
|
||||||
|
OVERVIEW_RANGE_OPTIONS,
|
||||||
|
type OverviewRangeHours,
|
||||||
|
} from './access-log-utils';
|
||||||
|
|
||||||
|
function SparklineMetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
color,
|
||||||
|
fillColor,
|
||||||
|
labels,
|
||||||
|
values,
|
||||||
|
valueFormatter,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
color: string;
|
||||||
|
fillColor: string;
|
||||||
|
labels: string[];
|
||||||
|
values: number[];
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
}) {
|
||||||
|
const option = useMemo<EChartsOption>(
|
||||||
|
() => ({
|
||||||
|
animationDuration: 400,
|
||||||
|
grid: {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 8,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
show: false,
|
||||||
|
boundaryGap: false,
|
||||||
|
data: labels,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
show: false,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||||
|
borderWidth: 0,
|
||||||
|
textStyle: {
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const items = Array.isArray(params) ? params : [];
|
||||||
|
const item = items[0] as
|
||||||
|
{ axisValueLabel?: string; value?: number } | undefined;
|
||||||
|
if (!item) return '';
|
||||||
|
const raw = typeof item.value === 'number' ? item.value : 0;
|
||||||
|
const formatted = valueFormatter
|
||||||
|
? valueFormatter(raw)
|
||||||
|
: formatCompactNumber(raw);
|
||||||
|
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: values,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: {
|
||||||
|
color,
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: fillColor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[color, fillColor, labels, valueFormatter, values],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardContent className='p-4'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div className='min-w-0 space-y-1'>
|
||||||
|
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
|
||||||
|
<p className='text-[11px] text-muted-foreground'>{hint}</p>
|
||||||
|
</div>
|
||||||
|
<div className='h-16 w-28 shrink-0 sm:w-36'>
|
||||||
|
{labels.length > 0 ? (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: { label: string; value: number }[];
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-sm font-semibold text-foreground'>
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className='text-xs text-muted-foreground'>
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='pt-0'>
|
||||||
|
<RankChart
|
||||||
|
items={items}
|
||||||
|
color={color}
|
||||||
|
emptyMessage={`暂无 ${title} 数据`}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewRangeSwitch({
|
||||||
|
hours,
|
||||||
|
onHoursChange,
|
||||||
|
}: {
|
||||||
|
hours: OverviewRangeHours;
|
||||||
|
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<ToggleGroup
|
||||||
|
type='single'
|
||||||
|
value={String(hours)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
|
||||||
|
}}
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='justify-end'
|
||||||
|
>
|
||||||
|
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={String(option.value)}
|
||||||
|
className='px-2.5 text-xs'
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hours,
|
||||||
|
onHoursChange,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
data?: AccessLogOverview;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
hours: OverviewRangeHours;
|
||||||
|
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<OverviewRangeSwitch hours={hours} onHoursChange={onHoursChange} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoadingStateWithBorder
|
||||||
|
title='加载访问概览'
|
||||||
|
description='正在聚合请求量、访问量与带宽趋势...'
|
||||||
|
/>
|
||||||
|
) : error ? (
|
||||||
|
<ErrorInline
|
||||||
|
message={error.message || '加载访问概览失败'}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
) : !data ? (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
title='暂无概览数据'
|
||||||
|
description='当前时间范围内没有可展示的访问统计。'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewContent data={data} hours={hours} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewContent({
|
||||||
|
data,
|
||||||
|
hours,
|
||||||
|
}: {
|
||||||
|
data: AccessLogOverview;
|
||||||
|
hours: number;
|
||||||
|
}) {
|
||||||
|
const requestLabels = data.trends.requests.map((point) =>
|
||||||
|
formatOverviewTrendLabel(point.bucket_started_at, hours),
|
||||||
|
);
|
||||||
|
const requestValues = data.trends.requests.map((point) => point.value);
|
||||||
|
const visitValues = data.trends.visits.map((point) => point.value);
|
||||||
|
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
|
||||||
|
const hint = formatOverviewRangeHint(hours);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='grid gap-4 lg:grid-cols-3'>
|
||||||
|
<SparklineMetricCard
|
||||||
|
title='Total Requests'
|
||||||
|
value={formatCompactNumber(data.summary.total_requests)}
|
||||||
|
hint={hint}
|
||||||
|
color='#f59e0b'
|
||||||
|
fillColor='rgba(245, 158, 11, 0.18)'
|
||||||
|
labels={requestLabels}
|
||||||
|
values={requestValues}
|
||||||
|
/>
|
||||||
|
<SparklineMetricCard
|
||||||
|
title='Total Visits'
|
||||||
|
value={formatCompactNumber(data.summary.total_visits)}
|
||||||
|
hint={`${hint} · 独立访客`}
|
||||||
|
color='#38bdf8'
|
||||||
|
fillColor='rgba(56, 189, 248, 0.16)'
|
||||||
|
labels={requestLabels}
|
||||||
|
values={visitValues}
|
||||||
|
/>
|
||||||
|
<SparklineMetricCard
|
||||||
|
title='Bandwidth Served'
|
||||||
|
value={formatBytes(data.summary.bandwidth_served)}
|
||||||
|
hint={`${hint} · 已提供数据`}
|
||||||
|
color='#34d399'
|
||||||
|
fillColor='rgba(52, 211, 153, 0.16)'
|
||||||
|
labels={requestLabels}
|
||||||
|
values={bandwidthValues}
|
||||||
|
valueFormatter={formatBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-sm font-semibold'>
|
||||||
|
Requests over time
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className='text-xs'>
|
||||||
|
观察请求量是否出现异常抬升或回落。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TrendChart
|
||||||
|
labels={requestLabels}
|
||||||
|
height={280}
|
||||||
|
showSummary={false}
|
||||||
|
series={[
|
||||||
|
{
|
||||||
|
label: '请求量',
|
||||||
|
color: '#f59e0b',
|
||||||
|
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||||
|
variant: 'area',
|
||||||
|
values: requestValues,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-3'>
|
||||||
|
<RankCard
|
||||||
|
title='Top Paths'
|
||||||
|
description='访问量最高的请求路径。'
|
||||||
|
color='#a78bfa'
|
||||||
|
items={data.top_paths.map((item) => ({
|
||||||
|
label: item.key,
|
||||||
|
value: item.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<RankCard
|
||||||
|
title='Top Hosts'
|
||||||
|
description='流量集中的访问域名。'
|
||||||
|
color='#34d399'
|
||||||
|
items={data.top_hosts.map((item) => ({
|
||||||
|
label: item.key,
|
||||||
|
value: item.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<RankCard
|
||||||
|
title='Top IPs'
|
||||||
|
description='请求次数最多的来源 IP。'
|
||||||
|
color='#38bdf8'
|
||||||
|
items={data.top_ips.map((item) => ({
|
||||||
|
label: item.key,
|
||||||
|
value: item.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
|
||||||
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { AccessLogService } from '@/lib/services/openflare';
|
import { AccessLogService } from '@/lib/services/openflare';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { AccessLogFilters } from './components/access-log-filters';
|
import { AccessLogFilters } from './components/access-log-filters';
|
||||||
import {
|
import {
|
||||||
type AccessLogTab,
|
type AccessLogTab,
|
||||||
DETAIL_SORT_OPTIONS,
|
type OverviewRangeHours,
|
||||||
FOLD_SORT_OPTIONS,
|
|
||||||
formatCompactNumber,
|
|
||||||
IP_SORT_OPTIONS,
|
|
||||||
parseSortValue,
|
parseSortValue,
|
||||||
type SearchDraft,
|
type SearchDraft,
|
||||||
} from './components/access-log-utils';
|
} from './components/access-log-utils';
|
||||||
import { CleanupDialog } from './components/cleanup-dialog';
|
import { CleanupDialog } from './components/cleanup-dialog';
|
||||||
|
import { DetailTab } from './components/detail-tab';
|
||||||
const trendChartConfig = {
|
import { OverviewTab } from './components/overview-tab';
|
||||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
const emptyDraft: SearchDraft = {
|
const emptyDraft: SearchDraft = {
|
||||||
nodeId: '',
|
nodeId: '',
|
||||||
@@ -59,62 +27,27 @@ const emptyDraft: SearchDraft = {
|
|||||||
path: '',
|
path: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function PaginationBar({
|
|
||||||
page,
|
|
||||||
hasMore,
|
|
||||||
loading,
|
|
||||||
onPrev,
|
|
||||||
onNext,
|
|
||||||
}: {
|
|
||||||
page: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
onPrev: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
|
||||||
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
disabled={loading || page <= 0}
|
|
||||||
onClick={onPrev}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
disabled={loading || !hasMore}
|
|
||||||
onClick={onNext}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccessLogsPage() {
|
export default function AccessLogsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<AccessLogTab>('list');
|
const [tab, setTab] = useState<AccessLogTab>('overview');
|
||||||
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
|
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
|
||||||
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
|
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||||
const [foldSort, setFoldSort] = useState('bucket_started_at:desc');
|
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
|
||||||
const [ipSort, setIpSort] = useState('total_requests:desc');
|
|
||||||
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3);
|
|
||||||
const [trendIp, setTrendIp] = useState('');
|
|
||||||
const [appliedTrendIp, setAppliedTrendIp] = useState('');
|
|
||||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||||
|
|
||||||
const detailSortState = parseSortValue(detailSort);
|
const detailSortState = parseSortValue(detailSort);
|
||||||
const foldSortState = parseSortValue(foldSort);
|
|
||||||
const ipSortState = parseSortValue(ipSort);
|
const overviewQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
|
||||||
|
queryFn: () =>
|
||||||
|
AccessLogService.getOverview({
|
||||||
|
hours: overviewHours,
|
||||||
|
}),
|
||||||
|
enabled: tab === 'overview',
|
||||||
|
});
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
@@ -140,68 +73,6 @@ export default function AccessLogsPage() {
|
|||||||
enabled: tab === 'list',
|
enabled: tab === 'list',
|
||||||
});
|
});
|
||||||
|
|
||||||
const foldsQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'openflare',
|
|
||||||
'access-logs',
|
|
||||||
'folds',
|
|
||||||
filters,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
foldSort,
|
|
||||||
foldMinutes,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
|
||||||
AccessLogService.listFolds({
|
|
||||||
node_id: filters.nodeId || undefined,
|
|
||||||
remote_addr: filters.remoteAddr || undefined,
|
|
||||||
host: filters.host || undefined,
|
|
||||||
path: filters.path || undefined,
|
|
||||||
p: page,
|
|
||||||
page_size: pageSize,
|
|
||||||
sort_by: foldSortState.sortBy,
|
|
||||||
sort_order: foldSortState.sortOrder,
|
|
||||||
fold_minutes: foldMinutes,
|
|
||||||
}),
|
|
||||||
enabled: tab === 'folds',
|
|
||||||
});
|
|
||||||
|
|
||||||
const ipSummaryQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'openflare',
|
|
||||||
'access-logs',
|
|
||||||
'ip-summary',
|
|
||||||
filters,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
ipSort,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
|
||||||
AccessLogService.listIPSummaries({
|
|
||||||
node_id: filters.nodeId || undefined,
|
|
||||||
remote_addr: filters.remoteAddr || undefined,
|
|
||||||
host: filters.host || undefined,
|
|
||||||
p: page,
|
|
||||||
page_size: pageSize,
|
|
||||||
sort_by: ipSortState.sortBy,
|
|
||||||
sort_order: ipSortState.sortOrder,
|
|
||||||
}),
|
|
||||||
enabled: tab === 'ip-summary',
|
|
||||||
});
|
|
||||||
|
|
||||||
const ipTrendQuery = useQuery({
|
|
||||||
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
|
|
||||||
queryFn: () =>
|
|
||||||
AccessLogService.getIPTrend({
|
|
||||||
node_id: filters.nodeId || undefined,
|
|
||||||
remote_addr: appliedTrendIp,
|
|
||||||
host: filters.host || undefined,
|
|
||||||
hours: 24,
|
|
||||||
bucket_minutes: 30,
|
|
||||||
}),
|
|
||||||
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const cleanupMutation = useMutation({
|
const cleanupMutation = useMutation({
|
||||||
mutationFn: (retentionDays: number) =>
|
mutationFn: (retentionDays: number) =>
|
||||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||||
@@ -217,35 +88,6 @@ export default function AccessLogsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSummary = useMemo(() => {
|
|
||||||
if (tab === 'list' && listQuery.data) {
|
|
||||||
return {
|
|
||||||
totalRecord: listQuery.data.total_record,
|
|
||||||
totalIp: listQuery.data.total_ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (tab === 'folds' && foldsQuery.data) {
|
|
||||||
return {
|
|
||||||
totalRecord: foldsQuery.data.total_record,
|
|
||||||
totalIp: foldsQuery.data.total_ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (tab === 'ip-summary' && ipSummaryQuery.data) {
|
|
||||||
return {
|
|
||||||
totalRecord: 0,
|
|
||||||
totalIp: ipSummaryQuery.data.total_ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { totalRecord: 0, totalIp: 0 };
|
|
||||||
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]);
|
|
||||||
|
|
||||||
const trendChartData = useMemo(() => {
|
|
||||||
return (ipTrendQuery.data?.points ?? []).map((point) => ({
|
|
||||||
label: formatDateTime(point.bucket_started_at).slice(5),
|
|
||||||
requests: point.request_count,
|
|
||||||
}));
|
|
||||||
}, [ipTrendQuery.data?.points]);
|
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
setFilters({
|
setFilters({
|
||||||
nodeId: draft.nodeId.trim(),
|
nodeId: draft.nodeId.trim(),
|
||||||
@@ -260,8 +102,6 @@ export default function AccessLogsPage() {
|
|||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
setFilters(emptyDraft);
|
setFilters(emptyDraft);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setTrendIp('');
|
|
||||||
setAppliedTrendIp('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,17 +109,11 @@ export default function AccessLogsPage() {
|
|||||||
}, [tab, pageSize]);
|
}, [tab, pageSize]);
|
||||||
|
|
||||||
const refreshActive = () => {
|
const refreshActive = () => {
|
||||||
|
if (tab === 'overview') void overviewQuery.refetch();
|
||||||
if (tab === 'list') void listQuery.refetch();
|
if (tab === 'list') void listQuery.refetch();
|
||||||
if (tab === 'folds') void foldsQuery.refetch();
|
|
||||||
if (tab === 'ip-summary') void ipSummaryQuery.refetch();
|
|
||||||
if (tab === 'ip-trend') void ipTrendQuery.refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFetching =
|
const isFetching = overviewQuery.isFetching || listQuery.isFetching;
|
||||||
listQuery.isFetching ||
|
|
||||||
foldsQuery.isFetching ||
|
|
||||||
ipSummaryQuery.isFetching ||
|
|
||||||
ipTrendQuery.isFetching;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='py-6 px-1 space-y-6'>
|
<div className='py-6 px-1 space-y-6'>
|
||||||
@@ -289,7 +123,7 @@ export default function AccessLogsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||||
<p className='text-sm text-muted-foreground'>
|
<p className='text-sm text-muted-foreground'>
|
||||||
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
|
查看访问概览、排行榜与明细日志。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,392 +150,51 @@ export default function AccessLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-3 sm:grid-cols-3'>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: '访问记录',
|
|
||||||
value: formatCompactNumber(activeSummary.totalRecord),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '来源 IP',
|
|
||||||
value: formatCompactNumber(activeSummary.totalIp),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '当前视图',
|
|
||||||
value:
|
|
||||||
tab === 'list'
|
|
||||||
? '明细日志'
|
|
||||||
: tab === 'folds'
|
|
||||||
? '时间折叠'
|
|
||||||
: tab === 'ip-summary'
|
|
||||||
? 'IP 汇总'
|
|
||||||
: 'IP 趋势',
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className='rounded-lg border border-dashed px-4 py-3'
|
|
||||||
>
|
|
||||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(value) => setTab(value as AccessLogTab)}
|
onValueChange={(value) => setTab(value as AccessLogTab)}
|
||||||
>
|
>
|
||||||
<TabsList className='grid w-full grid-cols-2 lg:grid-cols-4'>
|
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||||
<TabsTrigger value='list'>明细列表</TabsTrigger>
|
<TabsTrigger value='overview'>概览</TabsTrigger>
|
||||||
<TabsTrigger value='folds'>时间折叠</TabsTrigger>
|
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
||||||
<TabsTrigger value='ip-summary'>IP 汇总</TabsTrigger>
|
|
||||||
<TabsTrigger value='ip-trend'>IP 趋势</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
|
<TabsContent value='overview' className='mt-4'>
|
||||||
<AccessLogFilters
|
<OverviewTab
|
||||||
tab={tab}
|
data={overviewQuery.data}
|
||||||
draft={draft}
|
loading={overviewQuery.isLoading}
|
||||||
pageSize={pageSize}
|
error={
|
||||||
onDraftChange={setDraft}
|
overviewQuery.error instanceof Error ? overviewQuery.error : null
|
||||||
onPageSizeChange={setPageSize}
|
}
|
||||||
onSearch={handleSearch}
|
hours={overviewHours}
|
||||||
onReset={handleReset}
|
onHoursChange={setOverviewHours}
|
||||||
|
onRetry={() => void overviewQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value='list' className='mt-4'>
|
|
||||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
|
||||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
|
||||||
<p className='text-sm font-medium'>明细日志</p>
|
|
||||||
<Select value={detailSort} onValueChange={setDetailSort}>
|
|
||||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{listQuery.isError ? (
|
|
||||||
<div className='p-4'>
|
|
||||||
<ErrorInline
|
|
||||||
message={
|
|
||||||
listQuery.error instanceof Error
|
|
||||||
? listQuery.error.message
|
|
||||||
: '加载失败'
|
|
||||||
}
|
|
||||||
onRetry={() => void listQuery.refetch()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : listQuery.isLoading ? (
|
|
||||||
<LoadingStateWithBorder />
|
|
||||||
) : (listQuery.data?.items ?? []).length === 0 ? (
|
|
||||||
<EmptyStateWithBorder title='暂无访问日志' />
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader className='bg-muted/40'>
|
|
||||||
<TableRow className='border-dashed hover:bg-transparent'>
|
|
||||||
<TableHead className='text-xs'>时间</TableHead>
|
|
||||||
<TableHead className='text-xs'>节点</TableHead>
|
|
||||||
<TableHead className='text-xs'>IP</TableHead>
|
|
||||||
<TableHead className='text-xs'>域名</TableHead>
|
|
||||||
<TableHead className='text-xs'>路径</TableHead>
|
|
||||||
<TableHead className='text-xs'>状态码</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(listQuery.data?.items ?? []).map((item) => (
|
|
||||||
<TableRow key={item.id} className='border-dashed'>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{formatDateTime(item.logged_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.node_name || item.node_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs font-mono'>
|
|
||||||
{item.remote_addr}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>{item.host}</TableCell>
|
|
||||||
<TableCell className='text-xs max-w-48 truncate'>
|
|
||||||
{item.path}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant='outline' className='text-[10px]'>
|
|
||||||
{item.status_code}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
<PaginationBar
|
|
||||||
page={page}
|
|
||||||
hasMore={listQuery.data?.has_more ?? false}
|
|
||||||
loading={listQuery.isFetching}
|
|
||||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
onNext={() => setPage((p) => p + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='folds' className='mt-4'>
|
<TabsContent value='list' className='mt-4 space-y-4'>
|
||||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
<div className='rounded-lg border border-dashed bg-background p-4'>
|
||||||
<div className='flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed'>
|
<AccessLogFilters
|
||||||
<div className='flex items-center gap-2'>
|
draft={draft}
|
||||||
<p className='text-sm font-medium'>时间折叠</p>
|
pageSize={pageSize}
|
||||||
<Select
|
onDraftChange={setDraft}
|
||||||
value={String(foldMinutes)}
|
onPageSizeChange={setPageSize}
|
||||||
onValueChange={(value) =>
|
onSearch={handleSearch}
|
||||||
setFoldMinutes(Number(value) as 3 | 5)
|
onReset={handleReset}
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className='h-8 w-36 text-xs'>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value='3'>3 分钟桶</SelectItem>
|
|
||||||
<SelectItem value='5'>5 分钟桶</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Select value={foldSort} onValueChange={setFoldSort}>
|
|
||||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{FOLD_SORT_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{foldsQuery.isError ? (
|
|
||||||
<div className='p-4'>
|
|
||||||
<ErrorInline
|
|
||||||
message={
|
|
||||||
foldsQuery.error instanceof Error
|
|
||||||
? foldsQuery.error.message
|
|
||||||
: '加载失败'
|
|
||||||
}
|
|
||||||
onRetry={() => void foldsQuery.refetch()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : foldsQuery.isLoading ? (
|
|
||||||
<LoadingStateWithBorder />
|
|
||||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
|
||||||
<EmptyStateWithBorder title='暂无折叠数据' />
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader className='bg-muted/40'>
|
|
||||||
<TableRow className='border-dashed hover:bg-transparent'>
|
|
||||||
<TableHead className='text-xs'>时间桶</TableHead>
|
|
||||||
<TableHead className='text-xs'>请求数</TableHead>
|
|
||||||
<TableHead className='text-xs'>独立 IP</TableHead>
|
|
||||||
<TableHead className='text-xs'>独立域名</TableHead>
|
|
||||||
<TableHead className='text-xs'>2xx</TableHead>
|
|
||||||
<TableHead className='text-xs'>4xx</TableHead>
|
|
||||||
<TableHead className='text-xs'>5xx</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(foldsQuery.data?.items ?? []).map((item) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.bucket_started_at}
|
|
||||||
className='border-dashed'
|
|
||||||
>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{formatDateTime(item.bucket_started_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.request_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.unique_ip_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.unique_host_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.success_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.client_error_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.server_error_count}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
<PaginationBar
|
|
||||||
page={page}
|
|
||||||
hasMore={foldsQuery.data?.has_more ?? false}
|
|
||||||
loading={foldsQuery.isFetching}
|
|
||||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
onNext={() => setPage((p) => p + 1)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
<DetailTab
|
||||||
|
data={listQuery.data}
|
||||||
<TabsContent value='ip-summary' className='mt-4'>
|
loading={listQuery.isLoading}
|
||||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
error={listQuery.error instanceof Error ? listQuery.error : null}
|
||||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
page={page}
|
||||||
<p className='text-sm font-medium'>IP 汇总</p>
|
detailSort={detailSort}
|
||||||
<Select value={ipSort} onValueChange={setIpSort}>
|
onDetailSortChange={setDetailSort}
|
||||||
<SelectTrigger className='h-8 w-52 text-xs'>
|
onRetry={() => void listQuery.refetch()}
|
||||||
<SelectValue />
|
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
</SelectTrigger>
|
onNextPage={() => setPage((p) => p + 1)}
|
||||||
<SelectContent>
|
isFetching={listQuery.isFetching}
|
||||||
{IP_SORT_OPTIONS.map((option) => (
|
/>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{ipSummaryQuery.isError ? (
|
|
||||||
<div className='p-4'>
|
|
||||||
<ErrorInline
|
|
||||||
message={
|
|
||||||
ipSummaryQuery.error instanceof Error
|
|
||||||
? ipSummaryQuery.error.message
|
|
||||||
: '加载失败'
|
|
||||||
}
|
|
||||||
onRetry={() => void ipSummaryQuery.refetch()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : ipSummaryQuery.isLoading ? (
|
|
||||||
<LoadingStateWithBorder />
|
|
||||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
|
||||||
<EmptyStateWithBorder title='暂无 IP 汇总数据' />
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader className='bg-muted/40'>
|
|
||||||
<TableRow className='border-dashed hover:bg-transparent'>
|
|
||||||
<TableHead className='text-xs'>IP</TableHead>
|
|
||||||
<TableHead className='text-xs'>总请求数</TableHead>
|
|
||||||
<TableHead className='text-xs'>近 3 小时</TableHead>
|
|
||||||
<TableHead className='text-xs'>最后访问</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(ipSummaryQuery.data?.items ?? []).map((item) => (
|
|
||||||
<TableRow key={item.remote_addr} className='border-dashed'>
|
|
||||||
<TableCell className='text-xs font-mono'>
|
|
||||||
{item.remote_addr}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.total_requests}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{item.recent_requests}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs'>
|
|
||||||
{formatDateTime(item.last_seen_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
<PaginationBar
|
|
||||||
page={page}
|
|
||||||
hasMore={ipSummaryQuery.data?.has_more ?? false}
|
|
||||||
loading={ipSummaryQuery.isFetching}
|
|
||||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
onNext={() => setPage((p) => p + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value='ip-trend' className='mt-4 space-y-4'>
|
|
||||||
<div className='rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end'>
|
|
||||||
<div className='space-y-1.5 flex-1'>
|
|
||||||
<p className='text-xs font-medium text-muted-foreground'>
|
|
||||||
趋势 IP
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs'
|
|
||||||
value={trendIp}
|
|
||||||
onChange={(e) => setTrendIp(e.target.value)}
|
|
||||||
placeholder='输入要分析趋势的 IP 地址'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setAppliedTrendIp(trendIp.trim())}
|
|
||||||
disabled={!trendIp.trim()}
|
|
||||||
>
|
|
||||||
查看趋势
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
|
|
||||||
{!appliedTrendIp ? (
|
|
||||||
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
|
|
||||||
) : ipTrendQuery.isLoading ? (
|
|
||||||
<LoadingStateWithBorder />
|
|
||||||
) : ipTrendQuery.isError ? (
|
|
||||||
<ErrorInline
|
|
||||||
message={
|
|
||||||
ipTrendQuery.error instanceof Error
|
|
||||||
? ipTrendQuery.error.message
|
|
||||||
: '加载失败'
|
|
||||||
}
|
|
||||||
onRetry={() => void ipTrendQuery.refetch()}
|
|
||||||
/>
|
|
||||||
) : trendChartData.length === 0 ? (
|
|
||||||
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
|
|
||||||
) : (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
<p className='text-sm font-medium'>
|
|
||||||
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
|
|
||||||
</p>
|
|
||||||
<ChartContainer
|
|
||||||
config={trendChartConfig}
|
|
||||||
className='h-64 w-full'
|
|
||||||
>
|
|
||||||
<AreaChart data={trendChartData}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey='label'
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
fontSize={10}
|
|
||||||
width={40}
|
|
||||||
/>
|
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
|
||||||
<Area
|
|
||||||
type='monotone'
|
|
||||||
dataKey='requests'
|
|
||||||
stroke='var(--color-requests)'
|
|
||||||
fill='var(--color-requests)'
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -1,702 +1,177 @@
|
|||||||
{
|
{
|
||||||
"Afghanistan": [
|
"Afghanistan": [65.99254367483304, 33.83349410470984],
|
||||||
65.99254367483304,
|
"Albania": [20.035803636476235, 41.179283471608585],
|
||||||
33.83349410470984
|
"Algeria": [2.6175172555729325, 28.158202786113698],
|
||||||
],
|
"Angola": [17.563198329790602, -12.334557680834592],
|
||||||
"Albania": [
|
"Argentina": [-65.15670576062132, -35.178771590782524],
|
||||||
20.035803636476235,
|
"Armenia": [44.929379894609674, 40.29884592895426],
|
||||||
41.179283471608585
|
"Australia": [134.361180723562, -25.574955774991995],
|
||||||
],
|
"Austria": [14.10165807418182, 47.57042831528493],
|
||||||
"Algeria": [
|
"Azerbaijan": [47.636777782137415, 40.3311508093757],
|
||||||
2.6175172555729325,
|
"Bangladesh": [90.21009840315206, 23.88793565572049],
|
||||||
28.158202786113698
|
"Belarus": [28.04895281233843, 53.53432933090482],
|
||||||
],
|
"Belgium": [4.628129604331132, 50.646494813466056],
|
||||||
"Angola": [
|
"Belize": [-88.72096138346826, 17.17073148225863],
|
||||||
17.563198329790602,
|
"Benin": [2.323507643298733, 9.63048313950481],
|
||||||
-12.334557680834592
|
"Bhutan": [90.40562361681495, 27.40133623508629],
|
||||||
],
|
"Bolivia": [-64.69104972918655, -16.70141507507877],
|
||||||
"Argentina": [
|
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
|
||||||
-65.15670576062132,
|
"Botswana": [23.791788508036337, -22.185333985163894],
|
||||||
-35.178771590782524
|
"Brazil": [-53.113858151998855, -10.827995867833263],
|
||||||
],
|
"Brunei": [114.54229268997476, 4.451218476864121],
|
||||||
"Armenia": [
|
"Bulgaria": [25.225364036830744, 42.77785706066581],
|
||||||
44.929379894609674,
|
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
|
||||||
40.29884592895426
|
"Burundi": [29.873065896828848, -3.3802825292327188],
|
||||||
],
|
"Cambodia": [104.90702345739547, 12.717932969523966],
|
||||||
"Australia": [
|
"Cameroon": [12.739868035578002, 5.692692453534944],
|
||||||
134.361180723562,
|
"Canada": [-102.31777616484295, 57.94457487676815],
|
||||||
-25.574955774991995
|
"Central African Republic": [20.490781591297914, 6.563625165826992],
|
||||||
],
|
"Chad": [18.645815630757973, 15.32729896930035],
|
||||||
"Austria": [
|
"Chile": [-71.34891661948937, -35.98028631773855],
|
||||||
14.10165807418182,
|
"China": [103.79804331240364, 36.61094167680319],
|
||||||
47.57042831528493
|
"Colombia": [-73.07794094917405, 3.9235618261252663],
|
||||||
],
|
"Costa Rica": [-84.21742467558424, 9.986463158070535],
|
||||||
"Azerbaijan": [
|
"Croatia": [16.458685366615978, 45.18567759026696],
|
||||||
47.636777782137415,
|
"Cuba": [-78.8954025482625, 21.62136808001903],
|
||||||
40.3311508093757
|
"Cyprus": [33.02409208254226, 34.91304424540028],
|
||||||
],
|
"Czech Republic": [15.330767057694615, 49.70160772219921],
|
||||||
"Bangladesh": [
|
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
|
||||||
90.21009840315206,
|
"Denmark": [-41.29088868109839, 74.72535220460091],
|
||||||
23.88793565572049
|
"Djibouti": [42.56788631653962, 11.745988995518626],
|
||||||
],
|
"Dominican Republic": [-70.48595892120278, 18.896879062003393],
|
||||||
"Belarus": [
|
"East Timor": [125.78222644041995, -8.858373836074785],
|
||||||
28.04895281233843,
|
"Ecuador": [-78.38384779532852, -1.4454571869341688],
|
||||||
53.53432933090482
|
"Egypt": [29.86739912685152, 26.49286512797635],
|
||||||
],
|
"El Salvador": [-88.86593554283397, 13.734573052971413],
|
||||||
"Belgium": [
|
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
|
||||||
4.628129604331132,
|
"Eritrea": [38.835037148217566, 15.359809600209143],
|
||||||
50.646494813466056
|
"Estonia": [25.811350172874032, 58.69303019372469],
|
||||||
],
|
"Ethiopia": [39.60372599651433, 8.621595995236198],
|
||||||
"Belize": [
|
"Fiji": [177.98415179869738, -17.827004078184217],
|
||||||
-88.72096138346826,
|
"Finland": [26.28958351526606, 64.52279699210293],
|
||||||
17.17073148225863
|
"France": [165.43817082439585, -21.285375782391018],
|
||||||
],
|
"Gabon": [11.774048593462979, -0.5859798536474902],
|
||||||
"Benin": [
|
"Gambia": [-16.21304964555284, 13.37062695465985],
|
||||||
2.323507643298733,
|
"Georgia": [43.48650319000057, 42.175027555818694],
|
||||||
9.63048313950481
|
"Germany": [10.380496796525602, 51.083853487429664],
|
||||||
],
|
"Ghana": [-1.2150159067556041, 7.94078880601944],
|
||||||
"Bhutan": [
|
"Greece": [22.53435219348475, 39.517734832602365],
|
||||||
90.40562361681495,
|
"Guatemala": [-90.35344030476544, 15.679044168855697],
|
||||||
27.40133623508629
|
"Guinea": [-10.943568403941383, 10.44078010915303],
|
||||||
],
|
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
|
||||||
"Bolivia": [
|
"Guyana": [-58.978019347627985, 4.800329726037504],
|
||||||
-64.69104972918655,
|
"Haiti": [-72.67866559866681, 18.916195586860297],
|
||||||
-16.70141507507877
|
"Honduras": [-86.65651796671759, 14.821029518594349],
|
||||||
],
|
"Hong Kong": [114.1747, 22.2783],
|
||||||
"Bosnia and Herzegovina": [
|
"Hungary": [19.410867145831855, 47.168542742918525],
|
||||||
17.762461846176542,
|
"Iceland": [-18.509670948264166, 65.03034972428229],
|
||||||
44.16398853722456
|
"India": [79.58636900833257, 22.901586798523514],
|
||||||
],
|
"Indonesia": [101.52237404492608, -0.44681277438152994],
|
||||||
"Botswana": [
|
"Iran": [54.266246557433305, 32.57616445788996],
|
||||||
23.791788508036337,
|
"Iraq": [43.74006501790808, 33.0341456153028],
|
||||||
-22.185333985163894
|
"Ireland": [-8.117695195030898, 53.2341315203433],
|
||||||
],
|
"Israel": [35.241573878810335, 31.94758544099519],
|
||||||
"Brazil": [
|
"Italy": [12.16233842748749, 43.51281131035235],
|
||||||
-53.113858151998855,
|
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
|
||||||
-10.827995867833263
|
"Jamaica": [-77.32850498837172, 18.172957893241634],
|
||||||
],
|
"Japan": [137.97352453982398, 36.589925009528365],
|
||||||
"Brunei": [
|
"Jordan": [36.76131486689134, 31.237145277490367],
|
||||||
114.54229268997476,
|
"Kashmir": [77.08733723957188, 35.415738932286466],
|
||||||
4.451218476864121
|
"Kazakhstan": [67.27815427190085, 48.155989088140196],
|
||||||
],
|
"Kenya": [37.795078751163835, 0.5967194222965451],
|
||||||
"Bulgaria": [
|
"Kosovo": [20.851519165302378, 42.51783231278167],
|
||||||
25.225364036830744,
|
"Kuwait": [47.566177395781736, 29.316145203902064],
|
||||||
42.77785706066581
|
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
|
||||||
],
|
"Laos": [103.75263811609112, 18.494354885305256],
|
||||||
"Burkina Faso": [
|
"Latvia": [24.89496154477673, 56.853143698564296],
|
||||||
-1.7757430610210276,
|
"Lebanon": [35.88502277707876, 33.91528991779158],
|
||||||
12.260370131848955
|
"Lesotho": [28.24303757633595, -29.57351232727891],
|
||||||
],
|
"Liberia": [-9.325867879201954, 6.453996218817175],
|
||||||
"Burundi": [
|
"Libya": [18.008785486000946, 27.031457363755713],
|
||||||
29.873065896828848,
|
"Lithuania": [23.872793668340726, 55.3142460584835],
|
||||||
-3.3802825292327188
|
"Luxembourg": [6.070340274408408, 49.76746140228473],
|
||||||
],
|
"Macao": [113.5439, 22.1987],
|
||||||
"Cambodia": [
|
"Macau": [113.5439, 22.1987],
|
||||||
104.90702345739547,
|
"Macedonia": [21.704642152886493, 41.60373573782414],
|
||||||
12.717932969523966
|
"Madagascar": [46.70172536870331, -19.374776628372288],
|
||||||
],
|
"Malawi": [34.288911351438394, -13.200363393826267],
|
||||||
"Cameroon": [
|
"Malaysia": [114.69201508943004, 3.57724156754415],
|
||||||
12.739868035578002,
|
"Mali": [-3.5321833143642736, 17.347810147640345],
|
||||||
5.692692453534944
|
"Mauritania": [-10.34307896458181, 20.259793585678146],
|
||||||
],
|
"Mexico": [-102.51849775988345, 23.943713432682014],
|
||||||
"Canada": [
|
"Moldova": [28.446472517363826, 47.2017924696845],
|
||||||
-102.31777616484295,
|
"Mongolia": [103.06423533091326, 46.82560864032323],
|
||||||
57.94457487676815
|
"Montenegro": [19.233365413523355, 42.78395456212118],
|
||||||
],
|
"Morocco": [-8.459192831832421, 29.83932361976915],
|
||||||
"Central African Republic": [
|
"Mozambique": [35.53173663088568, -17.27297477254694],
|
||||||
20.490781591297914,
|
"Myanmar": [96.49588816533908, 21.22201469728619],
|
||||||
6.563625165826992
|
"Namibia": [17.208945346875193, -22.132302890947404],
|
||||||
],
|
"Nepal": [83.9234099472594, 28.243335814044734],
|
||||||
"Chad": [
|
"Netherlands": [5.646194278986089, 52.27734132368374],
|
||||||
18.645815630757973,
|
"New Zealand": [170.54047059084724, -43.955381757717255],
|
||||||
15.32729896930035
|
"Nicaragua": [-85.03229930746807, 12.835548864379284],
|
||||||
],
|
"Niger": [9.384787308614277, 17.41758190683491],
|
||||||
"Chile": [
|
"Nigeria": [8.074235448092892, 9.58958974174772],
|
||||||
-71.34891661948937,
|
"North Korea": [127.21551783512186, 40.144856985239926],
|
||||||
-35.98028631773855
|
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
|
||||||
],
|
"Norway": [13.994247649210363, 64.32249798565987],
|
||||||
"China": [
|
"Oman": [56.08271927344639, 20.573738446088598],
|
||||||
103.79804331240364,
|
"Pakistan": [69.32988114530586, 29.951585862562073],
|
||||||
36.61094167680319
|
"Panama": [-80.05970573973497, 8.521692369504816],
|
||||||
],
|
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
|
||||||
"Colombia": [
|
"Paraguay": [-58.39900641582761, -23.223895588708068],
|
||||||
-73.07794094917405,
|
"Peru": [-74.38850244798846, -9.156316252308201],
|
||||||
3.9235618261252663
|
"Philippines": [121.45754828266669, 15.948824272939182],
|
||||||
],
|
"Poland": [19.379892383012464, 52.11963086628078],
|
||||||
"Costa Rica": [
|
"Portugal": [-7.977838817691485, 39.6854900148663],
|
||||||
-84.21742467558424,
|
"Qatar": [51.170816022848, 25.32876608728452],
|
||||||
9.986463158070535
|
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
|
||||||
],
|
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
|
||||||
"Croatia": [
|
"Romania": [24.971632366248883, 45.84560772065747],
|
||||||
16.458685366615978,
|
"Russia": [99.05228241954488, 61.677043847105494],
|
||||||
45.18567759026696
|
"Rwanda": [29.91765145538139, -1.9953628964377972],
|
||||||
],
|
"Saudi Arabia": [44.5342871199886, 24.122854227726673],
|
||||||
"Cuba": [
|
"Senegal": [-14.472411019597182, 14.338613732351318],
|
||||||
-78.8954025482625,
|
"Sierra Leone": [-11.82013204896783, 8.575093438325089],
|
||||||
21.62136808001903
|
"Singapore": [103.8198, 1.3521],
|
||||||
],
|
"Slovakia": [19.47296461136286, 48.71540683451534],
|
||||||
"Cyprus": [
|
"Slovenia": [14.771193461021065, 46.10807043333343],
|
||||||
33.02409208254226,
|
"Solomon Islands": [160.156396875585, -9.628589831637283],
|
||||||
34.91304424540028
|
"Somalia": [45.706539856671256, 4.747960517798881],
|
||||||
],
|
"Somaliland": [46.25513292885051, 9.729995248781385],
|
||||||
"Czech Republic": [
|
"South Africa": [25.160630055943862, -29.008774283210798],
|
||||||
15.330767057694615,
|
"South Korea": [127.87280447602005, 36.475157799464604],
|
||||||
49.70160772219921
|
"South Sudan": [30.253827921202955, 7.307277152400154],
|
||||||
],
|
"Spain": [-3.5708365129134876, 40.391564705240064],
|
||||||
"Democratic Republic of the Congo": [
|
"Sri Lanka": [80.70055059693914, 7.649883097905021],
|
||||||
23.64811688254194,
|
"Sudan": [29.946243665370346, 15.99205876641883],
|
||||||
-2.8775533309965056
|
"Suriname": [-55.91029765824739, 4.126766010517478],
|
||||||
],
|
"Swaziland": [31.47664514492297, -26.563545981108437],
|
||||||
"Denmark": [
|
"Sweden": [16.752551082675275, 62.8354879894394],
|
||||||
-41.29088868109839,
|
"Switzerland": [8.205812860319684, 46.805544047201984],
|
||||||
74.72535220460091
|
"Syria": [38.50449031631095, 35.01769370592341],
|
||||||
],
|
"Taiwan": [120.9605, 23.6978],
|
||||||
"Djibouti": [
|
"Tajikistan": [70.98712700685866, 38.55204366339685],
|
||||||
42.56788631653962,
|
"Thailand": [101.01060253224972, 15.132319216387051],
|
||||||
11.745988995518626
|
"The Bahamas": [-78.02733865351972, 24.719920319716515],
|
||||||
],
|
"Togo": [0.949513544830056, 8.576560980195756],
|
||||||
"Dominican Republic": [
|
"Trinidad and Tobago": [-61.270458833911896, 10.339930045317969],
|
||||||
-70.48595892120278,
|
"Tunisia": [9.546832905099203, 34.12673069029241],
|
||||||
18.896879062003393
|
"Turkey": [35.424986274948736, 38.998407240706534],
|
||||||
],
|
"Turkmenistan": [59.39292286662992, 39.1098173228857],
|
||||||
"East Timor": [
|
"Uganda": [32.36575488492427, 1.2757695499320103],
|
||||||
125.78222644041995,
|
"Ukraine": [31.381193375918794, 49.01345298489402],
|
||||||
-8.858373836074785
|
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
|
||||||
],
|
"United Kingdom": [-36.367082569942795, -54.468348288665524],
|
||||||
"Ecuador": [
|
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
|
||||||
-78.38384779532852,
|
"United States of America": [-99.14669730710926, 39.52689381707972],
|
||||||
-1.4454571869341688
|
"Uruguay": [-56.01665573850212, -32.80651677440885],
|
||||||
],
|
"Uzbekistan": [63.148826727968626, 41.7502616897204],
|
||||||
"Egypt": [
|
"Vanuatu": [166.84228659210697, -15.266627310669035],
|
||||||
29.86739912685152,
|
"Venezuela": [-66.1882174671549, 7.118299933056809],
|
||||||
26.49286512797635
|
"Vietnam": [106.29939096235873, 16.63088189612042],
|
||||||
],
|
"Western Sahara": [-12.218085234851758, 24.221135708939354],
|
||||||
"El Salvador": [
|
"Yemen": [47.546351590294876, 15.935825615412996],
|
||||||
-88.86593554283397,
|
"Zambia": [27.775321721593084, -13.460954386993254],
|
||||||
13.734573052971413
|
"Zimbabwe": [29.850564118889274, -19.00677451616593]
|
||||||
],
|
|
||||||
"Equatorial Guinea": [
|
|
||||||
10.481670786028316,
|
|
||||||
1.5660796013647418
|
|
||||||
],
|
|
||||||
"Eritrea": [
|
|
||||||
38.835037148217566,
|
|
||||||
15.359809600209143
|
|
||||||
],
|
|
||||||
"Estonia": [
|
|
||||||
25.811350172874032,
|
|
||||||
58.69303019372469
|
|
||||||
],
|
|
||||||
"Ethiopia": [
|
|
||||||
39.60372599651433,
|
|
||||||
8.621595995236198
|
|
||||||
],
|
|
||||||
"Fiji": [
|
|
||||||
177.98415179869738,
|
|
||||||
-17.827004078184217
|
|
||||||
],
|
|
||||||
"Finland": [
|
|
||||||
26.28958351526606,
|
|
||||||
64.52279699210293
|
|
||||||
],
|
|
||||||
"France": [
|
|
||||||
165.43817082439585,
|
|
||||||
-21.285375782391018
|
|
||||||
],
|
|
||||||
"Gabon": [
|
|
||||||
11.774048593462979,
|
|
||||||
-0.5859798536474902
|
|
||||||
],
|
|
||||||
"Gambia": [
|
|
||||||
-16.21304964555284,
|
|
||||||
13.37062695465985
|
|
||||||
],
|
|
||||||
"Georgia": [
|
|
||||||
43.48650319000057,
|
|
||||||
42.175027555818694
|
|
||||||
],
|
|
||||||
"Germany": [
|
|
||||||
10.380496796525602,
|
|
||||||
51.083853487429664
|
|
||||||
],
|
|
||||||
"Ghana": [
|
|
||||||
-1.2150159067556041,
|
|
||||||
7.94078880601944
|
|
||||||
],
|
|
||||||
"Greece": [
|
|
||||||
22.53435219348475,
|
|
||||||
39.517734832602365
|
|
||||||
],
|
|
||||||
"Guatemala": [
|
|
||||||
-90.35344030476544,
|
|
||||||
15.679044168855697
|
|
||||||
],
|
|
||||||
"Guinea": [
|
|
||||||
-10.943568403941383,
|
|
||||||
10.44078010915303
|
|
||||||
],
|
|
||||||
"Guinea Bissau": [
|
|
||||||
-14.929646023453557,
|
|
||||||
12.05088889829685
|
|
||||||
],
|
|
||||||
"Guyana": [
|
|
||||||
-58.978019347627985,
|
|
||||||
4.800329726037504
|
|
||||||
],
|
|
||||||
"Haiti": [
|
|
||||||
-72.67866559866681,
|
|
||||||
18.916195586860297
|
|
||||||
],
|
|
||||||
"Honduras": [
|
|
||||||
-86.65651796671759,
|
|
||||||
14.821029518594349
|
|
||||||
],
|
|
||||||
"Hong Kong": [
|
|
||||||
114.1747,
|
|
||||||
22.2783
|
|
||||||
],
|
|
||||||
"Hungary": [
|
|
||||||
19.410867145831855,
|
|
||||||
47.168542742918525
|
|
||||||
],
|
|
||||||
"Iceland": [
|
|
||||||
-18.509670948264166,
|
|
||||||
65.03034972428229
|
|
||||||
],
|
|
||||||
"India": [
|
|
||||||
79.58636900833257,
|
|
||||||
22.901586798523514
|
|
||||||
],
|
|
||||||
"Indonesia": [
|
|
||||||
101.52237404492608,
|
|
||||||
-0.44681277438152994
|
|
||||||
],
|
|
||||||
"Iran": [
|
|
||||||
54.266246557433305,
|
|
||||||
32.57616445788996
|
|
||||||
],
|
|
||||||
"Iraq": [
|
|
||||||
43.74006501790808,
|
|
||||||
33.0341456153028
|
|
||||||
],
|
|
||||||
"Ireland": [
|
|
||||||
-8.117695195030898,
|
|
||||||
53.2341315203433
|
|
||||||
],
|
|
||||||
"Israel": [
|
|
||||||
35.241573878810335,
|
|
||||||
31.94758544099519
|
|
||||||
],
|
|
||||||
"Italy": [
|
|
||||||
12.16233842748749,
|
|
||||||
43.51281131035235
|
|
||||||
],
|
|
||||||
"Ivory Coast": [
|
|
||||||
-5.569771898429842,
|
|
||||||
7.626172775954684
|
|
||||||
],
|
|
||||||
"Jamaica": [
|
|
||||||
-77.32850498837172,
|
|
||||||
18.172957893241634
|
|
||||||
],
|
|
||||||
"Japan": [
|
|
||||||
137.97352453982398,
|
|
||||||
36.589925009528365
|
|
||||||
],
|
|
||||||
"Jordan": [
|
|
||||||
36.76131486689134,
|
|
||||||
31.237145277490367
|
|
||||||
],
|
|
||||||
"Kashmir": [
|
|
||||||
77.08733723957188,
|
|
||||||
35.415738932286466
|
|
||||||
],
|
|
||||||
"Kazakhstan": [
|
|
||||||
67.27815427190085,
|
|
||||||
48.155989088140196
|
|
||||||
],
|
|
||||||
"Kenya": [
|
|
||||||
37.795078751163835,
|
|
||||||
0.5967194222965451
|
|
||||||
],
|
|
||||||
"Kosovo": [
|
|
||||||
20.851519165302378,
|
|
||||||
42.51783231278167
|
|
||||||
],
|
|
||||||
"Kuwait": [
|
|
||||||
47.566177395781736,
|
|
||||||
29.316145203902064
|
|
||||||
],
|
|
||||||
"Kyrgyzstan": [
|
|
||||||
74.58224715420873,
|
|
||||||
41.48363475100716
|
|
||||||
],
|
|
||||||
"Laos": [
|
|
||||||
103.75263811609112,
|
|
||||||
18.494354885305256
|
|
||||||
],
|
|
||||||
"Latvia": [
|
|
||||||
24.89496154477673,
|
|
||||||
56.853143698564296
|
|
||||||
],
|
|
||||||
"Lebanon": [
|
|
||||||
35.88502277707876,
|
|
||||||
33.91528991779158
|
|
||||||
],
|
|
||||||
"Lesotho": [
|
|
||||||
28.24303757633595,
|
|
||||||
-29.57351232727891
|
|
||||||
],
|
|
||||||
"Liberia": [
|
|
||||||
-9.325867879201954,
|
|
||||||
6.453996218817175
|
|
||||||
],
|
|
||||||
"Libya": [
|
|
||||||
18.008785486000946,
|
|
||||||
27.031457363755713
|
|
||||||
],
|
|
||||||
"Lithuania": [
|
|
||||||
23.872793668340726,
|
|
||||||
55.3142460584835
|
|
||||||
],
|
|
||||||
"Luxembourg": [
|
|
||||||
6.070340274408408,
|
|
||||||
49.76746140228473
|
|
||||||
],
|
|
||||||
"Macao": [
|
|
||||||
113.5439,
|
|
||||||
22.1987
|
|
||||||
],
|
|
||||||
"Macau": [
|
|
||||||
113.5439,
|
|
||||||
22.1987
|
|
||||||
],
|
|
||||||
"Macedonia": [
|
|
||||||
21.704642152886493,
|
|
||||||
41.60373573782414
|
|
||||||
],
|
|
||||||
"Madagascar": [
|
|
||||||
46.70172536870331,
|
|
||||||
-19.374776628372288
|
|
||||||
],
|
|
||||||
"Malawi": [
|
|
||||||
34.288911351438394,
|
|
||||||
-13.200363393826267
|
|
||||||
],
|
|
||||||
"Malaysia": [
|
|
||||||
114.69201508943004,
|
|
||||||
3.57724156754415
|
|
||||||
],
|
|
||||||
"Mali": [
|
|
||||||
-3.5321833143642736,
|
|
||||||
17.347810147640345
|
|
||||||
],
|
|
||||||
"Mauritania": [
|
|
||||||
-10.34307896458181,
|
|
||||||
20.259793585678146
|
|
||||||
],
|
|
||||||
"Mexico": [
|
|
||||||
-102.51849775988345,
|
|
||||||
23.943713432682014
|
|
||||||
],
|
|
||||||
"Moldova": [
|
|
||||||
28.446472517363826,
|
|
||||||
47.2017924696845
|
|
||||||
],
|
|
||||||
"Mongolia": [
|
|
||||||
103.06423533091326,
|
|
||||||
46.82560864032323
|
|
||||||
],
|
|
||||||
"Montenegro": [
|
|
||||||
19.233365413523355,
|
|
||||||
42.78395456212118
|
|
||||||
],
|
|
||||||
"Morocco": [
|
|
||||||
-8.459192831832421,
|
|
||||||
29.83932361976915
|
|
||||||
],
|
|
||||||
"Mozambique": [
|
|
||||||
35.53173663088568,
|
|
||||||
-17.27297477254694
|
|
||||||
],
|
|
||||||
"Myanmar": [
|
|
||||||
96.49588816533908,
|
|
||||||
21.22201469728619
|
|
||||||
],
|
|
||||||
"Namibia": [
|
|
||||||
17.208945346875193,
|
|
||||||
-22.132302890947404
|
|
||||||
],
|
|
||||||
"Nepal": [
|
|
||||||
83.9234099472594,
|
|
||||||
28.243335814044734
|
|
||||||
],
|
|
||||||
"Netherlands": [
|
|
||||||
5.646194278986089,
|
|
||||||
52.27734132368374
|
|
||||||
],
|
|
||||||
"New Zealand": [
|
|
||||||
170.54047059084724,
|
|
||||||
-43.955381757717255
|
|
||||||
],
|
|
||||||
"Nicaragua": [
|
|
||||||
-85.03229930746807,
|
|
||||||
12.835548864379284
|
|
||||||
],
|
|
||||||
"Niger": [
|
|
||||||
9.384787308614277,
|
|
||||||
17.41758190683491
|
|
||||||
],
|
|
||||||
"Nigeria": [
|
|
||||||
8.074235448092892,
|
|
||||||
9.58958974174772
|
|
||||||
],
|
|
||||||
"North Korea": [
|
|
||||||
127.21551783512186,
|
|
||||||
40.144856985239926
|
|
||||||
],
|
|
||||||
"Northern Cyprus": [
|
|
||||||
33.39079188058263,
|
|
||||||
35.225753269439686
|
|
||||||
],
|
|
||||||
"Norway": [
|
|
||||||
13.994247649210363,
|
|
||||||
64.32249798565987
|
|
||||||
],
|
|
||||||
"Oman": [
|
|
||||||
56.08271927344639,
|
|
||||||
20.573738446088598
|
|
||||||
],
|
|
||||||
"Pakistan": [
|
|
||||||
69.32988114530586,
|
|
||||||
29.951585862562073
|
|
||||||
],
|
|
||||||
"Panama": [
|
|
||||||
-80.05970573973497,
|
|
||||||
8.521692369504816
|
|
||||||
],
|
|
||||||
"Papua New Guinea": [
|
|
||||||
144.22896759838426,
|
|
||||||
-6.607045399379529
|
|
||||||
],
|
|
||||||
"Paraguay": [
|
|
||||||
-58.39900641582761,
|
|
||||||
-23.223895588708068
|
|
||||||
],
|
|
||||||
"Peru": [
|
|
||||||
-74.38850244798846,
|
|
||||||
-9.156316252308201
|
|
||||||
],
|
|
||||||
"Philippines": [
|
|
||||||
121.45754828266669,
|
|
||||||
15.948824272939182
|
|
||||||
],
|
|
||||||
"Poland": [
|
|
||||||
19.379892383012464,
|
|
||||||
52.11963086628078
|
|
||||||
],
|
|
||||||
"Portugal": [
|
|
||||||
-7.977838817691485,
|
|
||||||
39.6854900148663
|
|
||||||
],
|
|
||||||
"Qatar": [
|
|
||||||
51.170816022848,
|
|
||||||
25.32876608728452
|
|
||||||
],
|
|
||||||
"Republic of Serbia": [
|
|
||||||
20.766249889622852,
|
|
||||||
44.19998417744864
|
|
||||||
],
|
|
||||||
"Republic of the Congo": [
|
|
||||||
15.21207418503586,
|
|
||||||
-0.8564004333096924
|
|
||||||
],
|
|
||||||
"Romania": [
|
|
||||||
24.971632366248883,
|
|
||||||
45.84560772065747
|
|
||||||
],
|
|
||||||
"Russia": [
|
|
||||||
99.05228241954488,
|
|
||||||
61.677043847105494
|
|
||||||
],
|
|
||||||
"Rwanda": [
|
|
||||||
29.91765145538139,
|
|
||||||
-1.9953628964377972
|
|
||||||
],
|
|
||||||
"Saudi Arabia": [
|
|
||||||
44.5342871199886,
|
|
||||||
24.122854227726673
|
|
||||||
],
|
|
||||||
"Senegal": [
|
|
||||||
-14.472411019597182,
|
|
||||||
14.338613732351318
|
|
||||||
],
|
|
||||||
"Sierra Leone": [
|
|
||||||
-11.82013204896783,
|
|
||||||
8.575093438325089
|
|
||||||
],
|
|
||||||
"Singapore": [
|
|
||||||
103.8198,
|
|
||||||
1.3521
|
|
||||||
],
|
|
||||||
"Slovakia": [
|
|
||||||
19.47296461136286,
|
|
||||||
48.71540683451534
|
|
||||||
],
|
|
||||||
"Slovenia": [
|
|
||||||
14.771193461021065,
|
|
||||||
46.10807043333343
|
|
||||||
],
|
|
||||||
"Solomon Islands": [
|
|
||||||
160.156396875585,
|
|
||||||
-9.628589831637283
|
|
||||||
],
|
|
||||||
"Somalia": [
|
|
||||||
45.706539856671256,
|
|
||||||
4.747960517798881
|
|
||||||
],
|
|
||||||
"Somaliland": [
|
|
||||||
46.25513292885051,
|
|
||||||
9.729995248781385
|
|
||||||
],
|
|
||||||
"South Africa": [
|
|
||||||
25.160630055943862,
|
|
||||||
-29.008774283210798
|
|
||||||
],
|
|
||||||
"South Korea": [
|
|
||||||
127.87280447602005,
|
|
||||||
36.475157799464604
|
|
||||||
],
|
|
||||||
"South Sudan": [
|
|
||||||
30.253827921202955,
|
|
||||||
7.307277152400154
|
|
||||||
],
|
|
||||||
"Spain": [
|
|
||||||
-3.5708365129134876,
|
|
||||||
40.391564705240064
|
|
||||||
],
|
|
||||||
"Sri Lanka": [
|
|
||||||
80.70055059693914,
|
|
||||||
7.649883097905021
|
|
||||||
],
|
|
||||||
"Sudan": [
|
|
||||||
29.946243665370346,
|
|
||||||
15.99205876641883
|
|
||||||
],
|
|
||||||
"Suriname": [
|
|
||||||
-55.91029765824739,
|
|
||||||
4.126766010517478
|
|
||||||
],
|
|
||||||
"Swaziland": [
|
|
||||||
31.47664514492297,
|
|
||||||
-26.563545981108437
|
|
||||||
],
|
|
||||||
"Sweden": [
|
|
||||||
16.752551082675275,
|
|
||||||
62.8354879894394
|
|
||||||
],
|
|
||||||
"Switzerland": [
|
|
||||||
8.205812860319684,
|
|
||||||
46.805544047201984
|
|
||||||
],
|
|
||||||
"Syria": [
|
|
||||||
38.50449031631095,
|
|
||||||
35.01769370592341
|
|
||||||
],
|
|
||||||
"Taiwan": [
|
|
||||||
120.9605,
|
|
||||||
23.6978
|
|
||||||
],
|
|
||||||
"Tajikistan": [
|
|
||||||
70.98712700685866,
|
|
||||||
38.55204366339685
|
|
||||||
],
|
|
||||||
"Thailand": [
|
|
||||||
101.01060253224972,
|
|
||||||
15.132319216387051
|
|
||||||
],
|
|
||||||
"The Bahamas": [
|
|
||||||
-78.02733865351972,
|
|
||||||
24.719920319716515
|
|
||||||
],
|
|
||||||
"Togo": [
|
|
||||||
0.949513544830056,
|
|
||||||
8.576560980195756
|
|
||||||
],
|
|
||||||
"Trinidad and Tobago": [
|
|
||||||
-61.270458833911896,
|
|
||||||
10.339930045317969
|
|
||||||
],
|
|
||||||
"Tunisia": [
|
|
||||||
9.546832905099203,
|
|
||||||
34.12673069029241
|
|
||||||
],
|
|
||||||
"Turkey": [
|
|
||||||
35.424986274948736,
|
|
||||||
38.998407240706534
|
|
||||||
],
|
|
||||||
"Turkmenistan": [
|
|
||||||
59.39292286662992,
|
|
||||||
39.1098173228857
|
|
||||||
],
|
|
||||||
"Uganda": [
|
|
||||||
32.36575488492427,
|
|
||||||
1.2757695499320103
|
|
||||||
],
|
|
||||||
"Ukraine": [
|
|
||||||
31.381193375918794,
|
|
||||||
49.01345298489402
|
|
||||||
],
|
|
||||||
"United Arab Emirates": [
|
|
||||||
54.30345828396012,
|
|
||||||
23.905698042805522
|
|
||||||
],
|
|
||||||
"United Kingdom": [
|
|
||||||
-36.367082569942795,
|
|
||||||
-54.468348288665524
|
|
||||||
],
|
|
||||||
"United Republic of Tanzania": [
|
|
||||||
34.79490109621088,
|
|
||||||
-6.276146393386636
|
|
||||||
],
|
|
||||||
"United States of America": [
|
|
||||||
-99.14669730710926,
|
|
||||||
39.52689381707972
|
|
||||||
],
|
|
||||||
"Uruguay": [
|
|
||||||
-56.01665573850212,
|
|
||||||
-32.80651677440885
|
|
||||||
],
|
|
||||||
"Uzbekistan": [
|
|
||||||
63.148826727968626,
|
|
||||||
41.7502616897204
|
|
||||||
],
|
|
||||||
"Vanuatu": [
|
|
||||||
166.84228659210697,
|
|
||||||
-15.266627310669035
|
|
||||||
],
|
|
||||||
"Venezuela": [
|
|
||||||
-66.1882174671549,
|
|
||||||
7.118299933056809
|
|
||||||
],
|
|
||||||
"Vietnam": [
|
|
||||||
106.29939096235873,
|
|
||||||
16.63088189612042
|
|
||||||
],
|
|
||||||
"Western Sahara": [
|
|
||||||
-12.218085234851758,
|
|
||||||
24.221135708939354
|
|
||||||
],
|
|
||||||
"Yemen": [
|
|
||||||
47.546351590294876,
|
|
||||||
15.935825615412996
|
|
||||||
],
|
|
||||||
"Zambia": [
|
|
||||||
27.775321721593084,
|
|
||||||
-13.460954386993254
|
|
||||||
],
|
|
||||||
"Zimbabwe": [
|
|
||||||
29.850564118889274,
|
|
||||||
-19.00677451616593
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { EChartsOption } from 'echarts';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type RankChartItem = {
|
type RankChartItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -14,119 +14,65 @@ type RankChartProps = {
|
|||||||
color: string;
|
color: string;
|
||||||
valueFormatter?: (value: number) => string;
|
valueFormatter?: (value: number) => string;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||||
|
|
||||||
function getChartValue(params: unknown) {
|
|
||||||
if (typeof params !== 'object' || params === null || !('value' in params)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const rawValue = (params as { value?: unknown }).value;
|
|
||||||
if (Array.isArray(rawValue)) {
|
|
||||||
const candidate = rawValue[0];
|
|
||||||
return typeof candidate === 'number' ? candidate : 0;
|
|
||||||
}
|
|
||||||
return typeof rawValue === 'number' ? rawValue : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RankChart({
|
export function RankChart({
|
||||||
items,
|
items,
|
||||||
color,
|
color,
|
||||||
valueFormatter = defaultFormatter,
|
valueFormatter = defaultFormatter,
|
||||||
emptyMessage = '暂无分布数据',
|
emptyMessage = '暂无分布数据',
|
||||||
|
className,
|
||||||
}: RankChartProps) {
|
}: RankChartProps) {
|
||||||
const option = useMemo<EChartsOption>(
|
const maxValue = useMemo(
|
||||||
() => ({
|
() => Math.max(0, ...items.map((item) => item.value)),
|
||||||
animationDuration: 400,
|
[items],
|
||||||
grid: {
|
|
||||||
left: 16,
|
|
||||||
right: 24,
|
|
||||||
top: 12,
|
|
||||||
bottom: 12,
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'shadow',
|
|
||||||
},
|
|
||||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
|
||||||
borderWidth: 0,
|
|
||||||
textStyle: {
|
|
||||||
color: '#e2e8f0',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
formatter: (params: unknown) => {
|
|
||||||
const item = Array.isArray(params) ? params[0] : params;
|
|
||||||
const data = item as { name?: string; value?: number };
|
|
||||||
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLabel: {
|
|
||||||
color: '#94a3b8',
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: 'rgba(148, 163, 184, 0.16)',
|
|
||||||
type: 'dashed',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: items.map((item) => item.label),
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
color: '#cbd5e1',
|
|
||||||
width: 120,
|
|
||||||
overflow: 'truncate',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
data: items.map((item) => item.value),
|
|
||||||
barWidth: 12,
|
|
||||||
showBackground: true,
|
|
||||||
backgroundStyle: {
|
|
||||||
color: 'rgba(148, 163, 184, 0.12)',
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color,
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'right',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
formatter: (params: unknown) =>
|
|
||||||
valueFormatter(getChartValue(params)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[color, items, valueFormatter],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground'>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-[450px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactECharts
|
<div className={cn('h-[450px] space-y-1.5 overflow-y-auto', className)}>
|
||||||
option={option}
|
{items.map((item) => {
|
||||||
notMerge
|
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||||
lazyUpdate
|
return (
|
||||||
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
|
<div key={item.label} className='group space-y-1'>
|
||||||
/>
|
<div className='flex items-center justify-between gap-3 text-[13px] leading-none'>
|
||||||
|
<span
|
||||||
|
className='min-w-0 truncate font-medium text-foreground'
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className='shrink-0 tabular-nums text-foreground/80'>
|
||||||
|
{valueFormatter(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='h-1.5 overflow-hidden rounded-full bg-muted'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full transition-[width] duration-300'
|
||||||
|
style={{
|
||||||
|
width: `${ratio}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export type {
|
|||||||
OriginItem,
|
OriginItem,
|
||||||
OriginDetail,
|
OriginDetail,
|
||||||
AccessLogList,
|
AccessLogList,
|
||||||
|
AccessLogOverview,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
GeoIPLookupResult,
|
GeoIPLookupResult,
|
||||||
DatabaseCleanupResult,
|
DatabaseCleanupResult,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
AccessLogIPTrend,
|
AccessLogIPTrend,
|
||||||
AccessLogIPTrendFilters,
|
AccessLogIPTrendFilters,
|
||||||
AccessLogList,
|
AccessLogList,
|
||||||
|
AccessLogOverview,
|
||||||
|
AccessLogOverviewFilters,
|
||||||
FoldedAccessLogFilters,
|
FoldedAccessLogFilters,
|
||||||
FoldedAccessLogIPFilters,
|
FoldedAccessLogIPFilters,
|
||||||
FoldedAccessLogIPList,
|
FoldedAccessLogIPList,
|
||||||
@@ -32,6 +34,12 @@ export class AccessLogService extends OpenFlareBaseService {
|
|||||||
return this.get<AccessLogList>('/', buildSearchParams(filters));
|
return this.get<AccessLogList>('/', buildSearchParams(filters));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getOverview(
|
||||||
|
filters: AccessLogOverviewFilters = {},
|
||||||
|
): Promise<AccessLogOverview> {
|
||||||
|
return this.get<AccessLogOverview>('/overview', buildSearchParams(filters));
|
||||||
|
}
|
||||||
|
|
||||||
static listFolds(
|
static listFolds(
|
||||||
filters: FoldedAccessLogFilters,
|
filters: FoldedAccessLogFilters,
|
||||||
): Promise<FoldedAccessLogList> {
|
): Promise<FoldedAccessLogList> {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export type {
|
|||||||
AccessLogIPTrendFilters,
|
AccessLogIPTrendFilters,
|
||||||
AccessLogItem,
|
AccessLogItem,
|
||||||
AccessLogList,
|
AccessLogList,
|
||||||
|
AccessLogOverview,
|
||||||
|
AccessLogOverviewFilters,
|
||||||
|
AccessLogOverviewMetricPoint,
|
||||||
FoldedAccessLogFilters,
|
FoldedAccessLogFilters,
|
||||||
FoldedAccessLogIPFilters,
|
FoldedAccessLogIPFilters,
|
||||||
FoldedAccessLogIPList,
|
FoldedAccessLogIPList,
|
||||||
|
|||||||
@@ -500,6 +500,35 @@ export interface AccessLogFilters {
|
|||||||
sort_order?: 'asc' | 'desc';
|
sort_order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccessLogOverviewFilters {
|
||||||
|
node_id?: string;
|
||||||
|
host?: string;
|
||||||
|
hours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessLogOverviewMetricPoint {
|
||||||
|
bucket_started_at: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessLogOverview {
|
||||||
|
generated_at: string;
|
||||||
|
hours: number;
|
||||||
|
summary: {
|
||||||
|
total_requests: number;
|
||||||
|
total_visits: number;
|
||||||
|
bandwidth_served: number;
|
||||||
|
};
|
||||||
|
trends: {
|
||||||
|
requests: AccessLogOverviewMetricPoint[];
|
||||||
|
visits: AccessLogOverviewMetricPoint[];
|
||||||
|
bandwidth: AccessLogOverviewMetricPoint[];
|
||||||
|
};
|
||||||
|
top_paths: DistributionItem[];
|
||||||
|
top_hosts: DistributionItem[];
|
||||||
|
top_ips: DistributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessLogItem {
|
export interface AccessLogItem {
|
||||||
id: string;
|
id: string;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
|
|||||||
@@ -178,6 +178,44 @@ type AccessLogIPTrendView struct {
|
|||||||
Points []AccessLogIPTrendPoint `json:"points"`
|
Points []AccessLogIPTrendPoint `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessLogOverviewQuery filters access log overview queries.
|
||||||
|
type AccessLogOverviewQuery struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessLogOverviewMetricPoint is a single overview trend bucket.
|
||||||
|
type AccessLogOverviewMetricPoint struct {
|
||||||
|
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessLogOverviewSummary is the headline totals for the overview window.
|
||||||
|
type AccessLogOverviewSummary struct {
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalVisits int64 `json:"total_visits"`
|
||||||
|
BandwidthServed int64 `json:"bandwidth_served"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessLogOverviewTrends groups sparkline/series data for the overview.
|
||||||
|
type AccessLogOverviewTrends struct {
|
||||||
|
Requests []AccessLogOverviewMetricPoint `json:"requests"`
|
||||||
|
Visits []AccessLogOverviewMetricPoint `json:"visits"`
|
||||||
|
Bandwidth []AccessLogOverviewMetricPoint `json:"bandwidth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessLogOverview is the access-log analytics overview payload.
|
||||||
|
type AccessLogOverview struct {
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
Summary AccessLogOverviewSummary `json:"summary"`
|
||||||
|
Trends AccessLogOverviewTrends `json:"trends"`
|
||||||
|
TopPaths []DistributionItem `json:"top_paths"`
|
||||||
|
TopHosts []DistributionItem `json:"top_hosts"`
|
||||||
|
TopIPs []DistributionItem `json:"top_ips"`
|
||||||
|
}
|
||||||
|
|
||||||
// AccessLogCleanupInput is the cleanup request payload.
|
// AccessLogCleanupInput is the cleanup request payload.
|
||||||
type AccessLogCleanupInput struct {
|
type AccessLogCleanupInput struct {
|
||||||
RetentionDays int `json:"retention_days"`
|
RetentionDays int `json:"retention_days"`
|
||||||
@@ -190,6 +228,142 @@ type AccessLogCleanupResult struct {
|
|||||||
Cutoff time.Time `json:"cutoff"`
|
Cutoff time.Time `json:"cutoff"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAccessLogOverviewHours = 24
|
||||||
|
maxAccessLogOverviewHours = 24 * 30
|
||||||
|
accessLogOverviewTopLimit = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
|
||||||
|
func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*AccessLogOverview, error) {
|
||||||
|
normalized := normalizeAccessLogOverviewQuery(input)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
since := now.Add(-time.Duration(normalized.Hours) * time.Hour)
|
||||||
|
query := model.OpenFlareAccessLogQuery{
|
||||||
|
NodeID: normalized.NodeID,
|
||||||
|
Host: normalized.Host,
|
||||||
|
Since: since,
|
||||||
|
Until: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryRow, err := model.TrafficSummaryOpenFlareAccessLogs(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPoints, visitPoints, bandwidthPoints := buildAccessLogOverviewTrends(
|
||||||
|
ctx, now, normalized.Hours, query,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &AccessLogOverview{
|
||||||
|
GeneratedAt: now,
|
||||||
|
Hours: normalized.Hours,
|
||||||
|
Summary: AccessLogOverviewSummary{
|
||||||
|
TotalRequests: summaryRow.RequestCount,
|
||||||
|
TotalVisits: summaryRow.UniqueIPCount,
|
||||||
|
BandwidthServed: summaryRow.BytesSent,
|
||||||
|
},
|
||||||
|
Trends: AccessLogOverviewTrends{
|
||||||
|
Requests: requestPoints,
|
||||||
|
Visits: visitPoints,
|
||||||
|
Bandwidth: bandwidthPoints,
|
||||||
|
},
|
||||||
|
TopPaths: valueCountDistribution(ctx, query, "path", accessLogOverviewTopLimit),
|
||||||
|
TopHosts: valueCountDistribution(ctx, query, "host", accessLogOverviewTopLimit),
|
||||||
|
TopIPs: valueCountDistribution(ctx, query, "remote_addr", accessLogOverviewTopLimit),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOverviewQuery {
|
||||||
|
hours := input.Hours
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = defaultAccessLogOverviewHours
|
||||||
|
}
|
||||||
|
if hours > maxAccessLogOverviewHours {
|
||||||
|
hours = maxAccessLogOverviewHours
|
||||||
|
}
|
||||||
|
return AccessLogOverviewQuery{
|
||||||
|
NodeID: strings.TrimSpace(input.NodeID),
|
||||||
|
Host: strings.TrimSpace(input.Host),
|
||||||
|
Hours: hours,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueCountDistribution(
|
||||||
|
ctx context.Context,
|
||||||
|
query model.OpenFlareAccessLogQuery,
|
||||||
|
column string,
|
||||||
|
limit int,
|
||||||
|
) []DistributionItem {
|
||||||
|
rows, err := model.ValueCountsOpenFlareAccessLogs(ctx, query, column, limit)
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return []DistributionItem{}
|
||||||
|
}
|
||||||
|
items := make([]DistributionItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if strings.TrimSpace(row.Value) == "" || row.Count <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, DistributionItem{Key: row.Value, Value: row.Count})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAccessLogOverviewTrends(
|
||||||
|
ctx context.Context,
|
||||||
|
now time.Time,
|
||||||
|
hours int,
|
||||||
|
query model.OpenFlareAccessLogQuery,
|
||||||
|
) (
|
||||||
|
requests []AccessLogOverviewMetricPoint,
|
||||||
|
visits []AccessLogOverviewMetricPoint,
|
||||||
|
bandwidth []AccessLogOverviewMetricPoint,
|
||||||
|
) {
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = defaultAccessLogOverviewHours
|
||||||
|
}
|
||||||
|
start := now.Truncate(time.Hour).Add(-time.Duration(hours-1) * time.Hour)
|
||||||
|
requests = make([]AccessLogOverviewMetricPoint, hours)
|
||||||
|
visits = make([]AccessLogOverviewMetricPoint, hours)
|
||||||
|
bandwidth = make([]AccessLogOverviewMetricPoint, hours)
|
||||||
|
for index := range requests {
|
||||||
|
bucketAt := start.Add(time.Duration(index) * time.Hour)
|
||||||
|
requests[index].BucketStartedAt = bucketAt
|
||||||
|
visits[index].BucketStartedAt = bucketAt
|
||||||
|
bandwidth[index].BucketStartedAt = bucketAt
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
||||||
|
NodeID: query.NodeID,
|
||||||
|
Host: query.Host,
|
||||||
|
Since: query.Since,
|
||||||
|
Until: query.Until,
|
||||||
|
FoldMinutes: 60,
|
||||||
|
SortBy: defaultAccessLogSortBy,
|
||||||
|
SortOrder: sortOrderAsc,
|
||||||
|
})
|
||||||
|
if err != nil || len(buckets) == 0 {
|
||||||
|
return requests, visits, bandwidth
|
||||||
|
}
|
||||||
|
byEpoch := make(map[int64]*model.OpenFlareAccessLogBucketRow, len(buckets))
|
||||||
|
for _, row := range buckets {
|
||||||
|
if row == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byEpoch[row.BucketEpoch] = row
|
||||||
|
}
|
||||||
|
for index := range requests {
|
||||||
|
row, ok := byEpoch[requests[index].BucketStartedAt.Unix()]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
requests[index].Value = row.RequestCount
|
||||||
|
visits[index].Value = row.UniqueIPCount
|
||||||
|
bandwidth[index].Value = row.BytesSent
|
||||||
|
}
|
||||||
|
return requests, visits, bandwidth
|
||||||
|
}
|
||||||
|
|
||||||
// ListAccessLogs returns paginated access logs.
|
// ListAccessLogs returns paginated access logs.
|
||||||
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
|
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
|
||||||
normalized := normalizeAccessLogQuery(input)
|
normalized := normalizeAccessLogQuery(input)
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ type diskCounterState struct {
|
|||||||
seen bool
|
seen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func buildTrafficWindowSummaryFromAccessLogs(
|
func buildTrafficWindowSummaryFromAccessLogs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nodeID string,
|
nodeID string,
|
||||||
@@ -333,7 +332,7 @@ func BuildTrafficTrendPointsFromAccessLogs(ctx context.Context, now time.Time, n
|
|||||||
Since: since,
|
Since: since,
|
||||||
Until: now,
|
Until: now,
|
||||||
FoldMinutes: 60,
|
FoldMinutes: 60,
|
||||||
SortBy: "logged_at",
|
SortBy: defaultAccessLogSortBy,
|
||||||
SortOrder: sortOrderAsc,
|
SortOrder: sortOrderAsc,
|
||||||
})
|
})
|
||||||
if err != nil || len(buckets) == 0 {
|
if err != nil || len(buckets) == 0 {
|
||||||
@@ -379,7 +378,7 @@ func applyAccessLogBytesToNetworkTrend(ctx context.Context, now time.Time, nodeI
|
|||||||
Since: since,
|
Since: since,
|
||||||
Until: now,
|
Until: now,
|
||||||
FoldMinutes: 60,
|
FoldMinutes: 60,
|
||||||
SortBy: "logged_at",
|
SortBy: defaultAccessLogSortBy,
|
||||||
SortOrder: sortOrderAsc,
|
SortOrder: sortOrderAsc,
|
||||||
})
|
})
|
||||||
if err != nil || len(buckets) == 0 {
|
if err != nil || len(buckets) == 0 {
|
||||||
|
|||||||
@@ -12,6 +12,33 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetAccessLogOverviewHandler 获取访问日志概览。
|
||||||
|
// @Summary 获取访问日志概览
|
||||||
|
// @Description 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限
|
||||||
|
// @Tags openflare-observability
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param node_id query string false "节点 ID"
|
||||||
|
// @Param host query string false "请求 Host"
|
||||||
|
// @Param hours query int false "统计时间范围(小时)"
|
||||||
|
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
|
||||||
|
// @Failure 400 {object} response.Any "参数错误"
|
||||||
|
// @Failure 401 {object} response.Any "未登录"
|
||||||
|
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||||
|
// @Failure 500 {object} response.Any "内部错误"
|
||||||
|
// @Router /api/v1/d/access-logs/overview [get]
|
||||||
|
func GetAccessLogOverviewHandler(c *gin.Context) {
|
||||||
|
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
|
||||||
|
NodeID: c.Query("node_id"),
|
||||||
|
Host: c.Query("host"),
|
||||||
|
Hours: readQueryInt(c, "hours"),
|
||||||
|
})
|
||||||
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(result))
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccessLogsHandler 分页列出访问日志。
|
// GetAccessLogsHandler 分页列出访问日志。
|
||||||
// @Summary 列出访问日志
|
// @Summary 列出访问日志
|
||||||
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TrafficSummaryOpenFlareAccessLogs(ctx context.Context, query OpenFlareAcces
|
|||||||
return currentAccessLogStore().TrafficSummary(ctx, query)
|
return currentAccessLogStore().TrafficSummary(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValueCountsOpenFlareAccessLogs groups logs by status_code or host.
|
// ValueCountsOpenFlareAccessLogs groups logs by status_code, host, path, or remote_addr.
|
||||||
func ValueCountsOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery, column string, limit int) ([]OpenFlareAccessLogValueCount, error) {
|
func ValueCountsOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery, column string, limit int) ([]OpenFlareAccessLogValueCount, error) {
|
||||||
return currentAccessLogStore().ValueCounts(ctx, query, column, limit)
|
return currentAccessLogStore().ValueCounts(ctx, query, column, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
accessLogColumnStatusCode = "status_code"
|
accessLogColumnStatusCode = "status_code"
|
||||||
accessLogColumnHost = "host"
|
accessLogColumnHost = "host"
|
||||||
|
accessLogColumnPath = "path"
|
||||||
|
accessLogColumnRemoteAddr = "remote_addr"
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryAccessLogStore struct {
|
type memoryAccessLogStore struct {
|
||||||
@@ -473,17 +475,24 @@ func (s *memoryAccessLogStore) ValueCounts(_ context.Context, filter OpenFlareAc
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
col := strings.TrimSpace(strings.ToLower(column))
|
col := strings.TrimSpace(strings.ToLower(column))
|
||||||
if col != accessLogColumnStatusCode && col != accessLogColumnHost {
|
switch col {
|
||||||
|
case accessLogColumnStatusCode, accessLogColumnHost, accessLogColumnPath, accessLogColumnRemoteAddr:
|
||||||
|
default:
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows := s.filterRecords(filter)
|
rows := s.filterRecords(filter)
|
||||||
counts := make(map[string]int64)
|
counts := make(map[string]int64)
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var value string
|
var value string
|
||||||
if col == accessLogColumnStatusCode {
|
switch col {
|
||||||
|
case accessLogColumnStatusCode:
|
||||||
value = strconv.Itoa(row.StatusCode)
|
value = strconv.Itoa(row.StatusCode)
|
||||||
} else {
|
case accessLogColumnHost:
|
||||||
value = strings.TrimSpace(row.Host)
|
value = strings.TrimSpace(row.Host)
|
||||||
|
case accessLogColumnPath:
|
||||||
|
value = strings.TrimSpace(row.Path)
|
||||||
|
case accessLogColumnRemoteAddr:
|
||||||
|
value = strings.TrimSpace(row.RemoteAddr)
|
||||||
}
|
}
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -206,27 +206,19 @@ WHERE %s`, tableName, clause)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
|
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
|
||||||
// Allowed columns: status_code, host.
|
// Allowed columns: status_code, host, path, remote_addr.
|
||||||
func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, column string, limit int) ([]NodeAccessLogValueCount, error) {
|
func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, column string, limit int) ([]NodeAccessLogValueCount, error) {
|
||||||
conn, err := nodeAccessLogConn()
|
conn, err := nodeAccessLogConn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
col := strings.TrimSpace(strings.ToLower(column))
|
col := strings.TrimSpace(strings.ToLower(column))
|
||||||
switch col {
|
valueExpr, ok := nodeAccessLogValueCountExpr(col)
|
||||||
case nodeAccessLogColumnStatusCode, nodeAccessLogColumnHost:
|
if !ok {
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported value count column: %s", column)
|
return nil, fmt.Errorf("unsupported value count column: %s", column)
|
||||||
}
|
}
|
||||||
clause, args := buildNodeAccessLogFilterClause(filter)
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
||||||
tableName := nodeAccessLogTableName()
|
tableName := nodeAccessLogTableName()
|
||||||
// status_code is numeric; cast to string for a uniform Value field.
|
|
||||||
var valueExpr string
|
|
||||||
if col == nodeAccessLogColumnStatusCode {
|
|
||||||
valueExpr = "toString(" + nodeAccessLogColumnStatusCode + ")"
|
|
||||||
} else {
|
|
||||||
valueExpr = "trim(" + nodeAccessLogColumnHost + ")"
|
|
||||||
}
|
|
||||||
sql := fmt.Sprintf(`
|
sql := fmt.Sprintf(`
|
||||||
SELECT %s AS value, count() AS count
|
SELECT %s AS value, count() AS count
|
||||||
FROM %s
|
FROM %s
|
||||||
@@ -259,6 +251,21 @@ ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, valueExpr)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nodeAccessLogValueCountExpr(column string) (string, bool) {
|
||||||
|
switch column {
|
||||||
|
case nodeAccessLogColumnStatusCode:
|
||||||
|
return "toString(" + nodeAccessLogColumnStatusCode + ")", true
|
||||||
|
case nodeAccessLogColumnHost:
|
||||||
|
return "trim(" + nodeAccessLogColumnHost + ")", true
|
||||||
|
case nodeAccessLogColumnPath:
|
||||||
|
return "trim(" + nodeAccessLogColumnPath + ")", true
|
||||||
|
case nodeAccessLogColumnRemoteAddr:
|
||||||
|
return "trim(" + nodeAccessLogColumnRemoteAddr + ")", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NodeAggregatesNodeAccessLogs returns per-node request/error/UV aggregates.
|
// NodeAggregatesNodeAccessLogs returns per-node request/error/UV aggregates.
|
||||||
func NodeAggregatesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]NodeAccessLogNodeAggregate, error) {
|
func NodeAggregatesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]NodeAccessLogNodeAggregate, error) {
|
||||||
conn, err := nodeAccessLogConn()
|
conn, err := nodeAccessLogConn()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
accessLogRoute.Use(apiutil.AdminMiddlewares()...)
|
accessLogRoute.Use(apiutil.AdminMiddlewares()...)
|
||||||
{
|
{
|
||||||
apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler)
|
apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler)
|
||||||
|
accessLogRoute.GET("/overview", observability.GetAccessLogOverviewHandler)
|
||||||
accessLogRoute.GET("/folds", observability.GetFoldedAccessLogsHandler)
|
accessLogRoute.GET("/folds", observability.GetFoldedAccessLogsHandler)
|
||||||
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
|
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
|
||||||
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
|
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ func TestCountryCentroidGermany(t *testing.T) {
|
|||||||
|
|
||||||
func TestCountryCentroidHongKongSingaporeTaiwan(t *testing.T) {
|
func TestCountryCentroidHongKongSingaporeTaiwan(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
iso string
|
iso string
|
||||||
name string
|
name string
|
||||||
minLat float64
|
minLat float64
|
||||||
maxLat float64
|
maxLat float64
|
||||||
minLon float64
|
minLon float64
|
||||||
maxLon float64
|
maxLon float64
|
||||||
}{
|
}{
|
||||||
{iso: "HK", name: "Hong Kong", minLat: 22, maxLat: 23, minLon: 113, maxLon: 115},
|
{iso: "HK", name: "Hong Kong", minLat: 22, maxLat: 23, minLon: 113, maxLon: 115},
|
||||||
{iso: "SG", name: "Singapore", minLat: 1, maxLat: 2, minLon: 103, maxLon: 104},
|
{iso: "SG", name: "Singapore", minLat: 1, maxLat: 2, minLon: 103, maxLon: 104},
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ type NodeMetricSnapshot struct {
|
|||||||
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
||||||
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
||||||
StorageTotalBytes int64 `json:"storage_total_bytes"`
|
StorageTotalBytes int64 `json:"storage_total_bytes"`
|
||||||
DiskReadBytes int64 `json:"disk_read_bytes"`
|
DiskReadBytes int64 `json:"disk_read_bytes"`
|
||||||
DiskWriteBytes int64 `json:"disk_write_bytes"`
|
DiskWriteBytes int64 `json:"disk_write_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeAccessLog is an access log entry from agent (L1 business fact).
|
// NodeAccessLog is an access log entry from agent (L1 business fact).
|
||||||
|
|||||||
Reference in New Issue
Block a user