diff --git a/atsf_server/service/dashboard.go b/atsf_server/service/dashboard.go index 849bcbaf..99ef7835 100644 --- a/atsf_server/service/dashboard.go +++ b/atsf_server/service/dashboard.go @@ -160,6 +160,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) { view := &DashboardOverviewView{ GeneratedAt: now, Nodes: make([]DashboardNodeHealth, 0, len(nodes)), + ActiveAlerts: make([]DashboardAlert, 0), Distributions: buildTrafficDistributions(reports, 8), Trends: DashboardTrends{ Traffic24h: buildTrafficTrendPoints(now, reports), diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go index cf87e9fc..16d2c0ee 100644 --- a/atsf_server/service/node_update_test.go +++ b/atsf_server/service/node_update_test.go @@ -879,3 +879,18 @@ func TestGetDashboardOverview(t *testing.T) { t.Fatalf("unexpected riskiest node: %+v", view.Peaks.RiskiestNode) } } + +func TestGetDashboardOverviewReturnsEmptyAlertSlice(t *testing.T) { + setupServiceTestDB(t) + + view, err := GetDashboardOverview() + if err != nil { + t.Fatalf("GetDashboardOverview failed: %v", err) + } + if view.ActiveAlerts == nil { + t.Fatalf("expected active alerts to be an empty slice, got nil") + } + if len(view.ActiveAlerts) != 0 { + t.Fatalf("expected empty active alerts, got %+v", view.ActiveAlerts) + } +} diff --git a/atsf_server/web/features/dashboard/api/overview.ts b/atsf_server/web/features/dashboard/api/overview.ts index 2c7616ac..e1c51cf7 100644 --- a/atsf_server/web/features/dashboard/api/overview.ts +++ b/atsf_server/web/features/dashboard/api/overview.ts @@ -2,6 +2,38 @@ import { apiRequest } from '@/lib/api/client'; import type { DashboardOverview } from '@/features/dashboard/types'; -export function getDashboardOverview() { - return apiRequest('/dashboard/overview'); +function arrayOrEmpty(value: T[] | null | undefined) { + return Array.isArray(value) ? value : []; +} + +function normalizeDashboardOverview( + overview: DashboardOverview | null | undefined, +): DashboardOverview | null { + if (!overview) { + return null; + } + + return { + ...overview, + nodes: arrayOrEmpty(overview.nodes), + active_alerts: arrayOrEmpty(overview.active_alerts), + distributions: { + ...overview.distributions, + source_countries: arrayOrEmpty(overview.distributions?.source_countries), + status_codes: arrayOrEmpty(overview.distributions?.status_codes), + top_domains: arrayOrEmpty(overview.distributions?.top_domains), + }, + trends: { + ...overview.trends, + traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h), + capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h), + network_24h: arrayOrEmpty(overview.trends?.network_24h), + disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h), + }, + }; +} + +export async function getDashboardOverview() { + const overview = await apiRequest('/dashboard/overview'); + return normalizeDashboardOverview(overview); } diff --git a/atsf_server/web/features/dashboard/components/world-stage.tsx b/atsf_server/web/features/dashboard/components/world-stage.tsx index 74c08012..25e10f68 100644 --- a/atsf_server/web/features/dashboard/components/world-stage.tsx +++ b/atsf_server/web/features/dashboard/components/world-stage.tsx @@ -1,8 +1,13 @@ 'use client'; +import type { EChartsOption } from 'echarts'; +import ReactECharts from 'echarts-for-react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; +import { useTheme } from '@/components/providers/theme-provider'; import { StatusBadge } from '@/components/ui/status-badge'; import type { DashboardConfig, @@ -17,20 +22,53 @@ import { getOpenrestyStatusLabel, getOpenrestyStatusVariant, } from '@/features/nodes/utils'; +import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; -const stageAnchors = [ - { left: '16%', top: '24%' }, - { left: '24%', top: '58%' }, - { left: '45%', top: '22%' }, - { left: '52%', top: '54%' }, - { left: '72%', top: '30%' }, - { left: '84%', top: '68%' }, -]; +const fallbackCoordinates = [ + [-122.4194, 37.7749], + [-46.6333, -23.5505], + [-0.1276, 51.5072], + [2.3522, 48.8566], + [77.209, 28.6139], + [121.4737, 31.2304], + [103.8198, 1.3521], + [151.2093, -33.8688], + [28.0473, -26.2041], + [139.6917, 35.6895], +] as const; -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} +type Tone = 'healthy' | 'warning' | 'danger'; + +type MapNodeDatum = { + id: number; + name: string; + geoName: string; + route: string; + derivedFromGeo: boolean; + requestCount: number; + errorCount: number; + activeEventCount: number; + status: DashboardNodeHealth['status']; + openrestyStatus: DashboardNodeHealth['openresty_status']; + tone: Tone; + value: [number, number, number]; + itemStyle: { + color: string; + borderColor: string; + borderWidth: number; + shadowBlur: number; + shadowColor: string; + }; + emphasis: { + itemStyle: { + shadowBlur: number; + shadowColor: string; + borderColor: string; + borderWidth: number; + }; + }; +}; function formatPercent(value: number) { if (!Number.isFinite(value)) { @@ -46,55 +84,43 @@ function buildNodeDetailHref(id?: number | null) { return `/node/detail?id=${id}`; } -function projectNodeCoordinates(node: DashboardNodeHealth, index: number) { - if ( - typeof node.geo_latitude === 'number' && - typeof node.geo_longitude === 'number' - ) { - const left = clamp(((node.geo_longitude + 180) / 360) * 100, 8, 92); - const top = clamp(((90 - node.geo_latitude) / 180) * 100, 12, 78); - return { - left: `${left}%`, - top: `${top}%`, - derivedFromGeo: true, - }; - } - - return { - ...stageAnchors[index % stageAnchors.length], - derivedFromGeo: false, - }; -} - -function getNodeSignalTone(node: DashboardNodeHealth) { +function getNodeTone(node: DashboardNodeHealth): Tone { if ( node.status === 'offline' || node.openresty_status === 'unhealthy' || node.active_event_count > 0 ) { - return { - dot: 'bg-rose-400 shadow-[0_0_18px_rgba(251,113,133,0.75)]', - ring: 'border-rose-400/50 bg-rose-500/12 text-rose-50', - halo: 'bg-rose-500/20', - }; + return 'danger'; } if ( node.cpu_usage_percent >= 80 || node.memory_usage_percent >= 85 || node.storage_usage_percent >= 85 + ) { + return 'warning'; + } + + return 'healthy'; +} + +function getNodeCoordinates(node: DashboardNodeHealth, index: number) { + if ( + typeof node.geo_latitude === 'number' && + typeof node.geo_longitude === 'number' ) { return { - dot: 'bg-amber-300 shadow-[0_0_18px_rgba(252,211,77,0.75)]', - ring: 'border-amber-300/45 bg-amber-400/12 text-amber-50', - halo: 'bg-amber-400/18', + coordinates: [node.geo_longitude, node.geo_latitude] as [number, number], + derivedFromGeo: true, }; } return { - dot: 'bg-emerald-300 shadow-[0_0_18px_rgba(110,231,183,0.7)]', - ring: 'border-emerald-300/40 bg-emerald-400/12 text-emerald-50', - halo: 'bg-emerald-400/18', + coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [ + number, + number, + ], + derivedFromGeo: false, }; } @@ -102,34 +128,70 @@ function HeroMetric({ label, value, hint, + isDark, }: { label: string; value: string; hint: string; + isDark: boolean; }) { return ( -
-

