完善权限监控与安全中心
This commit is contained in:
@@ -20,7 +20,7 @@ from app.models.permission_access_log import PermissionAccessLog
|
|||||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||||
from app.models.security_access_log import SecurityAccessLog
|
from app.models.security_access_log import SecurityAccessLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.ip_location import resolve_ip_location
|
from app.services.ip_location import IpLocation, resolve_ip_location
|
||||||
|
|
||||||
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
||||||
|
|
||||||
@@ -266,17 +266,13 @@ async def get_access_logs(
|
|||||||
).select_from(PermissionAccessLog)
|
).select_from(PermissionAccessLog)
|
||||||
user_stats_query = (
|
user_stats_query = (
|
||||||
select(
|
select(
|
||||||
PermissionAccessLog.user_id,
|
|
||||||
User.full_name,
|
|
||||||
PermissionAccessLog.role,
|
|
||||||
PermissionAccessLog.ip_address.label("sample_ip_address"),
|
PermissionAccessLog.ip_address.label("sample_ip_address"),
|
||||||
func.count().label("total_count"),
|
func.count().label("total_count"),
|
||||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||||
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
||||||
)
|
)
|
||||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
.group_by(PermissionAccessLog.ip_address)
|
||||||
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
|
|
||||||
.order_by(desc("total_count"), desc("last_seen_at"))
|
.order_by(desc("total_count"), desc("last_seen_at"))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
)
|
)
|
||||||
@@ -292,6 +288,26 @@ async def get_access_logs(
|
|||||||
summary_row = summary_result.one()
|
summary_row = summary_result.one()
|
||||||
user_stats_result = await db.execute(user_stats_query)
|
user_stats_result = await db.execute(user_stats_query)
|
||||||
user_stats_rows = user_stats_result.all()
|
user_stats_rows = user_stats_result.all()
|
||||||
|
latest_user_by_ip = {}
|
||||||
|
if user_stats_rows:
|
||||||
|
ranked_ips = [stat.sample_ip_address for stat in user_stats_rows if stat.sample_ip_address]
|
||||||
|
latest_user_query = (
|
||||||
|
select(
|
||||||
|
PermissionAccessLog.ip_address,
|
||||||
|
PermissionAccessLog.user_id,
|
||||||
|
User.full_name,
|
||||||
|
PermissionAccessLog.role,
|
||||||
|
PermissionAccessLog.created_at,
|
||||||
|
)
|
||||||
|
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||||
|
.where(PermissionAccessLog.ip_address.in_(ranked_ips))
|
||||||
|
.order_by(PermissionAccessLog.ip_address, desc(PermissionAccessLog.created_at))
|
||||||
|
)
|
||||||
|
for condition in conditions:
|
||||||
|
latest_user_query = latest_user_query.where(condition)
|
||||||
|
latest_user_result = await db.execute(latest_user_query)
|
||||||
|
for row in latest_user_result.all():
|
||||||
|
latest_user_by_ip.setdefault(row.ip_address, row)
|
||||||
|
|
||||||
query = query.order_by(desc(PermissionAccessLog.created_at))
|
query = query.order_by(desc(PermissionAccessLog.created_at))
|
||||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
@@ -323,11 +339,12 @@ async def get_access_logs(
|
|||||||
user_stats = []
|
user_stats = []
|
||||||
for stat in user_stats_rows:
|
for stat in user_stats_rows:
|
||||||
sample_location = resolve_ip_location(stat.sample_ip_address)
|
sample_location = resolve_ip_location(stat.sample_ip_address)
|
||||||
|
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
|
||||||
user_stats.append(
|
user_stats.append(
|
||||||
{
|
{
|
||||||
"user_id": str(stat.user_id),
|
"user_id": str(latest_user.user_id) if latest_user else "",
|
||||||
"user_name": stat.full_name or "未知用户",
|
"user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
|
||||||
"role": stat.role,
|
"role": latest_user.role if latest_user else "",
|
||||||
"total_count": stat.total_count,
|
"total_count": stat.total_count,
|
||||||
"denied_count": stat.denied_count,
|
"denied_count": stat.denied_count,
|
||||||
"unique_ip_count": stat.unique_ip_count,
|
"unique_ip_count": stat.unique_ip_count,
|
||||||
@@ -361,6 +378,43 @@ def _security_account_label(auth_status: str, user_identifier: str | None, user_
|
|||||||
return "未知账号"
|
return "未知账号"
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_PROBE_MARKERS = (
|
||||||
|
"/.env",
|
||||||
|
".env",
|
||||||
|
"/.git",
|
||||||
|
".git/config",
|
||||||
|
"backup",
|
||||||
|
"config.php",
|
||||||
|
"wp-config",
|
||||||
|
"database.yml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CHINA_IP_COUNTRY_LABELS = {"中国", "China", "Mainland China", "中国香港", "中国澳门", "中国台湾"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_non_china_ip(ip_location: IpLocation) -> bool:
|
||||||
|
country = (ip_location.country or "").strip()
|
||||||
|
return bool(country) and country not in CHINA_IP_COUNTRY_LABELS and not country.startswith("中国")
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
|
||||||
|
if ip_location and _is_non_china_ip(ip_location):
|
||||||
|
return {"category": "ABNORMAL_IP", "severity": "HIGH"}
|
||||||
|
path = (log.path or "").lower()
|
||||||
|
if any(marker in path for marker in SENSITIVE_PROBE_MARKERS):
|
||||||
|
return {"category": "PROBE", "severity": "CRITICAL"}
|
||||||
|
if log.status_code >= 500:
|
||||||
|
return {"category": "SERVER_ERROR", "severity": "HIGH"}
|
||||||
|
if log.auth_status == "INVALID_TOKEN":
|
||||||
|
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
|
||||||
|
if log.auth_status == "ANONYMOUS" and log.status_code in {401, 403}:
|
||||||
|
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
|
||||||
|
if log.status_code == 404:
|
||||||
|
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
|
||||||
|
return {"category": "OTHER", "severity": "LOW"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/security-logs", status_code=status.HTTP_200_OK)
|
@router.get("/security-logs", status_code=status.HTTP_200_OK)
|
||||||
async def get_security_access_logs(
|
async def get_security_access_logs(
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
@@ -418,6 +472,7 @@ async def get_security_access_logs(
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for log in logs:
|
for log in logs:
|
||||||
|
ip_location = resolve_ip_location(log.client_ip)
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"id": str(log.id),
|
"id": str(log.id),
|
||||||
@@ -426,10 +481,16 @@ async def get_security_access_logs(
|
|||||||
"status_code": log.status_code,
|
"status_code": log.status_code,
|
||||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||||
"client_ip": log.client_ip,
|
"client_ip": log.client_ip,
|
||||||
|
"ip_location": ip_location.location,
|
||||||
|
"ip_country": ip_location.country,
|
||||||
|
"ip_province": ip_location.province,
|
||||||
|
"ip_city": ip_location.city,
|
||||||
|
"ip_isp": ip_location.isp,
|
||||||
"user_agent": log.user_agent,
|
"user_agent": log.user_agent,
|
||||||
"auth_status": log.auth_status,
|
"auth_status": log.auth_status,
|
||||||
"user_identifier": log.user_identifier,
|
"user_identifier": log.user_identifier,
|
||||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||||
|
**_classify_security_access_log(log, ip_location),
|
||||||
"created_at": log.created_at.isoformat(),
|
"created_at": log.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -623,14 +684,26 @@ async def get_ip_locations(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||||
|
security_result = await db.execute(
|
||||||
|
select(
|
||||||
|
SecurityAccessLog.client_ip,
|
||||||
|
SecurityAccessLog.user_identifier,
|
||||||
|
SecurityAccessLog.auth_status,
|
||||||
|
SecurityAccessLog.status_code,
|
||||||
|
).where(
|
||||||
|
SecurityAccessLog.created_at >= start_time,
|
||||||
|
SecurityAccessLog.client_ip.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
buckets: dict[tuple[str, str, str], dict] = {}
|
buckets: dict[tuple[str, str, str], dict] = {}
|
||||||
all_ip_addresses: set[str] = set()
|
all_ip_addresses: set[str] = set()
|
||||||
all_user_ids: set[uuid.UUID] = set()
|
all_user_ids: set[str] = set()
|
||||||
total_count = 0
|
total_count = 0
|
||||||
allowed_count = 0
|
allowed_count = 0
|
||||||
denied_count = 0
|
denied_count = 0
|
||||||
|
|
||||||
for ip_address, user_id, allowed in result.all():
|
def add_ip_location_row(ip_address: str, user_id: str | uuid.UUID | None, allowed: bool) -> None:
|
||||||
|
nonlocal total_count, allowed_count, denied_count
|
||||||
ip_info = resolve_ip_location(ip_address)
|
ip_info = resolve_ip_location(ip_address)
|
||||||
key = (ip_info.country, ip_info.province, ip_info.city)
|
key = (ip_info.country, ip_info.province, ip_info.city)
|
||||||
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
|
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
|
||||||
@@ -652,14 +725,28 @@ async def get_ip_locations(
|
|||||||
bucket["total_count"] += 1
|
bucket["total_count"] += 1
|
||||||
bucket["allowed_count" if allowed else "denied_count"] += 1
|
bucket["allowed_count" if allowed else "denied_count"] += 1
|
||||||
bucket["ip_addresses"].add(ip_address)
|
bucket["ip_addresses"].add(ip_address)
|
||||||
bucket["user_ids"].add(user_id)
|
if user_id:
|
||||||
|
bucket["user_ids"].add(str(user_id))
|
||||||
total_count += 1
|
total_count += 1
|
||||||
if allowed:
|
if allowed:
|
||||||
allowed_count += 1
|
allowed_count += 1
|
||||||
else:
|
else:
|
||||||
denied_count += 1
|
denied_count += 1
|
||||||
all_ip_addresses.add(ip_address)
|
all_ip_addresses.add(ip_address)
|
||||||
all_user_ids.add(user_id)
|
if user_id:
|
||||||
|
all_user_ids.add(str(user_id))
|
||||||
|
|
||||||
|
for ip_address, user_id, allowed in result.all():
|
||||||
|
add_ip_location_row(ip_address, user_id, allowed)
|
||||||
|
|
||||||
|
for client_ip, user_identifier, auth_status, status_code in security_result.all():
|
||||||
|
user_id = None
|
||||||
|
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||||
|
try:
|
||||||
|
user_id = uuid.UUID(user_identifier)
|
||||||
|
except ValueError:
|
||||||
|
user_id = user_identifier
|
||||||
|
add_ip_location_row(client_ip, user_id, status_code < 400)
|
||||||
|
|
||||||
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
|
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""监控API测试:权限系统监控API端点验证。"""
|
"""监控API测试:权限系统监控API端点验证。"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,12 +13,12 @@ from app.api.v1.system_permissions import list_system_permissions
|
|||||||
|
|
||||||
|
|
||||||
class FakeIpInfo:
|
class FakeIpInfo:
|
||||||
def __init__(self, province: str, city: str, isp: str = "电信") -> None:
|
def __init__(self, province: str, city: str, isp: str = "电信", country: str = "中国") -> None:
|
||||||
self.country = "中国"
|
self.country = country
|
||||||
self.province = province
|
self.province = province
|
||||||
self.city = city
|
self.city = city
|
||||||
self.isp = isp
|
self.isp = isp
|
||||||
self.location = f"中国 / {province} / {city} / {isp}"
|
self.location = " / ".join(part for part in [country, province, city, isp] if part)
|
||||||
|
|
||||||
|
|
||||||
class AdminUserStub:
|
class AdminUserStub:
|
||||||
@@ -526,6 +527,117 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
|||||||
assert result["items"][0]["city"] == ""
|
assert result["items"][0]["city"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch):
|
||||||
|
"""来源分析应同时统计安全事件中的异常来源 IP。"""
|
||||||
|
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||||
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
study_id = "00000000-0000-0000-0000-000000000801"
|
||||||
|
user_id = "00000000-0000-0000-0000-000000000901"
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||||
|
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": study_id,
|
||||||
|
"code": "IP-LOCATION-SECURITY-STUDY",
|
||||||
|
"name": "IP Location Security Study",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"is_locked": False,
|
||||||
|
"visit_schedule": "[]",
|
||||||
|
"active_roles": "[]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||||
|
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": user_id,
|
||||||
|
"email": "source-analysis@example.com",
|
||||||
|
"password_hash": "hash",
|
||||||
|
"full_name": "来源分析用户",
|
||||||
|
"clinical_department": "临床运营",
|
||||||
|
"is_admin": False,
|
||||||
|
"status": "ACTIVE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_access_logs
|
||||||
|
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||||
|
VALUES
|
||||||
|
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"study_id": study_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"endpoint_key": "admin.permissions.read",
|
||||||
|
"role": "PM",
|
||||||
|
"allowed": True,
|
||||||
|
"elapsed_ms": 3.2,
|
||||||
|
"ip_address": "10.3.1.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for ip_address in ["8.8.8.8", "1.1.1.1"]:
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO security_access_logs
|
||||||
|
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||||
|
VALUES
|
||||||
|
(:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status, :user_identifier, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin",
|
||||||
|
"status_code": 403,
|
||||||
|
"elapsed_ms": 8.5,
|
||||||
|
"client_ip": ip_address,
|
||||||
|
"user_agent": "pytest",
|
||||||
|
"auth_status": "ANONYMOUS",
|
||||||
|
"user_identifier": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
ip_info_by_address = {
|
||||||
|
"10.3.1.1": FakeIpInfo("", "", "", "局域网"),
|
||||||
|
"8.8.8.8": FakeIpInfo("California", "Mountain View", "Google", "United States"),
|
||||||
|
"1.1.1.1": FakeIpInfo("Queensland", "Brisbane", "Cloudflare", "Australia"),
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
permission_monitoring,
|
||||||
|
"resolve_ip_location",
|
||||||
|
lambda ip: ip_info_by_address[ip],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||||
|
locations = {item["country"]: item for item in result["items"]}
|
||||||
|
|
||||||
|
assert result["summary"]["total_count"] == 3
|
||||||
|
assert result["summary"]["denied_count"] == 2
|
||||||
|
assert result["summary"]["unique_ip_count"] == 3
|
||||||
|
assert result["summary"]["unique_user_count"] == 1
|
||||||
|
assert locations["United States"]["total_count"] == 1
|
||||||
|
assert locations["United States"]["denied_count"] == 1
|
||||||
|
assert locations["Australia"]["total_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_access_logs_include_user_behavior_summary(db_session):
|
async def test_access_logs_include_user_behavior_summary(db_session):
|
||||||
"""访问日志应返回用户行为审计汇总和用户排行。"""
|
"""访问日志应返回用户行为审计汇总和用户排行。"""
|
||||||
@@ -637,10 +749,121 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
|||||||
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_logs_user_stats_query_avoids_postgresql_uuid_max():
|
||||||
|
"""访问日志用户统计不能对 UUID 字段使用 max,PostgreSQL 不支持 max(uuid)。"""
|
||||||
|
source = inspect.getsource(permission_monitoring.get_access_logs)
|
||||||
|
|
||||||
|
assert "func.max(PermissionAccessLog.user_id)" not in source
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
||||||
|
"""IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。"""
|
||||||
|
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
study_id = "00000000-0000-0000-0000-000000001201"
|
||||||
|
user_a = "00000000-0000-0000-0000-000000001301"
|
||||||
|
user_b = "00000000-0000-0000-0000-000000001302"
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||||
|
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": study_id,
|
||||||
|
"code": "ACCESS-IP-RANK-STUDY",
|
||||||
|
"name": "Access IP Rank Study",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"is_locked": False,
|
||||||
|
"visit_schedule": "[]",
|
||||||
|
"active_roles": "[]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for user_id, email, name in [
|
||||||
|
(user_a, "ip-rank-a@example.com", "IP排行用户A"),
|
||||||
|
(user_b, "ip-rank-b@example.com", "IP排行用户B"),
|
||||||
|
]:
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||||
|
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"password_hash": "hash",
|
||||||
|
"full_name": name,
|
||||||
|
"clinical_department": "临床运营",
|
||||||
|
"is_admin": False,
|
||||||
|
"status": "ACTIVE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(user_a, "183.230.169.20", True),
|
||||||
|
(user_a, "183.230.169.20", True),
|
||||||
|
(user_b, "183.230.169.20", False),
|
||||||
|
(user_a, "45.148.10.95", False),
|
||||||
|
(user_b, "52.53.218.145", False),
|
||||||
|
]
|
||||||
|
for user, ip_address, allowed in rows:
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_access_logs
|
||||||
|
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||||
|
VALUES
|
||||||
|
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"study_id": study_id,
|
||||||
|
"user_id": user,
|
||||||
|
"endpoint_key": "admin.permissions.read",
|
||||||
|
"role": "CRA",
|
||||||
|
"allowed": allowed,
|
||||||
|
"elapsed_ms": 4.0,
|
||||||
|
"ip_address": ip_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await permission_monitoring.get_access_logs(
|
||||||
|
db=db_session,
|
||||||
|
_=AdminUserStub(),
|
||||||
|
study_id=None,
|
||||||
|
user_id=None,
|
||||||
|
endpoint_key=None,
|
||||||
|
role=None,
|
||||||
|
allowed=None,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
page=1,
|
||||||
|
page_size=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["user_stats"][0]["sample_ip_address"] == "183.230.169.20"
|
||||||
|
assert result["user_stats"][0]["total_count"] == 3
|
||||||
|
assert result["user_stats"][0]["denied_count"] == 1
|
||||||
|
assert {stat["sample_ip_address"] for stat in result["user_stats"][1:]} == {"52.53.218.145", "45.148.10.95"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_security_access_logs_include_anonymous_ip_attempts(db_session, monkeypatch):
|
||||||
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
||||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
permission_monitoring,
|
||||||
|
"resolve_ip_location",
|
||||||
|
lambda ip: FakeIpInfo("上海市", "上海市", "联通"),
|
||||||
|
)
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -667,5 +890,113 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
|||||||
assert result["summary"]["anonymous_count"] == 1
|
assert result["summary"]["anonymous_count"] == 1
|
||||||
assert result["summary"]["error_count"] == 1
|
assert result["summary"]["error_count"] == 1
|
||||||
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
||||||
|
assert result["items"][0]["ip_location"] == "中国 / 上海市 / 上海市 / 联通"
|
||||||
|
assert result["items"][0]["ip_country"] == "中国"
|
||||||
|
assert result["items"][0]["ip_province"] == "上海市"
|
||||||
|
assert result["items"][0]["ip_city"] == "上海市"
|
||||||
|
assert result["items"][0]["ip_isp"] == "联通"
|
||||||
assert result["items"][0]["account_label"] == "未知账号"
|
assert result["items"][0]["account_label"] == "未知账号"
|
||||||
assert result["items"][0]["status_code"] == 401
|
assert result["items"][0]["status_code"] == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_security_access_logs_classify_sensitive_path_probe(db_session):
|
||||||
|
"""安全中心应把敏感路径探测识别为严重安全事件。"""
|
||||||
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO security_access_logs
|
||||||
|
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||||
|
VALUES
|
||||||
|
('00000000-0000-4000-8000-000000000502', 'GET', '/api/.git/config', 404, 0.7,
|
||||||
|
'198.51.100.20', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await permission_monitoring.get_security_access_logs(
|
||||||
|
db=db_session,
|
||||||
|
_=AdminUserStub(),
|
||||||
|
status_min=400,
|
||||||
|
auth_status=None,
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["items"][0]["category"] == "PROBE"
|
||||||
|
assert result["items"][0]["severity"] == "CRITICAL"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch):
|
||||||
|
"""安全事件明细应把非中国公网 IP 归类为异常 IP。"""
|
||||||
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
permission_monitoring,
|
||||||
|
"resolve_ip_location",
|
||||||
|
lambda ip: FakeIpInfo("California", "San Jose", "Google", "United States"),
|
||||||
|
)
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO security_access_logs
|
||||||
|
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||||
|
VALUES
|
||||||
|
('00000000-0000-4000-8000-000000000504', 'GET', '/api/v1/studies/', 401, 8.4,
|
||||||
|
'52.53.218.145', 'foreign-client/1.0', 'INVALID_TOKEN', NULL, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await permission_monitoring.get_security_access_logs(
|
||||||
|
db=db_session,
|
||||||
|
_=AdminUserStub(),
|
||||||
|
status_min=400,
|
||||||
|
auth_status=None,
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
item = result["items"][0]
|
||||||
|
assert item["category"] == "ABNORMAL_IP"
|
||||||
|
assert item["severity"] == "HIGH"
|
||||||
|
assert item["ip_location"] == "United States / California / San Jose / Google"
|
||||||
|
assert item["ip_country"] == "United States"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db_session):
|
||||||
|
"""安全中心公网 IP 应复用 ip2region 解析结果,避免前端只能显示未知位置。"""
|
||||||
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO security_access_logs
|
||||||
|
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||||
|
VALUES
|
||||||
|
('00000000-0000-4000-8000-000000000503', 'GET', '/api/v1/auth/login', 404, 1.2,
|
||||||
|
'52.53.218.145', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await permission_monitoring.get_security_access_logs(
|
||||||
|
db=db_session,
|
||||||
|
_=AdminUserStub(),
|
||||||
|
status_min=400,
|
||||||
|
auth_status=None,
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
item = result["items"][0]
|
||||||
|
assert item["client_ip"] == "52.53.218.145"
|
||||||
|
assert item["ip_location"] not in {"", "公网", "未知"}
|
||||||
|
assert item["ip_country"] == "United States"
|
||||||
|
assert item["ip_province"] == "California"
|
||||||
|
assert item["ip_city"] == "San Jose"
|
||||||
|
assert item["category"] == "ABNORMAL_IP"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -57,6 +57,16 @@ describe("PermissionAccessLogs", () => {
|
|||||||
expect(source).not.toContain("筛选范围内行为总量");
|
expect(source).not.toContain("筛选范围内行为总量");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps audit grid widths consistent with SecurityCenter", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
||||||
|
expect(source).toContain("@media (max-width: 1100px)");
|
||||||
|
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
|
||||||
|
expect(source).toContain("@media (max-width: 720px)");
|
||||||
|
expect(source).toContain("grid-template-columns: 1fr;");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes the duplicated audit hero copy", () => {
|
it("removes the duplicated audit hero copy", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
@@ -65,6 +75,7 @@ describe("PermissionAccessLogs", () => {
|
|||||||
expect(source).not.toContain("权限监控 / 访问日志");
|
expect(source).not.toContain("权限监控 / 访问日志");
|
||||||
expect(source).not.toContain("用户行为审计");
|
expect(source).not.toContain("用户行为审计");
|
||||||
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
|
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
|
||||||
|
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
||||||
@@ -85,14 +96,56 @@ describe("PermissionAccessLogs", () => {
|
|||||||
|
|
||||||
expect(source).toContain("ip=${ip}");
|
expect(source).toContain("ip=${ip}");
|
||||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||||
|
expect(source).toContain("ipRankingItems");
|
||||||
expect(source).toContain("IP访问排行");
|
expect(source).toContain("IP访问排行");
|
||||||
expect(source).toContain("账号");
|
expect(source).toContain("账号");
|
||||||
expect(source).toContain("rank-location");
|
expect(source).toContain("rank-location");
|
||||||
|
expect(source).not.toContain("TOP {{ userStats.length }}");
|
||||||
expect(source).not.toContain("rank-ip-line");
|
expect(source).not.toContain("rank-ip-line");
|
||||||
expect(source).not.toContain("来源IP");
|
expect(source).not.toContain("来源IP");
|
||||||
expect(source).not.toContain("去重IP");
|
expect(source).not.toContain("去重IP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
|
||||||
|
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
|
||||||
|
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
|
||||||
|
expect(source).toContain("securityLogs.value.forEach((event) =>");
|
||||||
|
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
|
||||||
|
expect(source).toContain('ABNORMAL_IP: "异常IP"');
|
||||||
|
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
|
||||||
|
expect(source).toContain("TOP {{ ipRankingItems.length }}");
|
||||||
|
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
|
||||||
|
expect(source).toContain("异常IP</el-tag>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts IP ranking by overall access total before risk labels", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("existing.total += user.total_count;");
|
||||||
|
expect(source).toContain("existing.riskCount += user.denied_count;");
|
||||||
|
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
|
||||||
|
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||||
|
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
|
||||||
|
);
|
||||||
|
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||||
|
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads all security pages for complete abnormal IP ranking data", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
|
||||||
|
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
|
||||||
|
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
|
||||||
|
expect(source).toContain("allItems.push(...res.data.items);");
|
||||||
|
expect(source).toContain("securityLogs.value = allItems;");
|
||||||
|
expect(source).not.toContain("page_size: 80");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
@@ -111,6 +164,16 @@ describe("PermissionAccessLogs", () => {
|
|||||||
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows interface audit log total instead of current page line count", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("{{ formatNumber(total) }} 条");
|
||||||
|
expect(source).toContain("共 {{ formatNumber(total) }} 条");
|
||||||
|
expect(source).toContain(":total=\"total\"");
|
||||||
|
expect(source).not.toContain("{{ terminalLines.length }} 条");
|
||||||
|
expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
|
||||||
|
});
|
||||||
|
|
||||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
|
|||||||
@@ -44,21 +44,23 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div class="section-title-group">
|
<div class="section-title-group">
|
||||||
<h4>IP访问排行</h4>
|
<h4>IP访问排行</h4>
|
||||||
<p>按来源 IP 排序,账号作为辅助定位</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
|
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingItems.length }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userStats.length" class="user-rank-list">
|
<div v-if="ipRankingItems.length" class="user-rank-list">
|
||||||
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
|
<div v-for="(item, index) in ipRankingItems" :key="item.ip" class="user-rank-row" :class="{ 'abnormal-rank-row': item.isAbnormal }">
|
||||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||||
<div class="rank-user">
|
<div class="rank-user">
|
||||||
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
|
<strong>
|
||||||
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
|
{{ item.ip }}
|
||||||
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
|
<el-tag v-if="item.isAbnormal" class="rank-risk-tag" effect="plain" size="small" type="danger">异常IP</el-tag>
|
||||||
|
</strong>
|
||||||
|
<small>{{ item.label }}</small>
|
||||||
|
<span class="rank-location">{{ item.location }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rank-count">
|
<div class="rank-count">
|
||||||
<strong>{{ user.total_count }}</strong>
|
<strong>{{ item.total }}</strong>
|
||||||
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
|
<small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +71,6 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div class="section-title-group">
|
<div class="section-title-group">
|
||||||
<h4>日志审计</h4>
|
<h4>日志审计</h4>
|
||||||
<p>查看详细的接口访问与安全事件记录</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-actions">
|
<div class="log-actions">
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
<strong>接口访问审计日志</strong>
|
<strong>接口访问审计日志</strong>
|
||||||
<span>记录所有 API 接口的访问行为</span>
|
<span>记录所有 API 接口的访问行为</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
<el-tag effect="dark" size="small" round>{{ formatNumber(total) }} 条</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||||
<div class="log-action-icon security">
|
<div class="log-action-icon security">
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
<div class="dialog-head">
|
<div class="dialog-head">
|
||||||
<strong>接口访问审计日志</strong>
|
<strong>接口访问审计日志</strong>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} 条</el-tag>
|
<el-tag effect="plain" type="info">共 {{ formatNumber(total) }} 条</el-tag>
|
||||||
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +181,8 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const REALTIME_POLL_INTERVAL_MS = 3000;
|
const REALTIME_POLL_INTERVAL_MS = 3000;
|
||||||
|
const IP_RANKING_LIMIT = 10;
|
||||||
|
const SECURITY_RANKING_PAGE_SIZE = 200;
|
||||||
|
|
||||||
const emptySummary: AccessLogsSummary = {
|
const emptySummary: AccessLogsSummary = {
|
||||||
total_count: 0,
|
total_count: 0,
|
||||||
@@ -228,6 +231,26 @@ const SECURITY_AUTH_LABELS: Record<string, string> = {
|
|||||||
AUTHENTICATED: "已认证",
|
AUTHENTICATED: "已认证",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SECURITY_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
ABNORMAL_IP: "异常IP",
|
||||||
|
PROBE: "敏感路径探测",
|
||||||
|
INVALID_TOKEN: "无效令牌",
|
||||||
|
SERVER_ERROR: "服务异常",
|
||||||
|
ANONYMOUS_API: "匿名 API",
|
||||||
|
NOT_FOUND_NOISE: "普通 404",
|
||||||
|
OTHER: "其他",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IpRankingItem {
|
||||||
|
ip: string;
|
||||||
|
location: string;
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
riskCount: number;
|
||||||
|
isAbnormal: boolean;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const formatTerminalTime = (iso: string) => {
|
const formatTerminalTime = (iso: string) => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
@@ -239,6 +262,13 @@ const formatIpLocation = (row: AccessLogItem) => {
|
|||||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatSecurityIpLocation = (row: SecurityAccessLogItem) => {
|
||||||
|
const parts = [row.ip_country && row.ip_country !== "中国" ? row.ip_country : "", row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryLabel = (category: string) => SECURITY_CATEGORY_LABELS[category] || category || "其他";
|
||||||
|
|
||||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||||
|
|
||||||
const metricCards = computed(() => [
|
const metricCards = computed(() => [
|
||||||
@@ -304,6 +334,77 @@ const securityTerminalLines = computed(() => {
|
|||||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ipRankingItems = computed<IpRankingItem[]>(() => {
|
||||||
|
const rankingByIp = new Map<string, IpRankingItem>();
|
||||||
|
|
||||||
|
userStats.value.forEach((user) => {
|
||||||
|
const ip = user.sample_ip_address || "未知 IP";
|
||||||
|
const existing = rankingByIp.get(ip);
|
||||||
|
const lastSeenAt =
|
||||||
|
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(user.last_seen_at || 0).getTime()
|
||||||
|
? existing.lastSeenAt
|
||||||
|
: user.last_seen_at;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
rankingByIp.set(ip, {
|
||||||
|
ip,
|
||||||
|
location: user.primary_location || "未知属地",
|
||||||
|
label: `${user.user_name || "未知用户"} / ${roleLabel(user.role)}`,
|
||||||
|
total: user.total_count,
|
||||||
|
riskCount: user.denied_count,
|
||||||
|
isAbnormal: false,
|
||||||
|
lastSeenAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.total += user.total_count;
|
||||||
|
existing.riskCount += user.denied_count;
|
||||||
|
existing.lastSeenAt = lastSeenAt;
|
||||||
|
if (existing.location === "未知属地" && user.primary_location) existing.location = user.primary_location;
|
||||||
|
});
|
||||||
|
|
||||||
|
securityLogs.value.forEach((event) => {
|
||||||
|
const ip = event.client_ip || "未知 IP";
|
||||||
|
const existing = rankingByIp.get(ip);
|
||||||
|
const isAbnormal = event.category === "ABNORMAL_IP";
|
||||||
|
const location = formatSecurityIpLocation(event) || existing?.location || "未知属地";
|
||||||
|
const lastSeenAt =
|
||||||
|
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(event.created_at).getTime()
|
||||||
|
? existing.lastSeenAt
|
||||||
|
: event.created_at;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
rankingByIp.set(ip, {
|
||||||
|
ip,
|
||||||
|
location,
|
||||||
|
label: isAbnormal ? categoryLabel(event.category) : `${categoryLabel(event.category)} / ${event.account_label || "未知账号"}`,
|
||||||
|
total: 1,
|
||||||
|
riskCount: isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0,
|
||||||
|
isAbnormal,
|
||||||
|
lastSeenAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.location = location;
|
||||||
|
existing.total += 1;
|
||||||
|
existing.riskCount += isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0;
|
||||||
|
existing.isAbnormal = existing.isAbnormal || isAbnormal;
|
||||||
|
existing.lastSeenAt = lastSeenAt;
|
||||||
|
if (isAbnormal) existing.label = categoryLabel(event.category);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...rankingByIp.values()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.total !== a.total) return b.total - a.total;
|
||||||
|
if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;
|
||||||
|
if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);
|
||||||
|
return new Date(b.lastSeenAt || 0).getTime() - new Date(a.lastSeenAt || 0).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, IP_RANKING_LIMIT);
|
||||||
|
});
|
||||||
|
|
||||||
const onFilterChange = () => {
|
const onFilterChange = () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
loadData();
|
loadData();
|
||||||
@@ -363,9 +464,15 @@ const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
|||||||
if (!showSecurityLog.value) return;
|
if (!showSecurityLog.value) return;
|
||||||
if (!options.silent) securityLoading.value = true;
|
if (!options.silent) securityLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||||
securityLogs.value = res.data.items;
|
const allItems = [...first.data.items];
|
||||||
securitySummary.value = res.data.summary || emptySecuritySummary;
|
const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);
|
||||||
|
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||||
|
const res = await fetchSecurityAccessLogs({ status_min: 400, page: nextPage, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||||
|
allItems.push(...res.data.items);
|
||||||
|
}
|
||||||
|
securityLogs.value = allItems;
|
||||||
|
securitySummary.value = first.data.summary || emptySecuritySummary;
|
||||||
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
||||||
} catch {
|
} catch {
|
||||||
if (options.silent) return;
|
if (options.silent) return;
|
||||||
@@ -551,7 +658,7 @@ defineExpose({ refresh });
|
|||||||
|
|
||||||
.audit-grid {
|
.audit-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 380px minmax(0, 1fr);
|
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +739,10 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rank-user strong {
|
.rank-user strong {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
color: var(--audit-ink);
|
color: var(--audit-ink);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -667,6 +778,14 @@ defineExpose({ refresh });
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.abnormal-rank-row {
|
||||||
|
background: #fff7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-risk-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.log-actions {
|
.log-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -798,17 +917,15 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.audit-metrics {
|
.audit-metrics,
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-grid {
|
.audit-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.audit-metrics {
|
.audit-metrics,
|
||||||
|
.audit-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
|
|||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
||||||
|
const readWorldMapSource = () => readFileSync(resolve(__dirname, "./worldMapSource.ts"), "utf8");
|
||||||
|
|
||||||
describe("PermissionIpLocations", () => {
|
describe("PermissionIpLocations", () => {
|
||||||
it("renders IP location visualization without duplicate region detail table", () => {
|
it("renders IP location visualization without duplicate region detail table", () => {
|
||||||
@@ -20,20 +21,228 @@ describe("PermissionIpLocations", () => {
|
|||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("VChart");
|
expect(source).toContain("VChart");
|
||||||
expect(source).toContain("chinaMapOption");
|
expect(source).toContain("sourceMapOption");
|
||||||
expect(source).toContain("访问来源分布");
|
expect(source).toContain("来源分布");
|
||||||
expect(source).toContain("访问次数");
|
expect(source).toContain("访问次数");
|
||||||
expect(source).toContain("访问用户数");
|
expect(source).toContain("访问用户数");
|
||||||
expect(source).toContain("来源 IP 数");
|
expect(source).toContain("来源 IP 数");
|
||||||
expect(source).toContain("summary");
|
expect(source).toContain("summary");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults the access source period to 90 days", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const days = ref(90);");
|
||||||
|
expect(source).not.toContain("const days = ref(7);");
|
||||||
|
expect(source).toContain('<el-radio-button :value="90">90天</el-radio-button>');
|
||||||
|
expect(source).toContain('90: "近 90 天"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overlay the source distribution card with a loading mask", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('<section class="distribution-card">');
|
||||||
|
expect(source).toContain('<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">');
|
||||||
|
expect(source).not.toContain('<section v-loading="loading" class="distribution-card">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports switching source distribution between China and global flow views without the global heat map", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const worldMapSource = readWorldMapSource();
|
||||||
|
|
||||||
|
expect(source).toContain("../assets/world.json");
|
||||||
|
expect(source).toContain('registerMap("ctms-world"');
|
||||||
|
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
||||||
|
expect(source).toContain("mapViewOptions");
|
||||||
|
expect(source).toContain("<el-segmented v-model=\"mapView\"");
|
||||||
|
expect(source).toContain("sourceMapOption");
|
||||||
|
expect(source).toContain('label: "中国"');
|
||||||
|
expect(source).toContain('label: "全球"');
|
||||||
|
expect(source).not.toContain('label: "全球", value: "world"');
|
||||||
|
expect(source).not.toContain("worldData");
|
||||||
|
expect(source).not.toContain("buildMapOption");
|
||||||
|
expect(worldMapSource).toContain("unpkg.com/echarts@4.9.0/map/json/world.json");
|
||||||
|
expect(worldMapSource).toContain("ECharts 世界地图 GeoJSON");
|
||||||
|
expect(worldMapSource).toContain('"United States": "United States"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a global access flow view pointing sources to the server location", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const worldMapSource = readWorldMapSource();
|
||||||
|
|
||||||
|
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
||||||
|
expect(source).toContain('label: "全球"');
|
||||||
|
expect(source).toContain("LinesChart");
|
||||||
|
expect(source).toContain("EffectScatterChart");
|
||||||
|
expect(source).toContain("GeoComponent");
|
||||||
|
expect(source).toContain("buildFlowMapOption");
|
||||||
|
expect(source).toContain("serverLocation");
|
||||||
|
expect(source).toContain("resolveSourceCoordinate");
|
||||||
|
expect(source).toContain('coordinateSystem: "geo"');
|
||||||
|
expect(source).toContain("coords: [point.coord, serverLocation.coord]");
|
||||||
|
expect(source).toContain("服务器所在地");
|
||||||
|
expect(worldMapSource).toContain("resolveSourceCoordinate");
|
||||||
|
expect(worldMapSource).toContain('"United States": [-95.7129, 37.0902]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a China access flow map for the China view", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const worldMapSource = readWorldMapSource();
|
||||||
|
|
||||||
|
expect(source).toContain("chinaFlowSourceData");
|
||||||
|
expect(source).toContain('buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路")');
|
||||||
|
expect(source).not.toContain('buildMapOption("ctms-china", provinceData.value, 1.08)');
|
||||||
|
expect(source).toContain('mapView.value === "china" ? chinaFlowSourceData.value');
|
||||||
|
expect(worldMapSource).toContain('"江苏省": [118.7633, 32.0617]');
|
||||||
|
expect(worldMapSource).toContain('"上海市": [121.4737, 31.2304]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same source rows for the global flow map and the source ranking", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const worldMapSource = readWorldMapSource();
|
||||||
|
|
||||||
|
expect(source).toContain("flowSourceData");
|
||||||
|
expect(source).toContain('mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value');
|
||||||
|
expect(source).toContain("buildFlowMapOption(flowSourceData.value)");
|
||||||
|
expect(source).not.toContain("buildFlowMapOption(worldData.value)");
|
||||||
|
expect(source).toContain('const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world"');
|
||||||
|
expect(source).toContain("coord: resolveSourceCoordinate(item)");
|
||||||
|
expect(source).toContain("name: formatLocation(point)");
|
||||||
|
expect(worldMapSource).toContain("cityCoordinates");
|
||||||
|
expect(worldMapSource).toContain('"重庆市": [106.5516, 29.563]');
|
||||||
|
expect(worldMapSource).toContain('"南京市": [118.7969, 32.0603]');
|
||||||
|
expect(worldMapSource).toContain('"Istanbul": [28.9784, 41.0082]');
|
||||||
|
expect(worldMapSource).toContain('"South Holland": [4.493, 52.0208]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps global flow lines thin and readable", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const lineWidth = point.abnormal ? 1.1 : 0.8");
|
||||||
|
expect(source).toContain("width: lineWidth");
|
||||||
|
expect(source).toContain("opacity: point.abnormal ? 0.58 : 0.38");
|
||||||
|
expect(source).not.toContain("width: Math.min(5");
|
||||||
|
expect(source).not.toContain("Math.sqrt(point.total_count");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps domestic flow markers compact so nearby China locations stay visible", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const isDomestic = !params?.data?.abnormal");
|
||||||
|
expect(source).toContain("const maxSize = isDomestic ? 12 : 16");
|
||||||
|
expect(source).toContain("const minSize = isDomestic ? 5 : 7");
|
||||||
|
expect(source).toContain("scale: 1.7");
|
||||||
|
expect(source).toContain("scale: 1.5");
|
||||||
|
expect(source).toContain("symbolSize: 9");
|
||||||
|
expect(source).not.toContain("scale: 3");
|
||||||
|
expect(source).not.toContain("scale: 2.4");
|
||||||
|
expect(source).not.toContain("symbolSize: 14");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a compact flow legend and keeps the server tooltip address-only", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("source-map-wrap");
|
||||||
|
expect(source).toContain("fullscreen-map-wrap");
|
||||||
|
expect(source).toContain("flow-legend");
|
||||||
|
expect(source).toContain("map-legend");
|
||||||
|
expect(source).toContain("服务器");
|
||||||
|
expect(source).toContain("正常访问点");
|
||||||
|
expect(source).toContain("异常访问点");
|
||||||
|
expect(source).toContain('class="legend-dot server"');
|
||||||
|
expect(source).toContain('class="legend-dot normal"');
|
||||||
|
expect(source).toContain('class="legend-dot abnormal"');
|
||||||
|
expect(source).toContain('if (row.isServer) return "中国 / 上海";');
|
||||||
|
expect(source).not.toContain('if (row.isServer) return `${serverLocation.name}<br/>中国 / 上海`;');
|
||||||
|
expect(source).not.toContain("fullscreen-legend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports fullscreen preview for the current source map", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("fullscreenVisible");
|
||||||
|
expect(source).toContain("openFullscreenPreview");
|
||||||
|
expect(source).toContain("resizeFullscreenChart");
|
||||||
|
expect(source).toContain("<el-dialog");
|
||||||
|
expect(source).toContain("fullscreen");
|
||||||
|
expect(source).toContain("destroy-on-close");
|
||||||
|
expect(source).toContain(':close-on-click-modal="false"');
|
||||||
|
expect(source).toContain('aria-label="全屏预览"');
|
||||||
|
expect(source).toContain("fullscreenMapKey");
|
||||||
|
expect(source).toContain("fullscreen-source-map");
|
||||||
|
expect(source).toContain("全屏预览");
|
||||||
|
expect(source).toContain("退出全屏");
|
||||||
|
expect(source).toContain(":option=\"sourceMapOption\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises canvas pixel ratio for sharper fullscreen map rendering", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("getDevicePixelRatio");
|
||||||
|
expect(source).toContain("chartInitOptions");
|
||||||
|
expect(source).toContain("fullscreenChartInitOptions");
|
||||||
|
expect(source).toContain(":init-options=\"chartInitOptions\"");
|
||||||
|
expect(source).toContain(":init-options=\"fullscreenChartInitOptions\"");
|
||||||
|
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
||||||
|
expect(source).toContain("Math.min(getDevicePixelRatio() * 1.5, 3)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a flexible full-page layout so the source map fills the remaining viewport", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("height: 100%");
|
||||||
|
expect(source).toContain("min-height: 0");
|
||||||
|
expect(source).toContain("flex: 1 1 auto");
|
||||||
|
expect(source).toContain("grid-template-rows: minmax(0, 1fr)");
|
||||||
|
expect(source).toContain("height: 100%");
|
||||||
|
expect(source).toContain("overflow-y: auto");
|
||||||
|
expect(source).not.toContain("height: 320px");
|
||||||
|
expect(source).not.toContain("min-height: 430px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents horizontal scrolling in the source ranking panel", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain(".rank-panel");
|
||||||
|
expect(source).toContain("overflow: hidden");
|
||||||
|
expect(source).toContain("overflow-x: hidden");
|
||||||
|
expect(source).toContain("overflow-y: auto");
|
||||||
|
expect(source).toContain("box-sizing: border-box");
|
||||||
|
expect(source).toContain("grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content)");
|
||||||
|
expect(source).toContain("max-width: 100%");
|
||||||
|
expect(source).toContain("text-overflow: ellipsis");
|
||||||
|
expect(source).toContain("text-align: right");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates the chart when switching map modes to avoid stale geo state", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain(':key="mapView"');
|
||||||
|
expect(source).toContain(':update-options="chartUpdateOptions"');
|
||||||
|
expect(source).toContain("const chartUpdateOptions = { notMerge: true }");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes the source map after data load and keeps the chart container full width", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('ref="sourceChartRef"');
|
||||||
|
expect(source).toContain("const sourceChartRef = ref<{ resize?: () => void } | null>(null)");
|
||||||
|
expect(source).toContain("const resizeSourceCharts = async () =>");
|
||||||
|
expect(source).toContain("sourceChartRef.value?.resize?.()");
|
||||||
|
expect(source).toContain("await resizeSourceCharts()");
|
||||||
|
expect(source).toContain("defineExpose({ refresh: loadData, resize: resizeSourceCharts })");
|
||||||
|
expect(source).not.toContain(".source-map :deep(> div)");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses source-analysis copy instead of IP-location tab wording", () => {
|
it("uses source-analysis copy instead of IP-location tab wording", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("访问来源分析");
|
expect(source).toContain("访问来源分析");
|
||||||
expect(source).toContain("来源分布");
|
expect(source).toContain("来源分布");
|
||||||
expect(source).toContain("热点来源地");
|
expect(source).toContain("热点来源地");
|
||||||
|
expect(source).not.toContain("查看访问来源的地理分布");
|
||||||
|
expect(source).not.toContain("访问来源地理分布热力图");
|
||||||
|
expect(source).not.toContain("按访问次数排序");
|
||||||
expect(source).not.toContain("IP 属地分析");
|
expect(source).not.toContain("IP 属地分析");
|
||||||
expect(source).not.toContain("中国地图");
|
expect(source).not.toContain("中国地图");
|
||||||
expect(source).not.toContain("热点属地");
|
expect(source).not.toContain("热点属地");
|
||||||
@@ -50,23 +259,58 @@ describe("PermissionIpLocations", () => {
|
|||||||
expect(mapSource).not.toContain("provinceBoxes");
|
expect(mapSource).not.toContain("provinceBoxes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the map legend and disables map zoom interactions", () => {
|
it("removes heat-map visual legends and keeps map zoom interactions disabled", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("visualMap:");
|
expect(source).not.toContain("visualMap:");
|
||||||
expect(source).toContain("VisualMapComponent");
|
expect(source).not.toContain("VisualMapComponent");
|
||||||
expect(source).toContain("max: maxValue.value");
|
expect(source).not.toContain("hasMapData");
|
||||||
|
expect(source).not.toContain("maxValue");
|
||||||
|
expect(source).not.toContain('color: ["#93c5fd", "#60a5fa", "#3b82f6", "#2563eb", "#1d4ed8"]');
|
||||||
|
expect(source).not.toContain("outOfRange:");
|
||||||
expect(source).toContain("roam: false");
|
expect(source).toContain("roam: false");
|
||||||
expect(source).not.toContain("scaleLimit");
|
expect(source).not.toContain("scaleLimit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a stronger base map style so borders remain visible behind flow lines", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('areaColor: "#e6edf8"');
|
||||||
|
expect(source).toContain('borderColor: "#aebed2"');
|
||||||
|
expect(source).toContain("borderWidth: 1");
|
||||||
|
expect(source).toContain('shadowColor: "rgba(37, 99, 235, 0.08)"');
|
||||||
|
expect(source).toContain("shadowBlur: 10");
|
||||||
|
expect(source).toContain("shadowOffsetY: 2");
|
||||||
|
expect(source).toContain('areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3');
|
||||||
|
expect(source).not.toContain('areaColor: "#eef4ff"');
|
||||||
|
expect(source).not.toContain('borderColor: "#dbe7f7"');
|
||||||
|
expect(source).not.toContain("borderWidth: 0.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps geo hover interaction while avoiding empty white tooltip panels", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const formatGeoTooltip = (params: any) =>");
|
||||||
|
expect(source).toContain('return name || "暂无访问数据";');
|
||||||
|
expect(source).toContain('trigger: "item"');
|
||||||
|
expect(source).toContain("confine: true");
|
||||||
|
expect(source).toContain("formatter: formatGeoTooltip");
|
||||||
|
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatGeoTooltip,\n }");
|
||||||
|
expect(source).toContain("formatLineTooltip");
|
||||||
|
expect(source).toContain("formatPointTooltip");
|
||||||
|
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatLineTooltip,\n }");
|
||||||
|
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatPointTooltip,\n }");
|
||||||
|
expect(source).not.toContain("silent: true");
|
||||||
|
expect(source).not.toContain("tooltip: { show: false }");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses narrow metric cards without helper subtitles", () => {
|
it("uses narrow metric cards without helper subtitles", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("min-height: 64px");
|
expect(source).toContain("min-height: 64px");
|
||||||
expect(source).toContain("padding: 10px 16px");
|
expect(source).toContain("padding: 10px 16px");
|
||||||
expect(source).toContain("right: -46px");
|
expect(source).toContain("right: -34px");
|
||||||
expect(source).toContain("bottom: -52px");
|
expect(source).toContain("bottom: -42px");
|
||||||
expect(source).toContain("border-radius: 18px");
|
expect(source).toContain("border-radius: 18px");
|
||||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||||
expect(source).not.toContain("hint:");
|
expect(source).not.toContain("hint:");
|
||||||
@@ -84,11 +328,20 @@ describe("PermissionIpLocations", () => {
|
|||||||
expect(source).not.toContain("eyebrow");
|
expect(source).not.toContain("eyebrow");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places the period filter toolbar on the left", () => {
|
it("places the period filter toolbar on the right", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain(".ip-hero");
|
expect(source).toContain(".ip-hero");
|
||||||
expect(source).toContain("justify-content: flex-start");
|
expect(source).toContain("justify-content: space-between");
|
||||||
expect(source).not.toContain("justify-content: flex-end");
|
expect(source).toContain("margin-left: auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-China source labels visible and lists top 10 regions", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('row.country && row.country !== "中国" ? row.country : ""');
|
||||||
|
expect(source).toContain("row.isp");
|
||||||
|
expect(source).toContain('parts.join(" / ")');
|
||||||
|
expect(source).toContain(".slice(0, 10)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<div class="ip-locations-toolbar">
|
<div class="ip-locations-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="toolbar-title">访问来源分析</span>
|
<span class="toolbar-title">访问来源分析</span>
|
||||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
<el-radio-button :value="30">30天</el-radio-button>
|
<el-radio-button :value="30">30天</el-radio-button>
|
||||||
<el-radio-button :value="90">90天</el-radio-button>
|
<el-radio-button :value="90">90天</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-button :loading="loading" size="small" @click="loadData">
|
<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -33,23 +32,44 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="distribution-card">
|
<section class="distribution-card">
|
||||||
<div class="map-panel">
|
<div class="map-panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div class="section-title-group">
|
<div class="section-title-group">
|
||||||
<h4>来源分布</h4>
|
<h4>来源分布</h4>
|
||||||
<p>访问来源地理分布热力图</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
<div class="map-head-actions">
|
||||||
|
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
||||||
|
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||||
|
<el-tooltip content="全屏预览" placement="top">
|
||||||
|
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="source-map-wrap">
|
||||||
|
<v-chart
|
||||||
|
ref="sourceChartRef"
|
||||||
|
:key="mapView"
|
||||||
|
:option="sourceMapOption"
|
||||||
|
:init-options="chartInitOptions"
|
||||||
|
:update-options="chartUpdateOptions"
|
||||||
|
autoresize
|
||||||
|
class="source-map"
|
||||||
|
/>
|
||||||
|
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||||
|
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||||
|
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||||
|
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<v-chart :option="chinaMapOption" autoresize class="china-map" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="rank-panel">
|
<aside class="rank-panel">
|
||||||
<div class="section-head compact">
|
<div class="section-head compact">
|
||||||
<div class="section-title-group">
|
<div class="section-title-group">
|
||||||
<h4>热点来源地</h4>
|
<h4>热点来源地</h4>
|
||||||
<p>按访问次数排序</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="topRegions.length" class="region-list">
|
<div v-if="topRegions.length" class="region-list">
|
||||||
@@ -63,23 +83,65 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="fullscreenVisible"
|
||||||
|
class="source-map-dialog"
|
||||||
|
fullscreen
|
||||||
|
destroy-on-close
|
||||||
|
:show-close="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
append-to-body
|
||||||
|
@opened="resizeFullscreenChart"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="fullscreen-map-header">
|
||||||
|
<div class="fullscreen-title-group">
|
||||||
|
<h3>来源分布全屏预览</h3>
|
||||||
|
<span>{{ currentPeriodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fullscreen-actions">
|
||||||
|
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
||||||
|
<el-button size="small" @click="fullscreenVisible = false">退出全屏</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="fullscreen-map-wrap">
|
||||||
|
<v-chart
|
||||||
|
ref="fullscreenChartRef"
|
||||||
|
:key="fullscreenMapKey"
|
||||||
|
:option="sourceMapOption"
|
||||||
|
:init-options="fullscreenChartInitOptions"
|
||||||
|
:update-options="chartUpdateOptions"
|
||||||
|
autoresize
|
||||||
|
class="fullscreen-source-map"
|
||||||
|
/>
|
||||||
|
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||||
|
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||||
|
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||||
|
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, ref } from "vue";
|
import { computed, h, nextTick, onMounted, ref } from "vue";
|
||||||
import VChart from "vue-echarts";
|
import VChart from "vue-echarts";
|
||||||
import { registerMap, use } from "echarts/core";
|
import { registerMap, use } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { MapChart } from "echarts/charts";
|
import { EffectScatterChart, LinesChart } from "echarts/charts";
|
||||||
import { TooltipComponent, VisualMapComponent } from "echarts/components";
|
import { GeoComponent, TooltipComponent } from "echarts/components";
|
||||||
import chinaMapGeoJson from "../assets/china.json";
|
import chinaMapGeoJson from "../assets/china.json";
|
||||||
|
import worldMapGeoJson from "../assets/world.json";
|
||||||
import { fetchIpLocations } from "../api/projectPermissions";
|
import { fetchIpLocations } from "../api/projectPermissions";
|
||||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
import { resolveSourceCoordinate } from "./worldMapSource";
|
||||||
|
|
||||||
use([CanvasRenderer, MapChart, TooltipComponent, VisualMapComponent]);
|
use([CanvasRenderer, LinesChart, EffectScatterChart, GeoComponent, TooltipComponent]);
|
||||||
registerMap("ctms-china", chinaMapGeoJson as any);
|
registerMap("ctms-china", chinaMapGeoJson as any);
|
||||||
|
registerMap("ctms-world", worldMapGeoJson as any);
|
||||||
|
|
||||||
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||||
@@ -103,9 +165,22 @@ const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke:
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const days = ref(7);
|
const days = ref(90);
|
||||||
|
const mapView = ref<"china" | "flow">("china");
|
||||||
const items = ref<IpLocationStatItem[]>([]);
|
const items = ref<IpLocationStatItem[]>([]);
|
||||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||||
|
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
||||||
|
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2) };
|
||||||
|
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio() * 1.5, 3) };
|
||||||
|
const chartUpdateOptions = { notMerge: true };
|
||||||
|
const fullscreenVisible = ref(false);
|
||||||
|
const sourceChartRef = ref<{ resize?: () => void } | null>(null);
|
||||||
|
const fullscreenChartRef = ref<{ resize?: () => void } | null>(null);
|
||||||
|
|
||||||
|
const serverLocation = {
|
||||||
|
name: "服务器所在地",
|
||||||
|
coord: [121.4737, 31.2304] as [number, number],
|
||||||
|
};
|
||||||
|
|
||||||
const periodLabels: Record<number, string> = {
|
const periodLabels: Record<number, string> = {
|
||||||
1: "近 24 小时",
|
1: "近 24 小时",
|
||||||
@@ -115,11 +190,31 @@ const periodLabels: Record<number, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||||
|
const mapViewOptions = [
|
||||||
|
{ label: "中国", value: "china" },
|
||||||
|
{ label: "全球", value: "flow" },
|
||||||
|
];
|
||||||
|
const fullscreenMapKey = computed(() => `fullscreen-${mapView.value}-${fullscreenVisible.value ? "open" : "closed"}`);
|
||||||
|
|
||||||
|
const openFullscreenPreview = () => {
|
||||||
|
fullscreenVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeFullscreenChart = async () => {
|
||||||
|
await nextTick();
|
||||||
|
fullscreenChartRef.value?.resize?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeSourceCharts = async () => {
|
||||||
|
await nextTick();
|
||||||
|
sourceChartRef.value?.resize?.();
|
||||||
|
fullscreenChartRef.value?.resize?.();
|
||||||
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||||
|
|
||||||
const formatLocation = (row: IpLocationStatItem) => {
|
const formatLocation = (row: IpLocationStatItem) => {
|
||||||
const parts = [row.province, row.city].filter(Boolean);
|
const parts = [row.country && row.country !== "中国" ? row.country : "", row.province, row.city, row.isp].filter(Boolean);
|
||||||
return parts.length ? parts.join(" / ") : row.location;
|
return parts.length ? parts.join(" / ") : row.location;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,95 +261,203 @@ const metricCards = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const provinceData = computed(() => {
|
const rankedSourceRows = computed(() => [...items.value].sort((a, b) => b.total_count - a.total_count).slice(0, 10));
|
||||||
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
|
|
||||||
items.value.forEach((item) => {
|
|
||||||
const name = normalizeProvinceName(item.province || item.city);
|
|
||||||
if (!name || item.country !== "中国") return;
|
|
||||||
const current = provinceMap.get(name);
|
|
||||||
if (!current) {
|
|
||||||
provinceMap.set(name, { ...item, name });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
current.total_count += item.total_count;
|
|
||||||
current.allowed_count += item.allowed_count;
|
|
||||||
current.denied_count += item.denied_count;
|
|
||||||
current.unique_ip_count += item.unique_ip_count;
|
|
||||||
current.unique_user_count += item.unique_user_count || 0;
|
|
||||||
});
|
|
||||||
return Array.from(provinceMap.values());
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxValue = computed(() => {
|
const isChinaSource = (item: IpLocationStatItem) => item.location === "局域网" || item.country === "中国";
|
||||||
const values = provinceData.value.map((item) => item.total_count);
|
|
||||||
return values.length ? Math.max(...values) : 100;
|
|
||||||
});
|
|
||||||
|
|
||||||
const chinaMapOption = computed(() => ({
|
const chinaFlowSourceData = computed(() => rankedSourceRows.value.filter((item) => isChinaSource(item) && resolveSourceCoordinate(item)));
|
||||||
tooltip: {
|
|
||||||
trigger: "item",
|
const flowSourceData = computed(() => rankedSourceRows.value.filter((item) => resolveSourceCoordinate(item)));
|
||||||
formatter: (params: any) => {
|
|
||||||
const data = params.data;
|
const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world", zoom = 1.2, seriesName = "全球访问链路") => {
|
||||||
if (!data) return `${params.name}<br/>暂无访问数据`;
|
const points = data
|
||||||
return [
|
.map((item) => ({
|
||||||
`${params.name}`,
|
...item,
|
||||||
`访问次数:${formatNumber(data.value)}`,
|
name: formatLocation(item),
|
||||||
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
|
coord: resolveSourceCoordinate(item),
|
||||||
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
|
abnormal: Boolean(item.country && item.country !== "中国"),
|
||||||
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
|
}))
|
||||||
].join("<br/>");
|
.filter((item): item is IpLocationStatItem & { name: string; coord: [number, number]; abnormal: boolean } => Boolean(item.coord));
|
||||||
|
|
||||||
|
const formatLineTooltip = (params: any) => {
|
||||||
|
const row = params.data;
|
||||||
|
if (!row) return "";
|
||||||
|
return [
|
||||||
|
`${row.fromName} → ${row.toName}`,
|
||||||
|
`访问次数:${formatNumber(row.value || 0)}`,
|
||||||
|
row.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||||
|
].join("<br/>");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPointTooltip = (params: any) => {
|
||||||
|
const row = params.data;
|
||||||
|
if (!row) return "";
|
||||||
|
if (row.isServer) return "中国 / 上海";
|
||||||
|
return [
|
||||||
|
`${row.name}`,
|
||||||
|
`访问次数:${formatNumber(row.total_count || 0)}`,
|
||||||
|
`访问用户数:${formatNumber(row.unique_user_count || 0)}`,
|
||||||
|
`来源 IP 数:${formatNumber(row.unique_ip_count || 0)}`,
|
||||||
|
`拒绝次数:${formatNumber(row.denied_count || 0)}`,
|
||||||
|
row.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||||
|
].join("<br/>");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGeoTooltip = (params: any) => {
|
||||||
|
const name = String(params?.name || "").trim();
|
||||||
|
return name || "暂无访问数据";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
confine: true,
|
||||||
|
formatter: formatGeoTooltip,
|
||||||
},
|
},
|
||||||
},
|
geo: {
|
||||||
visualMap: {
|
map: mapName,
|
||||||
min: 0,
|
|
||||||
max: maxValue.value,
|
|
||||||
left: "left",
|
|
||||||
bottom: "20",
|
|
||||||
text: ["高", "低"],
|
|
||||||
inRange: {
|
|
||||||
color: ["#edf3ff", "#a5c4fd", "#6399f7", "#3b82f6", "#1d4ed8"],
|
|
||||||
},
|
|
||||||
show: true,
|
|
||||||
calculable: false,
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: "访问来源分布",
|
|
||||||
type: "map",
|
|
||||||
map: "ctms-china",
|
|
||||||
roam: false,
|
roam: false,
|
||||||
zoom: 1.08,
|
tooltip: {
|
||||||
|
show: true,
|
||||||
|
formatter: formatGeoTooltip,
|
||||||
|
},
|
||||||
|
zoom,
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: "#edf3ff",
|
areaColor: "#e6edf8",
|
||||||
borderColor: "#ffffff",
|
borderColor: "#aebed2",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
shadowColor: "rgba(37, 99, 235, 0.08)",
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetY: 2,
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
label: { show: false },
|
||||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
itemStyle: { areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3 },
|
||||||
},
|
},
|
||||||
data: provinceData.value.map((item) => ({
|
|
||||||
name: item.name,
|
|
||||||
value: item.total_count,
|
|
||||||
unique_user_count: item.unique_user_count,
|
|
||||||
unique_ip_count: item.unique_ip_count,
|
|
||||||
denied_count: item.denied_count,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
series: [
|
||||||
}));
|
{
|
||||||
|
name: seriesName,
|
||||||
|
type: "lines",
|
||||||
|
coordinateSystem: "geo",
|
||||||
|
zlevel: 2,
|
||||||
|
tooltip: {
|
||||||
|
show: true,
|
||||||
|
formatter: formatLineTooltip,
|
||||||
|
},
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 4,
|
||||||
|
trailLength: 0.18,
|
||||||
|
symbol: "arrow",
|
||||||
|
symbolSize: 6,
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
width: 1,
|
||||||
|
opacity: 0.28,
|
||||||
|
curveness: 0.2,
|
||||||
|
},
|
||||||
|
data: points
|
||||||
|
.filter((point) => point.name !== serverLocation.name)
|
||||||
|
.map((point) => {
|
||||||
|
const lineWidth = point.abnormal ? 1.1 : 0.8;
|
||||||
|
return {
|
||||||
|
fromName: point.name,
|
||||||
|
toName: serverLocation.name,
|
||||||
|
value: point.total_count,
|
||||||
|
abnormal: point.abnormal,
|
||||||
|
coords: [point.coord, serverLocation.coord],
|
||||||
|
lineStyle: {
|
||||||
|
color: point.abnormal ? "#f97316" : "#2563eb",
|
||||||
|
width: lineWidth,
|
||||||
|
opacity: point.abnormal ? 0.58 : 0.38,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "访问来源",
|
||||||
|
type: "effectScatter",
|
||||||
|
coordinateSystem: "geo",
|
||||||
|
zlevel: 3,
|
||||||
|
tooltip: {
|
||||||
|
show: true,
|
||||||
|
formatter: formatPointTooltip,
|
||||||
|
},
|
||||||
|
rippleEffect: {
|
||||||
|
brushType: "stroke",
|
||||||
|
scale: 1.7,
|
||||||
|
},
|
||||||
|
symbolSize: (value: number[], params: any) => {
|
||||||
|
const isDomestic = !params?.data?.abnormal;
|
||||||
|
const maxSize = isDomestic ? 12 : 16;
|
||||||
|
const minSize = isDomestic ? 5 : 7;
|
||||||
|
return Math.min(maxSize, Math.max(minSize, Math.sqrt(value[2] || 1) * 2));
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: any) => (params.data.abnormal ? "#f97316" : "#2563eb"),
|
||||||
|
shadowBlur: 6,
|
||||||
|
shadowColor: "rgba(37, 99, 235, 0.2)",
|
||||||
|
},
|
||||||
|
data: points.map((point) => ({
|
||||||
|
name: formatLocation(point),
|
||||||
|
value: [...point.coord, point.total_count],
|
||||||
|
total_count: point.total_count,
|
||||||
|
unique_user_count: point.unique_user_count,
|
||||||
|
unique_ip_count: point.unique_ip_count,
|
||||||
|
denied_count: point.denied_count,
|
||||||
|
abnormal: point.abnormal,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: serverLocation.name,
|
||||||
|
type: "effectScatter",
|
||||||
|
coordinateSystem: "geo",
|
||||||
|
zlevel: 4,
|
||||||
|
tooltip: {
|
||||||
|
show: true,
|
||||||
|
formatter: formatPointTooltip,
|
||||||
|
},
|
||||||
|
rippleEffect: {
|
||||||
|
brushType: "fill",
|
||||||
|
scale: 1.5,
|
||||||
|
},
|
||||||
|
symbolSize: 9,
|
||||||
|
itemStyle: {
|
||||||
|
color: "#16a34a",
|
||||||
|
shadowBlur: 6,
|
||||||
|
shadowColor: "rgba(22, 163, 74, 0.24)",
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: serverLocation.name,
|
||||||
|
value: [...serverLocation.coord, 1],
|
||||||
|
isServer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceMapOption = computed(() =>
|
||||||
|
mapView.value === "flow"
|
||||||
|
? buildFlowMapOption(flowSourceData.value)
|
||||||
|
: buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleRankRows = computed(() =>
|
||||||
|
mapView.value === "china" ? chinaFlowSourceData.value : mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value,
|
||||||
|
);
|
||||||
|
|
||||||
const topRegions = computed(() =>
|
const topRegions = computed(() =>
|
||||||
[...items.value]
|
visibleRankRows.value.map((item, index) => ({
|
||||||
.sort((a, b) => b.total_count - a.total_count)
|
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
||||||
.slice(0, 6)
|
rank: String(index + 1).padStart(2, "0"),
|
||||||
.map((item, index) => ({
|
name: formatLocation(item),
|
||||||
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
total_count: formatNumber(item.total_count),
|
||||||
rank: String(index + 1).padStart(2, "0"),
|
})),
|
||||||
name: formatLocation(item),
|
|
||||||
total_count: formatNumber(item.total_count),
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -271,9 +474,12 @@ const loadData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(loadData);
|
onMounted(async () => {
|
||||||
|
await loadData();
|
||||||
|
await resizeSourceCharts();
|
||||||
|
});
|
||||||
|
|
||||||
defineExpose({ refresh: loadData });
|
defineExpose({ refresh: loadData, resize: resizeSourceCharts });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -291,6 +497,8 @@ defineExpose({ refresh: loadData });
|
|||||||
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +513,7 @@ defineExpose({ refresh: loadData });
|
|||||||
.ip-locations-toolbar {
|
.ip-locations-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -330,7 +538,9 @@ defineExpose({ refresh: loadData });
|
|||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
@@ -357,8 +567,8 @@ defineExpose({ refresh: loadData });
|
|||||||
.metric-card::after {
|
.metric-card::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -46px;
|
right: -34px;
|
||||||
bottom: -52px;
|
bottom: -42px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -432,7 +642,10 @@ defineExpose({ refresh: loadData });
|
|||||||
.distribution-card {
|
.distribution-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
background: var(--ip-card);
|
background: var(--ip-card);
|
||||||
border: 1px solid var(--ip-border);
|
border: 1px solid var(--ip-border);
|
||||||
border-radius: var(--ip-radius);
|
border-radius: var(--ip-radius);
|
||||||
@@ -443,12 +656,20 @@ defineExpose({ refresh: loadData });
|
|||||||
.map-panel,
|
.map-panel,
|
||||||
.rank-panel {
|
.rank-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
border: 1px solid #f1f5f9;
|
border: 1px solid #f1f5f9;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-panel,
|
||||||
|
.rank-panel,
|
||||||
|
.source-map-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -461,6 +682,72 @@ defineExpose({ refresh: loadData });
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-width: 72%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-fullscreen-button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--ip-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.server {
|
||||||
|
background: #16a34a;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.normal {
|
||||||
|
background: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.abnormal {
|
||||||
|
background: #f97316;
|
||||||
|
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
.section-title-group h4 {
|
.section-title-group h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ip-ink);
|
color: var(--ip-ink);
|
||||||
@@ -474,9 +761,89 @@ defineExpose({ refresh: loadData });
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.china-map {
|
.source-map-wrap,
|
||||||
|
.fullscreen-map-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 430px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.source-map-dialog) {
|
||||||
|
--el-dialog-padding-primary: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.source-map-dialog .el-dialog__header) {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.source-map-dialog .el-dialog__body) {
|
||||||
|
height: calc(100vh - 74px);
|
||||||
|
padding: 0 22px 22px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-map-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-bottom: 1px solid var(--ip-border);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-title-group h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ip-ink);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-title-group span {
|
||||||
|
color: var(--ip-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-source-map {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 96px);
|
||||||
|
min-height: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-list {
|
.region-list {
|
||||||
@@ -486,12 +853,15 @@ defineExpose({ refresh: loadData });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.region-row {
|
.region-row {
|
||||||
|
box-sizing: border-box;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
max-width: 100%;
|
||||||
|
padding: 12px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #f1f5f9;
|
border: 1px solid #f1f5f9;
|
||||||
@@ -515,6 +885,7 @@ defineExpose({ refresh: loadData });
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank.top {
|
.rank.top {
|
||||||
@@ -523,6 +894,7 @@ defineExpose({ refresh: loadData });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.region-name {
|
.region-name {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -531,9 +903,14 @@ defineExpose({ refresh: loadData });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.region-value {
|
.region-value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--ip-blue);
|
color: var(--ip-blue);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -544,20 +921,72 @@ defineExpose({ refresh: loadData });
|
|||||||
.distribution-card {
|
.distribution-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-panel,
|
||||||
|
.rank-panel {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.ip-locations-toolbar {
|
.ip-locations-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.china-map {
|
.source-map {
|
||||||
height: 320px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-head-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.source-map-dialog .el-dialog__body) {
|
||||||
|
height: calc(100vh - 126px);
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-map-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 126px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-source-map {
|
||||||
|
height: calc(100vh - 144px);
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
PermissionTrendCharts: true,
|
PermissionTrendCharts: true,
|
||||||
PermissionAccessLogs: true,
|
PermissionAccessLogs: true,
|
||||||
PermissionIpLocations: true,
|
PermissionIpLocations: true,
|
||||||
|
SecurityCenter: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -64,6 +65,7 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
PermissionTrendCharts: true,
|
PermissionTrendCharts: true,
|
||||||
PermissionAccessLogs: true,
|
PermissionAccessLogs: true,
|
||||||
PermissionIpLocations: true,
|
PermissionIpLocations: true,
|
||||||
|
SecurityCenter: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -78,13 +80,38 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
expect(source).toContain(':show-security-log="props.isAdmin"');
|
expect(source).toContain(':show-security-log="props.isAdmin"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
|
||||||
|
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain(":class=\"{ 'is-source-tab': activeTab === 'ip-locations' }\"");
|
||||||
|
expect(source).toContain('class="ip-locations-pane"');
|
||||||
|
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||||
|
expect(source).toContain("overflow: hidden");
|
||||||
|
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||||
|
expect(source).toContain("overflow: auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes the source-analysis chart when its tab becomes active", () => {
|
||||||
|
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain('import { ref, computed, h, nextTick, onMounted } from "vue"');
|
||||||
|
expect(source).toContain("type IpLocationsExpose");
|
||||||
|
expect(source).toContain("resize: () => void | Promise<void>");
|
||||||
|
expect(source).toContain('if (tab === "ip-locations")');
|
||||||
|
expect(source).toContain("await nextTick()");
|
||||||
|
expect(source).toContain("await ipLocationsRef.value?.resize()");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses system monitoring tab names and operation-oriented overview copy", () => {
|
it("uses system monitoring tab names and operation-oriented overview copy", () => {
|
||||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||||
|
|
||||||
expect(source).toContain('label="运行概览"');
|
expect(source).toContain('label="运行概览"');
|
||||||
|
expect(source).toContain('label="安全中心"');
|
||||||
expect(source).toContain('label="性能趋势"');
|
expect(source).toContain('label="性能趋势"');
|
||||||
expect(source).toContain('label="访问审计"');
|
expect(source).toContain('label="访问审计"');
|
||||||
expect(source).toContain('label="来源分析"');
|
expect(source).toContain('label="来源分析"');
|
||||||
|
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
||||||
|
expect(source).toContain('name="security"');
|
||||||
expect(source).toContain('label: "今日监测事件"');
|
expect(source).toContain('label: "今日监测事件"');
|
||||||
expect(source).toContain('label: "每分钟事件"');
|
expect(source).toContain('label: "每分钟事件"');
|
||||||
expect(source).toContain('label: "今日通过率"');
|
expect(source).toContain('label: "今日通过率"');
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="permission-monitoring">
|
<div class="permission-monitoring" :class="{ 'is-source-tab': activeTab === 'ip-locations' }">
|
||||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
<el-tabs v-model="activeTab" class="soybean-monitoring-tabs" @tab-change="onTabChange">
|
||||||
<el-tab-pane label="运行概览" name="overview">
|
<el-tab-pane label="运行概览" name="overview">
|
||||||
|
<template #label>
|
||||||
|
<span class="soybean-tab-label">
|
||||||
|
<component :is="IconCheck" />
|
||||||
|
<span>运行概览</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<div v-if="statsSummary" class="stats-summary">
|
<div v-if="statsSummary" class="stats-summary">
|
||||||
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
|
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
|
||||||
<div class="summary-icon">
|
<div class="summary-icon">
|
||||||
@@ -122,22 +128,52 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="性能趋势" name="trends">
|
<el-tab-pane label="性能趋势" name="trends">
|
||||||
|
<template #label>
|
||||||
|
<span class="soybean-tab-label">
|
||||||
|
<component :is="IconActivity" />
|
||||||
|
<span>性能趋势</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<PermissionTrendCharts ref="trendChartsRef" />
|
<PermissionTrendCharts ref="trendChartsRef" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security">
|
||||||
|
<template #label>
|
||||||
|
<span class="soybean-tab-label">
|
||||||
|
<component :is="IconShield" />
|
||||||
|
<span>安全中心</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<SecurityCenter v-if="props.isAdmin" ref="securityCenterRef" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="访问审计" name="logs">
|
<el-tab-pane label="访问审计" name="logs">
|
||||||
|
<template #label>
|
||||||
|
<span class="soybean-tab-label">
|
||||||
|
<component :is="IconDatabase" />
|
||||||
|
<span>访问审计</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
|
<PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="来源分析" name="ip-locations">
|
<el-tab-pane label="来源分析" name="ip-locations">
|
||||||
<PermissionIpLocations ref="ipLocationsRef" />
|
<template #label>
|
||||||
|
<span class="soybean-tab-label">
|
||||||
|
<component :is="IconPercent" />
|
||||||
|
<span>来源分析</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="ip-locations-pane">
|
||||||
|
<PermissionIpLocations ref="ipLocationsRef" />
|
||||||
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, h, onMounted } from "vue";
|
import { ref, computed, h, nextTick, onMounted } from "vue";
|
||||||
import type {
|
import type {
|
||||||
PermissionMetricsResponse,
|
PermissionMetricsResponse,
|
||||||
AlertsResponse,
|
AlertsResponse,
|
||||||
@@ -155,6 +191,7 @@ import {
|
|||||||
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
|
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
|
||||||
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
|
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
|
||||||
import PermissionIpLocations from "./PermissionIpLocations.vue";
|
import PermissionIpLocations from "./PermissionIpLocations.vue";
|
||||||
|
import SecurityCenter from "./SecurityCenter.vue";
|
||||||
|
|
||||||
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
h("polyline", { points: "20 6 9 17 4 12" }),
|
h("polyline", { points: "20 6 9 17 4 12" }),
|
||||||
@@ -176,6 +213,9 @@ const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke
|
|||||||
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
|
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
|
||||||
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
|
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
|
||||||
]);
|
]);
|
||||||
|
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
|
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||||
|
]);
|
||||||
|
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
|
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
|
||||||
@@ -189,7 +229,13 @@ const statsSummary = ref<StatsSummaryResponse | null>(null);
|
|||||||
|
|
||||||
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
|
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
|
||||||
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
|
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
|
||||||
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
|
type IpLocationsExpose = {
|
||||||
|
refresh: () => void | Promise<void>;
|
||||||
|
resize: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ipLocationsRef = ref<IpLocationsExpose>();
|
||||||
|
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
|
||||||
|
|
||||||
const formatTime = (timestamp: number): string => {
|
const formatTime = (timestamp: number): string => {
|
||||||
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
||||||
@@ -273,13 +319,18 @@ const loadOverviewData = async () => {
|
|||||||
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
|
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = (tab: string) => {
|
const onTabChange = async (tab: string) => {
|
||||||
if (tab === "overview") loadOverviewData();
|
if (tab === "overview") loadOverviewData();
|
||||||
|
if (tab === "ip-locations") {
|
||||||
|
await nextTick();
|
||||||
|
await ipLocationsRef.value?.resize();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
if (activeTab.value === "overview") loadOverviewData();
|
if (activeTab.value === "overview") loadOverviewData();
|
||||||
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
|
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
|
||||||
|
else if (activeTab.value === "security") securityCenterRef.value?.refresh();
|
||||||
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
|
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
|
||||||
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
||||||
};
|
};
|
||||||
@@ -294,11 +345,144 @@ defineExpose({ refresh });
|
|||||||
--mon-ink: #1a2332;
|
--mon-ink: #1a2332;
|
||||||
--mon-muted: #64748b;
|
--mon-muted: #64748b;
|
||||||
--mon-border: #e2e8f0;
|
--mon-border: #e2e8f0;
|
||||||
|
--mon-tab-active: #6c63ff;
|
||||||
|
--mon-tab-active-bg: #f0edff;
|
||||||
--mon-radius: 16px;
|
--mon-radius: 16px;
|
||||||
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
||||||
|
height: 56px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid var(--mon-border);
|
||||||
|
border-bottom: 1px solid var(--mon-border);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap) {
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap::after),
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__active-bar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__nav-scroll),
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item) {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:nth-child(2)) {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:last-child) {
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item:not(.is-active)::after) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: -5px;
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item:last-child::after),
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active + .el-tabs__item::after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item:hover) {
|
||||||
|
color: var(--mon-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active) {
|
||||||
|
background: var(--mon-tab-active-bg);
|
||||||
|
color: var(--mon-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tab-pane) {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-locations-pane {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soybean-tab-label svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-summary {
|
.stats-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ describe("PermissionTrendCharts", () => {
|
|||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("系统性能趋势");
|
expect(source).toContain("系统性能趋势");
|
||||||
expect(source).toContain("查看系统运行监测指标的变化趋势");
|
|
||||||
expect(source).toContain("监测事件趋势");
|
expect(source).toContain("监测事件趋势");
|
||||||
expect(source).toContain("响应时间趋势");
|
expect(source).toContain("响应时间趋势");
|
||||||
expect(source).toContain("缓存命中率");
|
expect(source).toContain("缓存命中率");
|
||||||
@@ -17,6 +16,7 @@ describe("PermissionTrendCharts", () => {
|
|||||||
expect(source).toContain('name: "异常拒绝率"');
|
expect(source).toContain('name: "异常拒绝率"');
|
||||||
|
|
||||||
expect(source).not.toContain("数据趋势");
|
expect(source).not.toContain("数据趋势");
|
||||||
|
expect(source).not.toContain("查看系统运行监测指标的变化趋势");
|
||||||
expect(source).not.toContain("查看权限系统各项指标的变化趋势");
|
expect(source).not.toContain("查看权限系统各项指标的变化趋势");
|
||||||
expect(source).not.toContain("检查量趋势");
|
expect(source).not.toContain("检查量趋势");
|
||||||
expect(source).not.toContain("<h4>拒绝率趋势</h4>");
|
expect(source).not.toContain("<h4>拒绝率趋势</h4>");
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="trend-toolbar">
|
<div class="trend-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="toolbar-title">系统性能趋势</span>
|
<span class="toolbar-title">系统性能趋势</span>
|
||||||
<span class="toolbar-desc">查看系统运行监测指标的变化趋势</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||||
<el-radio-button value="24h">24小时</el-radio-button>
|
<el-radio-button value="24h">24小时</el-radio-button>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("SecurityCenter", () => {
|
||||||
|
it("renders the security dashboard in the same audit-card style as access logs", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("audit-filters");
|
||||||
|
expect(source).toContain("audit-metrics");
|
||||||
|
expect(source).toContain("audit-grid");
|
||||||
|
expect(source).toContain("metric-card");
|
||||||
|
expect(source).toContain("ranking-card");
|
||||||
|
expect(source).not.toContain("security-summary");
|
||||||
|
expect(source).not.toContain("security-card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places filters inside the event details section instead of the page header", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const tableIndex = source.indexOf('<section class="security-table-wrap">');
|
||||||
|
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
|
||||||
|
|
||||||
|
expect(tableIndex).toBeGreaterThan(-1);
|
||||||
|
expect(filtersIndex).toBeGreaterThan(tableIndex);
|
||||||
|
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("异常排行");
|
||||||
|
expect(source).toContain("事件分布");
|
||||||
|
expect(source).toContain("访问的接口类型");
|
||||||
|
expect(source).toContain("风险等级");
|
||||||
|
expect(source).toContain("event-distribution-card");
|
||||||
|
expect(source).toContain("distribution-section");
|
||||||
|
expect(source).toContain(".event-distribution-card > .section-head");
|
||||||
|
expect(source).toContain("padding-top: 24px");
|
||||||
|
expect(source).toContain("ipRiskStats");
|
||||||
|
expect(source).toContain("endpointTypeStats");
|
||||||
|
expect(source).toContain("riskLevelStats");
|
||||||
|
expect(source).toContain("resolveEndpointType");
|
||||||
|
expect(source).toContain("formatIpLocation");
|
||||||
|
expect(source).toContain("fallbackIpLocation");
|
||||||
|
expect(source).toContain("row.ip_province");
|
||||||
|
expect(source).toContain("row.ip_city");
|
||||||
|
expect(source).toContain("row.ip_isp");
|
||||||
|
expect(source).toContain("item.location");
|
||||||
|
expect(source).toContain("categoryText");
|
||||||
|
expect(source).toContain("rank-main-line");
|
||||||
|
expect(source).toContain("rank-meta-line");
|
||||||
|
expect(source).toContain("lastSeenAt");
|
||||||
|
expect(source).toContain("最后访问");
|
||||||
|
expect(source).toContain("slice(0, 10)");
|
||||||
|
expect(source).not.toContain("公网位置待解析");
|
||||||
|
expect(source).not.toContain("异常IP(位置)排行");
|
||||||
|
expect(source).not.toContain("risk-level-card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps detailed security events searchable and inspectable", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("detailFilteredEvents");
|
||||||
|
expect(source).toContain("paginatedEvents");
|
||||||
|
expect(source).toContain("openDetail");
|
||||||
|
expect(source).toContain("安全事件明细");
|
||||||
|
expect(source).toContain('label="IP"');
|
||||||
|
expect(source).toContain('label="位置"');
|
||||||
|
expect(source).not.toContain('label="来源 IP"');
|
||||||
|
expect(source).not.toContain('label="IP来源"');
|
||||||
|
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
|
||||||
|
expect(source).toContain("loadSecurityEvents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels abnormal IP events and paginates details like account management", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }');
|
||||||
|
expect(source).toContain('v-model:current-page="page"');
|
||||||
|
expect(source).toContain('v-model:page-size="pageSize"');
|
||||||
|
expect(source).toContain(':page-sizes="[10, 20, 50]"');
|
||||||
|
expect(source).toContain('layout="prev, pager, next, sizes, total"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates dashboard statistics from all events instead of detail filters or current page", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("statsEvents");
|
||||||
|
expect(source).toContain("detailFilteredEvents");
|
||||||
|
expect(source).toContain("loadSecurityStats");
|
||||||
|
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
|
||||||
|
expect(source).toContain("statsEvents.value");
|
||||||
|
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
|
||||||
|
expect(source).not.toContain("filteredStatsEvents");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
<template>
|
||||||
|
<div class="security-center">
|
||||||
|
<section class="audit-metrics">
|
||||||
|
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<component :is="card.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-body">
|
||||||
|
<span class="metric-label">{{ card.label }}</span>
|
||||||
|
<strong class="metric-value">{{ card.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="audit-grid">
|
||||||
|
<article class="ranking-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-title-group">
|
||||||
|
<h4>异常排行</h4>
|
||||||
|
</div>
|
||||||
|
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="ipRiskStats.length" class="risk-rank-list">
|
||||||
|
<div v-for="(item, index) in ipRiskStats" :key="item.ip" class="risk-rank-row">
|
||||||
|
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||||
|
<div class="rank-user">
|
||||||
|
<div class="rank-main-line">
|
||||||
|
<strong>{{ item.ip }}</strong>
|
||||||
|
<small>{{ item.location }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="rank-meta-line">
|
||||||
|
<span>{{ item.categoryText }}</span>
|
||||||
|
<span>最后访问 {{ formatTime(item.lastSeenAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rank-count">
|
||||||
|
<strong>{{ item.total }}</strong>
|
||||||
|
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无异常数据" :image-size="72" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ranking-card event-distribution-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-title-group">
|
||||||
|
<h4>事件分布</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="distribution-section">
|
||||||
|
<div class="distribution-section-head">
|
||||||
|
<strong>访问的接口类型</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="endpointTypeStats.length" class="distribution-list">
|
||||||
|
<div v-for="item in endpointTypeStats" :key="item.type" class="distribution-row">
|
||||||
|
<div class="distribution-info">
|
||||||
|
<strong>{{ item.type }}</strong>
|
||||||
|
<small>{{ item.samplePath }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="distribution-meter">
|
||||||
|
<span :style="{ width: `${item.percent}%` }" />
|
||||||
|
</div>
|
||||||
|
<strong class="distribution-count">{{ item.total }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无接口类型数据" :image-size="72" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distribution-section compact-risk-section">
|
||||||
|
<div class="distribution-section-head">
|
||||||
|
<strong>风险等级</strong>
|
||||||
|
</div>
|
||||||
|
<div class="risk-level-list">
|
||||||
|
<div v-for="item in riskLevelStats" :key="item.value" class="risk-level-row">
|
||||||
|
<span class="risk-dot" :class="item.tone" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<div class="risk-level-meter">
|
||||||
|
<span :class="item.tone" :style="{ width: `${item.percent}%` }" />
|
||||||
|
</div>
|
||||||
|
<strong>{{ item.total }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="security-table-wrap">
|
||||||
|
<div class="section-head table-head">
|
||||||
|
<div class="section-title-group">
|
||||||
|
<h4>安全事件明细</h4>
|
||||||
|
</div>
|
||||||
|
<section class="audit-filters detail-filters">
|
||||||
|
<el-select v-model="severityFilter" placeholder="风险等级" clearable style="width: 130px" @change="onFilterChange">
|
||||||
|
<el-option v-for="item in severityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="categoryFilter" placeholder="事件分类" clearable style="width: 150px" @change="onFilterChange">
|
||||||
|
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="authFilter" placeholder="认证状态" clearable style="width: 140px" @change="onFilterChange">
|
||||||
|
<el-option label="匿名" value="ANONYMOUS" />
|
||||||
|
<el-option label="无效令牌" value="INVALID_TOKEN" />
|
||||||
|
<el-option label="已认证" value="AUTHENTICATED" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索 IP、位置、账号、路径或 UA"
|
||||||
|
clearable
|
||||||
|
class="security-search"
|
||||||
|
:prefix-icon="SearchIcon"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadSecurityEvents">刷新</el-button>
|
||||||
|
</section>
|
||||||
|
<el-tag effect="plain" size="small" type="info">{{ securityTotal }} 条</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-table :data="paginatedEvents" v-loading="loading" class="security-table" table-layout="fixed">
|
||||||
|
<el-table-column label="级别" width="96">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="severityTagType(row.severity)" effect="dark" size="small">{{ severityLabel(row.severity) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="分类" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="IP" width="132">
|
||||||
|
<template #default="{ row }">{{ row.client_ip || "未知 IP" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="位置" width="180">
|
||||||
|
<template #default="{ row }">{{ formatIpLocation(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="账号" width="140" prop="account_label" />
|
||||||
|
<el-table-column label="请求" min-width="320">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="request-cell">
|
||||||
|
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
|
||||||
|
<span>{{ row.path }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="耗时" width="86">
|
||||||
|
<template #default="{ row }">{{ row.elapsed_ms.toFixed(1) }}ms</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="securityTotal > 0" class="pagination-wrap">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="securityTotal"
|
||||||
|
layout="prev, pager, next, sizes, total"
|
||||||
|
small
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailVisible" size="520px" direction="rtl" :show-close="false">
|
||||||
|
<template #header>
|
||||||
|
<div class="security-detail-head">
|
||||||
|
<strong>安全事件详情</strong>
|
||||||
|
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="selectedEvent" class="security-detail">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div><span>级别</span><strong>{{ severityLabel(selectedEvent.severity) }}</strong></div>
|
||||||
|
<div><span>分类</span><strong>{{ categoryLabel(selectedEvent.category) }}</strong></div>
|
||||||
|
<div><span>状态码</span><strong>{{ selectedEvent.status_code }}</strong></div>
|
||||||
|
<div><span>认证状态</span><strong>{{ authLabel(selectedEvent.auth_status) }}</strong></div>
|
||||||
|
<div><span>IP</span><strong>{{ selectedEvent.client_ip || "未知" }}</strong></div>
|
||||||
|
<div><span>位置</span><strong>{{ formatIpLocation(selectedEvent) }}</strong></div>
|
||||||
|
<div><span>接口类型</span><strong>{{ resolveEndpointType(selectedEvent.path) }}</strong></div>
|
||||||
|
<div><span>账号</span><strong>{{ selectedEvent.account_label }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<span>请求路径</span>
|
||||||
|
<code>{{ selectedEvent.method }} {{ selectedEvent.path }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<span>User Agent</span>
|
||||||
|
<code>{{ selectedEvent.user_agent || "-" }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, ref, watch } from "vue";
|
||||||
|
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||||
|
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
||||||
|
import type { SecurityAccessLogItem } from "@/types/api";
|
||||||
|
|
||||||
|
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
|
||||||
|
|
||||||
|
const MetricIconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
|
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||||
|
]);
|
||||||
|
const MetricIconIp = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
|
h("circle", { cx: "12", cy: "10", r: "3" }),
|
||||||
|
h("path", { d: "M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" }),
|
||||||
|
]);
|
||||||
|
const MetricIconAlert = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
|
h("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" }),
|
||||||
|
h("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
|
||||||
|
h("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }),
|
||||||
|
]);
|
||||||
|
const MetricIconServer = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||||
|
h("rect", { x: "3", y: "4", width: "18", height: "8", rx: "2" }),
|
||||||
|
h("rect", { x: "3", y: "12", width: "18", height: "8", rx: "2" }),
|
||||||
|
h("line", { x1: "7", y1: "8", x2: "7.01", y2: "8" }),
|
||||||
|
h("line", { x1: "7", y1: "16", x2: "7.01", y2: "16" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const statsEvents = ref<SecurityAccessLogItem[]>([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const keyword = ref("");
|
||||||
|
const severityFilter = ref("");
|
||||||
|
const categoryFilter = ref("");
|
||||||
|
const authFilter = ref("");
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
|
||||||
|
|
||||||
|
const severityOptions = [
|
||||||
|
{ label: "严重", value: "CRITICAL", tone: "critical" },
|
||||||
|
{ label: "高", value: "HIGH", tone: "high" },
|
||||||
|
{ label: "中", value: "MEDIUM", tone: "medium" },
|
||||||
|
{ label: "低", value: "LOW", tone: "low" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryOptions = [
|
||||||
|
{ label: "敏感路径探测", value: "PROBE" },
|
||||||
|
{ label: "异常IP", value: "ABNORMAL_IP" },
|
||||||
|
{ label: "无效令牌", value: "INVALID_TOKEN" },
|
||||||
|
{ label: "服务异常", value: "SERVER_ERROR" },
|
||||||
|
{ label: "匿名 API", value: "ANONYMOUS_API" },
|
||||||
|
{ label: "普通 404", value: "NOT_FOUND_NOISE" },
|
||||||
|
{ label: "其他", value: "OTHER" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const severityLabel = (value: string) => severityOptions.find((item) => item.value === value)?.label || value;
|
||||||
|
const categoryLabel = (value: string) => categoryOptions.find((item) => item.value === value)?.label || value;
|
||||||
|
|
||||||
|
const authLabel = (value: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ANONYMOUS: "匿名",
|
||||||
|
INVALID_TOKEN: "无效令牌",
|
||||||
|
AUTHENTICATED: "已认证",
|
||||||
|
};
|
||||||
|
return labels[value] || value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityTagType = (value: string) => {
|
||||||
|
if (value === "CRITICAL" || value === "HIGH") return "danger";
|
||||||
|
if (value === "MEDIUM") return "warning";
|
||||||
|
return "info";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||||
|
const formatTime = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
|
||||||
|
const isHighRisk = (event: SecurityAccessLogItem) => event.severity === "CRITICAL" || event.severity === "HIGH";
|
||||||
|
|
||||||
|
const fallbackIpLocation = (ip: string | null) => {
|
||||||
|
if (!ip) return "未知位置";
|
||||||
|
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
|
||||||
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
|
||||||
|
return "未知位置";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatIpLocation = (row: SecurityAccessLogItem) => {
|
||||||
|
const parts = [
|
||||||
|
row.ip_country && row.ip_country !== "中国" ? row.ip_country : "",
|
||||||
|
row.ip_province,
|
||||||
|
row.ip_city,
|
||||||
|
row.ip_isp,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (parts.length) return parts.join(" / ");
|
||||||
|
return row.ip_location || fallbackIpLocation(row.client_ip);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEndpointType = (path: string) => {
|
||||||
|
const normalizedPath = path.toLowerCase();
|
||||||
|
if (normalizedPath.includes("/auth") || normalizedPath.includes("/login")) return "认证接口";
|
||||||
|
if (normalizedPath.startsWith("/api/")) return "业务 API";
|
||||||
|
if (normalizedPath.match(/\.(js|css|png|jpg|jpeg|svg|ico|map)$/)) return "静态资源";
|
||||||
|
if ([".env", "wp-", "php", "admin", "shell", "config"].some((marker) => normalizedPath.includes(marker))) return "探测路径";
|
||||||
|
return "其他请求";
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailFilteredEvents = computed(() => {
|
||||||
|
const token = keyword.value.trim().toLowerCase();
|
||||||
|
return statsEvents.value.filter((event) => {
|
||||||
|
if (severityFilter.value && event.severity !== severityFilter.value) return false;
|
||||||
|
if (categoryFilter.value && event.category !== categoryFilter.value) return false;
|
||||||
|
if (authFilter.value && event.auth_status !== authFilter.value) return false;
|
||||||
|
if (!token) return true;
|
||||||
|
return [
|
||||||
|
event.client_ip,
|
||||||
|
formatIpLocation(event),
|
||||||
|
event.account_label,
|
||||||
|
event.path,
|
||||||
|
resolveEndpointType(event.path),
|
||||||
|
event.user_agent,
|
||||||
|
event.auth_status,
|
||||||
|
event.status_code,
|
||||||
|
].some((value) => String(value || "").toLowerCase().includes(token));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const securityTotal = computed(() => detailFilteredEvents.value.length);
|
||||||
|
|
||||||
|
const paginatedEvents = computed(() => {
|
||||||
|
const start = (page.value - 1) * pageSize.value;
|
||||||
|
return detailFilteredEvents.value.slice(start, start + pageSize.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const countByCategory = (category: string) => statsEvents.value.filter((event) => event.category === category).length;
|
||||||
|
|
||||||
|
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
|
||||||
|
const uniqueIpCount = new Set(statsEvents.value.map((event) => event.client_ip || "未知 IP")).size;
|
||||||
|
const highRiskCount = statsEvents.value.filter(isHighRisk).length;
|
||||||
|
const serverErrorCount = countByCategory("SERVER_ERROR");
|
||||||
|
return [
|
||||||
|
{ label: "安全事件", value: formatNumber(statsEvents.value.length), tone: "blue", icon: MetricIconShield },
|
||||||
|
{ label: "异常 IP", value: formatNumber(uniqueIpCount), tone: "cyan", icon: MetricIconIp },
|
||||||
|
{ label: "高危风险", value: formatNumber(highRiskCount), tone: highRiskCount > 0 ? "danger" : "indigo", icon: MetricIconAlert },
|
||||||
|
{ label: "服务异常", value: formatNumber(serverErrorCount), tone: serverErrorCount > 0 ? "danger" : "indigo", icon: MetricIconServer },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const ipRiskStats = computed(() => {
|
||||||
|
const stats = new Map<string, { ip: string; location: string; categoryText: string; total: number; highRiskCount: number; lastSeenAt: string }>();
|
||||||
|
statsEvents.value.forEach((event) => {
|
||||||
|
const ip = event.client_ip || "未知 IP";
|
||||||
|
const current = stats.get(ip) || {
|
||||||
|
ip,
|
||||||
|
location: formatIpLocation(event),
|
||||||
|
categoryText: categoryLabel(event.category),
|
||||||
|
total: 0,
|
||||||
|
highRiskCount: 0,
|
||||||
|
lastSeenAt: event.created_at,
|
||||||
|
};
|
||||||
|
current.total += 1;
|
||||||
|
if (isHighRisk(event)) current.highRiskCount += 1;
|
||||||
|
if (new Date(event.created_at).getTime() > new Date(current.lastSeenAt).getTime()) {
|
||||||
|
current.lastSeenAt = event.created_at;
|
||||||
|
current.categoryText = categoryLabel(event.category);
|
||||||
|
}
|
||||||
|
stats.set(ip, current);
|
||||||
|
});
|
||||||
|
return [...stats.values()].sort((a, b) => b.highRiskCount - a.highRiskCount || b.total - a.total).slice(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpointTypeStats = computed(() => {
|
||||||
|
const stats = new Map<string, { type: string; total: number; samplePath: string }>();
|
||||||
|
statsEvents.value.forEach((event) => {
|
||||||
|
const type = resolveEndpointType(event.path);
|
||||||
|
const current = stats.get(type) || { type, total: 0, samplePath: event.path };
|
||||||
|
current.total += 1;
|
||||||
|
stats.set(type, current);
|
||||||
|
});
|
||||||
|
const maxTotal = Math.max(1, ...[...stats.values()].map((item) => item.total));
|
||||||
|
return [...stats.values()]
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map((item) => ({ ...item, percent: Math.max(8, Math.round((item.total / maxTotal) * 100)) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const riskLevelStats = computed(() => {
|
||||||
|
const filteredCounts = severityOptions.map((item) => ({
|
||||||
|
...item,
|
||||||
|
total: statsEvents.value.filter((event) => event.severity === item.value).length,
|
||||||
|
}));
|
||||||
|
const maxTotal = Math.max(1, ...filteredCounts.map((item) => item.total));
|
||||||
|
return filteredCounts.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
percent: item.total > 0 ? Math.max(8, Math.round((item.total / maxTotal) * 100)) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const SECURITY_STATS_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
const loadSecurityEvents = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await loadSecurityStats();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSecurityStats = async () => {
|
||||||
|
const first = await fetchSecurityAccessLogs({
|
||||||
|
status_min: 400,
|
||||||
|
auth_status: authFilter.value || undefined,
|
||||||
|
page: 1,
|
||||||
|
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const allItems = [...first.data.items];
|
||||||
|
const total = first.data.total;
|
||||||
|
const totalPages = Math.ceil(total / SECURITY_STATS_PAGE_SIZE);
|
||||||
|
|
||||||
|
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||||
|
const res = await fetchSecurityAccessLogs({
|
||||||
|
status_min: 400,
|
||||||
|
auth_status: authFilter.value || undefined,
|
||||||
|
page: nextPage,
|
||||||
|
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
allItems.push(...res.data.items);
|
||||||
|
}
|
||||||
|
statsEvents.value = allItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = () => {
|
||||||
|
page.value = 1;
|
||||||
|
loadSecurityEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size;
|
||||||
|
page.value = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(keyword, () => {
|
||||||
|
page.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDetail = (event: SecurityAccessLogItem) => {
|
||||||
|
selectedEvent.value = event;
|
||||||
|
detailVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadSecurityEvents);
|
||||||
|
|
||||||
|
defineExpose({ refresh: loadSecurityEvents });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.security-center {
|
||||||
|
--audit-ink: #1a2332;
|
||||||
|
--audit-muted: #64748b;
|
||||||
|
--audit-border: #e2e8f0;
|
||||||
|
--audit-blue: #3b82f6;
|
||||||
|
--audit-cyan: #06b6d4;
|
||||||
|
--audit-danger: #ef4444;
|
||||||
|
--audit-indigo: #6366f1;
|
||||||
|
--card-radius: 16px;
|
||||||
|
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
border: 1px solid var(--audit-border);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-search {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--audit-border);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||||
|
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||||
|
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||||
|
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||||
|
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||||
|
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||||
|
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||||
|
|
||||||
|
.metric-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-card,
|
||||||
|
.security-table-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--audit-border);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head .section-title-group {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head .el-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-filters {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-group h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-group p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rank-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rank-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rank-row:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-no {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-no.rank-top {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-user,
|
||||||
|
.rank-count {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-main-line,
|
||||||
|
.rank-meta-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-main-line strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-main-line small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-meta-line span {
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-count {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-count strong {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-count small {
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-count .denied-highlight {
|
||||||
|
color: var(--audit-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-list,
|
||||||
|
.risk-level-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-distribution-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-section + .distribution-section {
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--audit-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-distribution-card > .section-head {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-distribution-card .section-title-group h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-section-head strong {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 0.9fr) minmax(120px, 1fr) 44px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-info strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-info small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-meter,
|
||||||
|
.risk-level-meter {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-meter span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #06b6d4, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-count {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-level-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10px 52px minmax(0, 1fr) 44px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-dot.critical,
|
||||||
|
.risk-level-meter span.critical { background: #991b1b; }
|
||||||
|
.risk-dot.high,
|
||||||
|
.risk-level-meter span.high { background: #ef4444; }
|
||||||
|
.risk-dot.medium,
|
||||||
|
.risk-level-meter span.medium { background: #f59e0b; }
|
||||||
|
.risk-dot.low,
|
||||||
|
.risk-level-meter span.low { background: #64748b; }
|
||||||
|
|
||||||
|
.risk-level-row > span:nth-child(2) {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-level-meter span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-level-row strong {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-table-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 112px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-cell strong {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-cell span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--audit-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-detail-head {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid div,
|
||||||
|
.detail-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--audit-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid span,
|
||||||
|
.detail-section span {
|
||||||
|
color: var(--audit-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid strong {
|
||||||
|
color: var(--audit-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--audit-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.audit-metrics,
|
||||||
|
.audit-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.audit-metrics,
|
||||||
|
.audit-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row {
|
||||||
|
grid-template-columns: 1fr 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-meter {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// 地图数据来源:https://unpkg.com/echarts@4.9.0/map/json/world.json
|
||||||
|
// 组件直接导入本地缓存的 ECharts 世界地图 GeoJSON,避免运行时依赖外部网络。
|
||||||
|
|
||||||
|
import type { IpLocationStatItem } from "../types/api";
|
||||||
|
|
||||||
|
export const normalizeWorldCountryName = (value?: string | null) => {
|
||||||
|
const country = String(value || "").trim();
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
中国: "China",
|
||||||
|
China: "China",
|
||||||
|
"Mainland China": "China",
|
||||||
|
中国香港: "China",
|
||||||
|
中国澳门: "China",
|
||||||
|
中国台湾: "China",
|
||||||
|
美国: "United States",
|
||||||
|
"United States": "United States",
|
||||||
|
"United States of America": "United States",
|
||||||
|
USA: "United States",
|
||||||
|
Australia: "Australia",
|
||||||
|
澳大利亚: "Australia",
|
||||||
|
Japan: "Japan",
|
||||||
|
日本: "Japan",
|
||||||
|
Singapore: "Singapore",
|
||||||
|
新加坡: "Singapore",
|
||||||
|
Germany: "Germany",
|
||||||
|
德国: "Germany",
|
||||||
|
France: "France",
|
||||||
|
法国: "France",
|
||||||
|
"United Kingdom": "United Kingdom",
|
||||||
|
英国: "United Kingdom",
|
||||||
|
Canada: "Canada",
|
||||||
|
加拿大: "Canada",
|
||||||
|
India: "India",
|
||||||
|
印度: "India",
|
||||||
|
Russia: "Russia",
|
||||||
|
俄罗斯: "Russia",
|
||||||
|
};
|
||||||
|
return aliases[country] || country;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countryCoordinates: Record<string, [number, number]> = {
|
||||||
|
China: [104.1954, 35.8617],
|
||||||
|
"United States": [-95.7129, 37.0902],
|
||||||
|
Netherlands: [5.2913, 52.1326],
|
||||||
|
Türkiye: [35.2433, 38.9637],
|
||||||
|
Turkey: [35.2433, 38.9637],
|
||||||
|
Australia: [133.7751, -25.2744],
|
||||||
|
Japan: [138.2529, 36.2048],
|
||||||
|
Singapore: [103.8198, 1.3521],
|
||||||
|
Germany: [10.4515, 51.1657],
|
||||||
|
France: [2.2137, 46.2276],
|
||||||
|
"United Kingdom": [-3.436, 55.3781],
|
||||||
|
Canada: [-106.3468, 56.1304],
|
||||||
|
India: [78.9629, 20.5937],
|
||||||
|
Russia: [105.3188, 61.524],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cityCoordinates: Record<string, [number, number]> = {
|
||||||
|
"局域网": [121.86, 31.45],
|
||||||
|
"北京市": [116.4074, 39.9042],
|
||||||
|
"天津市": [117.2008, 39.0842],
|
||||||
|
"河北省": [114.5025, 38.0455],
|
||||||
|
"山西省": [112.5492, 37.857],
|
||||||
|
"内蒙古自治区": [111.6708, 40.8183],
|
||||||
|
"辽宁省": [123.4315, 41.8057],
|
||||||
|
"吉林省": [125.3245, 43.8868],
|
||||||
|
"黑龙江省": [126.6424, 45.7567],
|
||||||
|
"上海市": [121.4737, 31.2304],
|
||||||
|
"江苏省": [118.7633, 32.0617],
|
||||||
|
"浙江省": [120.1551, 30.2741],
|
||||||
|
"安徽省": [117.2272, 31.8206],
|
||||||
|
"福建省": [119.2965, 26.0745],
|
||||||
|
"江西省": [115.8582, 28.682],
|
||||||
|
"山东省": [117.1201, 36.6512],
|
||||||
|
"河南省": [113.6254, 34.7466],
|
||||||
|
"湖北省": [114.3055, 30.5928],
|
||||||
|
"湖南省": [112.9388, 28.2282],
|
||||||
|
"广东省": [113.2644, 23.1291],
|
||||||
|
"广西壮族自治区": [108.3669, 22.817],
|
||||||
|
"海南省": [110.3312, 20.0311],
|
||||||
|
"重庆": [106.5516, 29.563],
|
||||||
|
"重庆市": [106.5516, 29.563],
|
||||||
|
"四川省": [104.0665, 30.5723],
|
||||||
|
"贵州省": [106.6302, 26.647],
|
||||||
|
"云南省": [102.8329, 24.8801],
|
||||||
|
"西藏自治区": [91.1322, 29.6604],
|
||||||
|
"陕西省": [108.9398, 34.3416],
|
||||||
|
"甘肃省": [103.8343, 36.0611],
|
||||||
|
"青海省": [101.7782, 36.6171],
|
||||||
|
"宁夏回族自治区": [106.2309, 38.4872],
|
||||||
|
"新疆维吾尔自治区": [87.6168, 43.8256],
|
||||||
|
"台湾省": [121.5654, 25.033],
|
||||||
|
"香港特别行政区": [114.1694, 22.3193],
|
||||||
|
"澳门特别行政区": [113.5439, 22.1987],
|
||||||
|
"南京": [118.7969, 32.0603],
|
||||||
|
"南京市": [118.7969, 32.0603],
|
||||||
|
"San Jose": [-121.8863, 37.3382],
|
||||||
|
"South Holland": [4.493, 52.0208],
|
||||||
|
"Istanbul": [28.9784, 41.0082],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null => {
|
||||||
|
if (item.location === "局域网") return cityCoordinates["局域网"];
|
||||||
|
const city = String(item.city || "").trim();
|
||||||
|
if (city && cityCoordinates[city]) return cityCoordinates[city];
|
||||||
|
const province = String(item.province || "").trim();
|
||||||
|
if (province && cityCoordinates[province]) return cityCoordinates[province];
|
||||||
|
const country = normalizeWorldCountryName(item.country);
|
||||||
|
return country ? countryCoordinates[country] || null : null;
|
||||||
|
};
|
||||||
@@ -273,10 +273,17 @@ export interface SecurityAccessLogItem {
|
|||||||
status_code: number;
|
status_code: number;
|
||||||
elapsed_ms: number;
|
elapsed_ms: number;
|
||||||
client_ip: string | null;
|
client_ip: string | null;
|
||||||
|
ip_location: string;
|
||||||
|
ip_country: string;
|
||||||
|
ip_province: string;
|
||||||
|
ip_city: string;
|
||||||
|
ip_isp: string;
|
||||||
user_agent: string | null;
|
user_agent: string | null;
|
||||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||||
user_identifier: string | null;
|
user_identifier: string | null;
|
||||||
account_label: string;
|
account_label: string;
|
||||||
|
category: "PROBE" | "ABNORMAL_IP" | "INVALID_TOKEN" | "SERVER_ERROR" | "ANONYMOUS_API" | "NOT_FOUND_NOISE" | "OTHER" | string;
|
||||||
|
severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user