+

+

{label}

-

{value}

-

{hint}

+

+ {value} +

+

+ {hint} +

); } -function SignalLegend() { +function LegendPill({ + label, + tone, + isDark, +}: { + label: string; + tone: Tone; + isDark: boolean; +}) { + const toneClass = + tone === 'healthy' + ? isDark + ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' + : 'border-emerald-200 bg-emerald-50 text-emerald-700' + : tone === 'warning' + ? isDark + ? 'border-amber-300/20 bg-amber-400/10 text-amber-100' + : 'border-amber-200 bg-amber-50 text-amber-700' + : isDark + ? 'border-rose-300/20 bg-rose-400/10 text-rose-100' + : 'border-rose-200 bg-rose-50 text-rose-700'; + return ( -
-
- 绿色: 运行健康 -
-
- 黄色: 容量压力 -
-
- 红色: 异常或离线 -
+
+ {label}
); } @@ -137,27 +199,51 @@ function SignalLegend() { function CountrySignal({ item, index, + isDark, }: { item: DistributionItem; index: number; + isDark: boolean; }) { - const accents = [ + const darkAccents = [ 'from-sky-400/35 to-cyan-400/10', 'from-violet-400/35 to-fuchsia-400/10', 'from-emerald-400/35 to-teal-400/10', ]; + const lightAccents = [ + 'from-sky-100 via-white to-cyan-50', + 'from-indigo-100 via-white to-fuchsia-50', + 'from-emerald-100 via-white to-teal-50', + ]; return (
-

+

{item.key}

-

+

{item.value.toLocaleString('zh-CN')}

-

最近 24 小时来源信号

+

+ 最近 24 小时来源信号 +

); } @@ -177,6 +263,34 @@ export function WorldStage({ nodes: DashboardNodeHealth[]; sourceCountries: DistributionItem[]; }) { + const router = useRouter(); + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + const [mapReady, setMapReady] = useState(false); + const [mapFailed, setMapFailed] = useState(false); + + useEffect(() => { + let disposed = false; + + import('echarts-maps/world.js') + .then(() => { + if (!disposed) { + setMapReady(true); + setMapFailed(false); + } + }) + .catch(() => { + if (!disposed) { + setMapReady(false); + setMapFailed(true); + } + }); + + return () => { + disposed = true; + }; + }, []); + const onlineRate = summary.total_nodes > 0 ? (summary.online_nodes / summary.total_nodes) * 100 @@ -198,23 +312,236 @@ export function WorldStage({ typeof node.geo_latitude === 'number' && typeof node.geo_longitude === 'number', ).length; - const signalNodes = nodes.slice(0, Math.max(stageAnchors.length, 8)); + + const mapPalette = useMemo( + () => + isDark + ? { + areaColor: '#13233b', + borderColor: 'rgba(125,211,252,0.18)', + shadowColor: 'rgba(8,15,31,0.35)', + labelColor: '#e2e8f0', + subLabelColor: '#94a3b8', + healthyColor: '#34d399', + warningColor: '#fbbf24', + dangerColor: '#fb7185', + healthyBorder: '#bbf7d0', + warningBorder: '#fde68a', + dangerBorder: '#fecdd3', + } + : { + areaColor: '#eaf2ff', + borderColor: 'rgba(71,85,105,0.16)', + shadowColor: 'rgba(148,163,184,0.18)', + labelColor: '#0f172a', + subLabelColor: '#475569', + healthyColor: '#10b981', + warningColor: '#f59e0b', + dangerColor: '#f43f5e', + healthyBorder: '#d1fae5', + warningBorder: '#fde68a', + dangerBorder: '#fecdd3', + }, + [isDark], + ); + + const mapNodes = useMemo( + () => + nodes.map((node, index) => { + const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index); + const tone = getNodeTone(node); + const toneColor = + tone === 'healthy' + ? mapPalette.healthyColor + : tone === 'warning' + ? mapPalette.warningColor + : mapPalette.dangerColor; + const toneBorder = + tone === 'healthy' + ? mapPalette.healthyBorder + : tone === 'warning' + ? mapPalette.warningBorder + : mapPalette.dangerBorder; + + return { + id: node.id, + name: node.name, + geoName: node.geo_name || node.name, + route: buildNodeDetailHref(node.id), + derivedFromGeo, + requestCount: node.request_count, + errorCount: node.error_count, + activeEventCount: node.active_event_count, + status: node.status, + openrestyStatus: node.openresty_status, + tone, + value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)], + itemStyle: { + color: toneColor, + borderColor: toneBorder, + borderWidth: 2, + shadowBlur: 18, + shadowColor: toneColor, + }, + emphasis: { + itemStyle: { + shadowBlur: 24, + shadowColor: toneColor, + borderColor: toneBorder, + borderWidth: 2, + }, + }, + }; + }), + [mapPalette, nodes], + ); + + const mapOption = useMemo( + () => ({ + backgroundColor: 'transparent', + tooltip: { + trigger: 'item', + backgroundColor: isDark + ? 'rgba(15,23,42,0.96)' + : 'rgba(255,255,255,0.96)', + borderColor: isDark + ? 'rgba(148,163,184,0.2)' + : 'rgba(148,163,184,0.24)', + borderWidth: 1, + textStyle: { + color: isDark ? '#e2e8f0' : '#0f172a', + fontSize: 12, + }, + formatter: (params: unknown) => { + const data = (params as { data?: MapNodeDatum }).data; + if (!data) { + return ''; + } + + const locationLine = data.derivedFromGeo + ? data.geoName + : `${data.geoName} · 备用落点`; + + return [ + `
${data.name}
`, + `
${locationLine}
`, + `
请求 ${data.requestCount.toLocaleString('zh-CN')} · 错误 ${data.errorCount.toLocaleString('zh-CN')}
`, + `
活动异常 ${data.activeEventCount} · 节点 ${getNodeStatusLabel(data.status)}
`, + `
OpenResty ${getOpenrestyStatusLabel(data.openrestyStatus)}
`, + ].join(''); + }, + }, + geo: { + map: 'world', + roam: false, + silent: true, + top: 18, + bottom: 18, + left: 10, + right: 10, + itemStyle: { + areaColor: mapPalette.areaColor, + borderColor: mapPalette.borderColor, + borderWidth: 0.8, + shadowBlur: 12, + shadowColor: mapPalette.shadowColor, + }, + emphasis: { + disabled: true, + }, + }, + series: [ + { + type: 'effectScatter', + coordinateSystem: 'geo', + data: mapNodes, + showEffectOn: 'render', + rippleEffect: { + scale: 4, + brushType: 'stroke', + }, + symbolSize: (value: unknown) => { + const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1; + return Math.max(10, Math.min(24, 10 + Math.log10(size + 1) * 4.5)); + }, + label: { + show: mapNodes.length <= 8, + position: 'right', + distance: 8, + formatter: '{b}', + color: mapPalette.labelColor, + fontSize: 11, + backgroundColor: isDark ? 'rgba(8,15,31,0.7)' : 'rgba(255,255,255,0.88)', + borderColor: isDark + ? 'rgba(148,163,184,0.16)' + : 'rgba(148,163,184,0.2)', + borderWidth: 1, + borderRadius: 999, + padding: [4, 8], + }, + emphasis: { + scale: true, + label: { + show: true, + }, + }, + }, + ], + }), + [isDark, mapNodes, mapPalette], + ); return ( -
-
+
+
-

+

Global Stage

-

全球态势板

-

+

+ 全球态势板 +

+

先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏, 让总览页真正承担值守入口的职责。

-
+
数据生成于 {formatDateTime(generatedAt)}
@@ -222,100 +549,110 @@ export function WorldStage({
-
-
-
+
+
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
- - +
+ + + +
- {signalNodes.length === 0 ? ( -
- + {mapReady ? ( + { + if (params.data?.route) { + router.push(params.data.route); + } + }, + }} + style={{ height: '100%', width: '100%' }} /> -
- ) : ( - signalNodes.map((node, index) => { - const anchor = projectNodeCoordinates(node, index); - const tone = getNodeSignalTone(node); - return ( - -
-
- - -
-
-

- {node.name} -

-

- {(node.geo_name || node.name) + ' · '} - 请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '} - {node.active_event_count} -

- {!anchor.derivedFromGeo ? ( -

- 未配置经纬度,当前使用备用落点 -

- ) : null} -
-
- - ); - }) - )} + ) : ( +
+ +
+ )} +
-
+ {mapReady && nodes.length === 0 ? ( +
+
+ 当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。 +
+
+ ) : null} + +
{sourceCountries.length > 0 ? ( sourceCountries.slice(0, 3).map((item, index) => ( )) ) : ( -
+
当前还没有可用于全球分布展示的来源国家数据。
)}
-
+
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。 - 未配置坐标的节点仍会使用备用落点,不影响首页态势判断。 + 未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
@@ -325,37 +662,63 @@ export function WorldStage({ label="在线覆盖" value={formatPercent(onlineRate)} hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`} + isDark={isDark} />
-
+
-

+

节点健康清单

-

+

风险优先队列

查看全部节点 @@ -365,14 +728,29 @@ export function WorldStage({
-

+

{node.name}

-

+

请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '} {node.error_count.toLocaleString('zh-CN')}

@@ -393,8 +771,15 @@ export function WorldStage({ ))} {nodes.length === 0 ? ( -
- 当前没有节点健康数据,等节点开始 heartbeat 后这里会出现世界覆盖信号。 +
+ 当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
) : null}
diff --git a/atsf_server/web/package.json b/atsf_server/web/package.json index 5e752d74..860df661 100644 --- a/atsf_server/web/package.json +++ b/atsf_server/web/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", + "echarts-maps": "^1.1.0", "marked": "^17.0.4", "next": "15.2.2", "react": "19.0.0", diff --git a/atsf_server/web/pnpm-lock.yaml b/atsf_server/web/pnpm-lock.yaml index 564bd6f5..45e1313b 100644 --- a/atsf_server/web/pnpm-lock.yaml +++ b/atsf_server/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: echarts-for-react: specifier: ^3.0.6 version: 3.0.6(echarts@6.0.0)(react@19.0.0) + echarts-maps: + specifier: ^1.1.0 + version: 1.1.0 marked: specifier: ^17.0.4 version: 17.0.4 @@ -1433,6 +1436,9 @@ packages: echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 react: ^15.0.0 || >=16.0.0 + echarts-maps@1.1.0: + resolution: {integrity: sha512-kr3rP50zETiib4QUQZBep9VdfE0i3NKL+6UfTcxyV1mpYzokW2RJmx3WnYeY6ColOsvtUkL9fAdsFz5jUDhCeA==} + echarts@6.0.0: resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} @@ -4162,6 +4168,8 @@ snapshots: react: 19.0.0 size-sensor: 1.0.3 + echarts-maps@1.1.0: {} + echarts@6.0.0: dependencies: tslib: 2.3.0 diff --git a/atsf_server/web/tests/unit/dashboard-overview.test.tsx b/atsf_server/web/tests/unit/dashboard-overview.test.tsx index 53bbb41b..9ad8c405 100644 --- a/atsf_server/web/tests/unit/dashboard-overview.test.tsx +++ b/atsf_server/web/tests/unit/dashboard-overview.test.tsx @@ -2,18 +2,56 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ThemeProvider } from '@/components/providers/theme-provider'; import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; vi.mock('echarts-for-react', () => ({ default: () =>
, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + describe('DashboardOverview', () => { afterEach(() => { vi.unstubAllGlobals(); }); + function stubMatchMedia() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + } + + function renderDashboardOverview() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + render( + + + + + , + ); + } + it('renders dashboard summary cards', async () => { + stubMatchMedia(); + vi.stubGlobal( 'fetch', vi.fn((input: RequestInfo | URL) => { @@ -203,19 +241,7 @@ describe('DashboardOverview', () => { }), ); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - render( - - - , - ); + renderDashboardOverview(); expect(await screen.findByText('全球态势板')).toBeInTheDocument(); expect(await screen.findByText('系统健康摘要')).toBeInTheDocument(); @@ -226,4 +252,103 @@ describe('DashboardOverview', () => { expect(await screen.findByText('处置建议')).toBeInTheDocument(); expect(await screen.findByText('节点健康列表')).toBeInTheDocument(); }); + + it('renders empty state when dashboard arrays are returned as null', async () => { + stubMatchMedia(); + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/dashboard/overview')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: { + generated_at: '2026-03-14T08:00:00Z', + summary: { + total_nodes: 0, + online_nodes: 0, + offline_nodes: 0, + pending_nodes: 0, + unhealthy_nodes: 0, + active_alerts: 0, + lagging_nodes: 0, + }, + traffic: { + request_count: 0, + unique_visitors: 0, + error_count: 0, + estimated_qps: 0, + reported_nodes: 0, + }, + capacity: { + average_cpu_usage_percent: 0, + average_memory_usage_percent: 0, + high_cpu_nodes: 0, + high_memory_nodes: 0, + high_storage_nodes: 0, + }, + config: { + active_version: '', + lagging_nodes: 0, + pending_nodes: 0, + }, + risk: { + critical_alerts: 0, + warning_alerts: 0, + info_alerts: 0, + offline_nodes: 0, + unhealthy_nodes: 0, + lagging_nodes: 0, + high_cpu_nodes: 0, + high_memory_nodes: 0, + high_storage_nodes: 0, + }, + peaks: { + peak_request_hour: { + bucket_started_at: '', + request_count: 0, + error_count: 0, + }, + peak_error_hour: { + bucket_started_at: '', + request_count: 0, + error_count: 0, + }, + busiest_node: null, + riskiest_node: null, + }, + distributions: { + source_countries: null, + status_codes: null, + top_domains: null, + }, + trends: { + traffic_24h: null, + capacity_24h: null, + network_24h: null, + disk_io_24h: null, + }, + nodes: null, + active_alerts: null, + }, + }), + ), + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }), + ); + + renderDashboardOverview(); + + expect(await screen.findByText('暂无活动异常')).toBeInTheDocument(); + expect(await screen.findByText('暂无节点')).toBeInTheDocument(); + expect(await screen.findByText('暂无来源分布数据')).toBeInTheDocument(); + }); }); diff --git a/atsf_server/web/types/echarts-maps.d.ts b/atsf_server/web/types/echarts-maps.d.ts new file mode 100644 index 00000000..ec27fa83 --- /dev/null +++ b/atsf_server/web/types/echarts-maps.d.ts @@ -0,0 +1 @@ +declare module 'echarts-maps/world.js';