Files
map-asset-gateway/services/api-go/internal/basemapapi/admin_template.go
T
Harmon 73ad03fa59 Rewrite the gateway around health, retries, slicing and a Windows service
Scanner
- manifest lookup is now depth-bounded and prunes numeric zoom directories, so
  a scan no longer walks the whole tile payload
- rescans only report a version as updated when something actually changed
- overlapping runs are refused instead of interleaving their bookkeeping

Hot path
- token authorisation loads only that token grants and throttles the
  last_used_at write instead of touching it on every tile
- SQLite runs in WAL with a busy timeout and a real connection pool
- the catalog lookup used by /tiles no longer loads every basemap

Security
- the admin surface requires ADMIN_TOKEN (bearer) or ADMIN_USER/ADMIN_PASSWORD
  (basic), and the service refuses to bind off-host without credentials
- CORS is an explicit allow-list; newly minted tokens never reach a redirect URL
- client tokens are redacted from the access log

Operations
- resource health inspection flags a moved or deleted tile root before a 404
- pushes retry with backoff and can be re-queued by hand; the schedule lives in
  the database so a restart does not lose it
- access log plus /metrics counters

Image slicing
- tile_jobs drive the GDAL bundled with QGIS via scripts/gdal2tiles-qgis.bat
- layer_sources remembers what a layer is made of: submit one source to append
  a flight, or all of them to rebuild the layer as a VRT mosaic with no tile
  seams; the console exposes both, including a one-click rebuild
- slicing is available to integrations through a narrow slice token scope
  (submit, poll, idempotent by code, completion callback)

Console
- rebuilt around a fixed rail and single-view navigation with health and
  outstanding work leading, instead of one long scrolling page

Windows service
- native install/start/stop/status with crash recovery, auto-start and a
  rotating log file, so no NSSM wrapper is needed
- start-all.bat and stop-all.bat are removed: they killed the process by port
  with taskkill, which fought the service restart policy

Verified with 49 tests and live runs against real tile data and real GDAL.
2026-10-16 10:35:30 +08:00

1126 lines
61 KiB
Go

package basemapapi
const adminTemplate = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>底图网关 · 运维台</title>
<style>
:root {
--ground: #070c12;
--panel: #0e161f;
--panel-2: #121d29;
--line: #1d2a39;
--line-2: #2b3d51;
--ink: #e8eef6;
--ink-2: #adbccb;
--ink-3: #8496a9;
--accent: #35c6c0;
--accent-ink: #04211f;
--ok: #3ddc84;
--warn: #ffd23f;
--bad: #ff3b57;
--r-0: #24414a;
--r-1: #2fb3a3;
--r-2: #3ddc84;
--r-3: #ffd23f;
--r-4: #ff8a2b;
--r-5: #ff3b57;
--r-6: #d64bff;
--z-rail: 20;
--z-top: 30;
--z-toast: 60;
--font: "Segoe UI", "Microsoft YaHei UI", system-ui, -apple-system, sans-serif;
--mono: "Cascadia Mono", "Consolas", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font: 400 13px/1.55 var(--font);
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: #6fe3de; }
::selection { background: var(--accent); color: var(--accent-ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
input, textarea, select, button { font: inherit; color: inherit; }
input, textarea, select { caret-color: var(--accent); }
* { scrollbar-color: var(--line-2) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--line-2); border: 3px solid var(--ground); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: #3b5069; }
::-webkit-scrollbar-track { background: transparent; }
.skip { position: absolute; left: -9999px; top: 0; background: var(--accent); color: var(--accent-ink); padding: 8px 14px; z-index: var(--z-toast); }
.skip:focus { left: 8px; top: 8px; }
.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; }
.rail {
position: sticky; top: 0; height: 100vh; overflow-y: auto;
background: var(--panel);
border-right: 1px solid var(--line);
display: flex; flex-direction: column; gap: 4px; padding: 14px 10px 18px;
}
.brand { padding: 4px 8px 14px; }
.brand b { display: block; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
.brand span { display: block; margin-top: 3px; color: var(--ink-3); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
.nav-label { padding: 12px 8px 5px; color: var(--ink-3); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; }
.nav-item {
display: flex; align-items: center; gap: 9px;
padding: 7px 9px; border-radius: 5px;
color: var(--ink-2); text-decoration: none; font-size: 13px;
border: 1px solid transparent;
}
.nav-item:hover { background: var(--panel-2); color: var(--ink); }
.nav-item[aria-current="page"] { background: #16242f; color: var(--ink); border-color: var(--line-2); }
.nav-item[aria-current="page"] svg { color: var(--accent); }
.nav-item svg { width: 15px; height: 15px; flex: none; color: var(--ink-3); }
.nav-item .n { margin-left: auto; color: var(--ink-3); font-size: 11px; font-family: var(--mono); }
.rail-foot { margin-top: auto; padding: 12px 8px 0; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 11px; }
.main { min-width: 0; }
.topbar {
position: sticky; top: 0; z-index: var(--z-top);
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 10px 22px;
background: rgba(7, 12, 18, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.topbar h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
.topbar .spacer { flex: 1 1 auto; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border: 1px solid var(--line-2); border-radius: 999px;
color: var(--ink-2); font-size: 11.5px; background: var(--panel);
}
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--idle, #64768a); }
.chip.is-ok .dot { background: var(--ok); }
.chip.is-bad .dot { background: var(--bad); }
.views { padding: 20px 22px 64px; max-width: 1500px; }
.view[hidden] { display: none; }
.view.sweep { animation: sweep 260ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes sweep {
from { opacity: 0; transform: translateY(-6px); clip-path: inset(0 0 84% 0); }
to { opacity: 1; transform: none; clip-path: inset(0 0 0 0); }
}
@media (prefers-reduced-motion: reduce) { .view.sweep { animation: none; } }
h2 { margin: 0; font-size: 15px; font-weight: 620; letter-spacing: -0.01em; }
label { display: block; margin-bottom: 3px; color: var(--ink-2); font-size: 11.5px; font-weight: 550; }
.lead { color: var(--ink-2); margin: 6px 0 0; max-width: 72ch; }
.sec { margin: 0 0 26px; }
.sec-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.sec-head .count { color: var(--ink-3); font-size: 11.5px; font-family: var(--mono); }
.sec-head .spacer { flex: 1 1 auto; }
.band { background: var(--panel); border: 1px solid var(--line); border-radius: 7px; }
.band-body { padding: 14px; }
.split { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 18px; align-items: start; }
@media (max-width: 1080px) { .split { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 860px) {
.shell { grid-template-columns: minmax(0, 1fr); }
.rail { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); }
.brand, .nav-label, .rail-foot { display: none; }
.views, .topbar { padding-left: 14px; padding-right: 14px; }
}
.scan { display: flex; align-items: flex-end; gap: 3px; height: 46px; }
.scan { justify-content: flex-start; }
.scan i { flex: 1 1 0; max-width: 46px; min-width: 4px; border-radius: 2px 2px 0 0; background: var(--r-1); opacity: 0.9; }
.scan i.ramp-ok { background: var(--r-2); }
.scan i.ramp-warn { background: var(--r-3); }
.scan i.ramp-bad { background: var(--r-5); }
.scan i.ramp-idle { background: var(--r-0); }
.scan-scale { display: flex; justify-content: space-between; margin-top: 6px; color: var(--ink-3); font-size: 10.5px; font-family: var(--mono); }
.legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-2); font-size: 11.5px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.metric { background: var(--panel); padding: 11px 13px; }
.metric b { display: block; font-size: 19px; font-weight: 620; font-family: var(--mono); letter-spacing: -0.02em; }
.metric span { display: block; margin-top: 2px; color: var(--ink-2); font-size: 11.5px; }
.metric.is-bad b { color: var(--bad); }
.metric.is-warn b { color: var(--warn); }
ul.attention { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
ul.attention li { display: flex; align-items: baseline; gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--line); }
ul.attention li:last-child { border-bottom: 0; }
ul.attention .what { font-weight: 550; }
ul.attention .why { color: var(--ink-2); font-size: 12px; }
ul.attention .at { margin-left: auto; color: var(--ink-3); font-size: 11.5px; font-family: var(--mono); white-space: nowrap; }
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 600; border: 1px solid transparent; white-space: nowrap; }
.badge.success { background: rgba(61,220,132,0.16); color: var(--ok); border-color: rgba(61,220,132,0.4); }
.badge.warning { background: rgba(255,210,63,0.16); color: var(--warn); border-color: rgba(255,210,63,0.4); }
.badge.danger { background: rgba(255,59,87,0.16); color: #ff8a9c; border-color: rgba(255,59,87,0.45); }
.badge.neutral { background: rgba(132,150,169,0.14); color: var(--ink-2); border-color: var(--line-2); }
.notice { padding: 10px 13px; border-radius: 6px; margin-bottom: 16px; border: 1px solid var(--line-2); background: var(--panel-2); }
.notice.danger { border-color: rgba(255,59,87,0.5); background: rgba(255,59,87,0.1); color: #ffb3bf; }
.mint { border: 1px solid rgba(53,198,192,0.5); background: rgba(53,198,192,0.09); border-radius: 7px; padding: 13px 14px; margin-bottom: 18px; }
.mint code { display: block; margin: 8px 0 10px; padding: 9px 11px; background: var(--ground); border: 1px solid var(--line-2); border-radius: 5px; font-family: var(--mono); font-size: 12.5px; word-break: break-all; color: var(--ink); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 8px 13px; color: var(--ink-3); font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; border-bottom: 1px solid var(--line); white-space: nowrap; }
td { padding: 10px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--panel-2); }
.t-wrap { overflow-x: auto; }
.mono { font-family: var(--mono); font-size: 12px; }
.small { font-size: 11.5px; }
.muted { color: var(--ink-3); }
.break { word-break: break-all; }
.nowrap { white-space: nowrap; }
.row-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.stack { display: flex; flex-direction: column; gap: 7px; }
.grid-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 12px; align-items: end; }
input[type="text"], input[type="number"], input:not([type]), textarea, select {
width: 100%; padding: 7px 9px; background: var(--ground);
border: 1px solid var(--line-2); border-radius: 5px; color: var(--ink);
}
input::placeholder, textarea::placeholder { color: var(--ink-3); }
input:disabled, textarea:disabled, select:disabled { opacity: 0.5; cursor: not-allowed; }
textarea { min-height: 62px; resize: vertical; font-family: var(--mono); font-size: 12px; }
.field-note { margin-top: 3px; color: var(--ink-3); font-size: 11px; }
button {
padding: 7px 13px; border-radius: 5px; cursor: pointer;
background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent);
font-weight: 620; font-size: 12.5px;
transition: filter 120ms ease-out;
}
button:hover { filter: brightness(1.12); }
button:active { transform: translateY(1px); }
button[disabled] { opacity: 0.45; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--ink-2); border-color: var(--line-2); font-weight: 500; }
.btn-ghost:hover { background: var(--panel-2); color: var(--ink); filter: none; }
.btn-quiet { background: transparent; border-color: transparent; color: var(--ink-2); padding: 5px 8px; }
.btn-quiet:hover { background: var(--panel-2); color: var(--ink); filter: none; }
.btn-danger { background: transparent; color: #ff8a9c; border-color: rgba(255,59,87,0.45); font-weight: 500; }
.btn-danger:hover { background: rgba(255,59,87,0.12); filter: none; }
details.form { border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
details.form > summary { cursor: pointer; padding: 10px 14px; color: var(--ink); font-weight: 550; list-style: none; }
details.form > summary::-webkit-details-marker { display: none; }
details.form > summary::before { content: "+"; display: inline-block; width: 14px; color: var(--accent); font-family: var(--mono); }
details.form[open] > summary::before { content: "-"; }
details.form[open] > summary { border-bottom: 1px solid var(--line); }
.ver { border: 1px solid var(--line); border-radius: 6px; padding: 9px 11px; margin-top: 7px; background: var(--panel-2); }
.ver-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 12px; margin: 0; }
.kv dt { color: var(--ink-3); font-size: 11.5px; }
.kv dd { margin: 0; word-break: break-all; font-family: var(--mono); font-size: 12px; }
.empty { padding: 22px 14px; text-align: center; color: var(--ink-3); }
.test-out { margin-top: 6px; font-size: 12px; font-family: var(--mono); }
.test-out.ok { color: var(--ok); }
.test-out.bad { color: var(--bad); }
.search { width: 210px; }
.check { display: flex; align-items: center; gap: 7px; font-weight: 500; margin: 0; }
.check input { width: auto; margin: 0; }
.toolbar { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; }
</style>
</head>
<body data-view="{{if .CreatedToken}}tokens{{end}}">
<a class="skip" href="#work">跳到主内容</a>
<div class="shell">
<aside class="rail">
<div class="brand">
<b>底图网关</b>
<span>Basemap Pusher</span>
</div>
<div class="nav-label">运维</div>
<a class="nav-item" href="#overview" data-view-link="overview">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 12h4l2-7 2 11 2-6h4"/></svg>
总览
</a>
<a class="nav-item" href="#basemaps" data-view-link="basemaps">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 5l7-2 7 2-7 2-7-2z"/><path d="M3 10l7 2 7-2"/><path d="M3 15l7 2 7-2"/></svg>
底图<span class="n">{{len .Basemaps}}</span>
</a>
<a class="nav-item" href="#vectors" data-view-link="vectors">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 16V6l6-3 6 3v10"/><path d="M8 16v-5h4v5"/></svg>
矢量<span class="n">{{len .VectorAssets}}</span>
</a>
<a class="nav-item" href="#tokens" data-view-link="tokens">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7.5" cy="10" r="3.5"/><path d="M11 10h6"/><path d="M15 10v3"/></svg>
令牌<span class="n">{{len .Tokens}}</span>
</a>
<a class="nav-item" href="#pushes" data-view-link="pushes">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M10 15V5"/><path d="M6 9l4-4 4 4"/><path d="M4 16h12"/></svg>
推送<span class="n">{{len .TargetSystems}}</span>
</a>
<div class="nav-label">系统</div>
<a class="nav-item" href="#scans" data-view-link="scans">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 5h12v10H4z"/><path d="M7 8h6"/><path d="M7 11h4"/></svg>
扫描<span class="n">{{len .ScanSources}}</span>
</a>
<a class="nav-item" href="#tiles" data-view-link="tiles">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 4h5v5H4z"/><path d="M11 4h5v5h-5z"/><path d="M4 11h5v5H4z"/><path d="M11 11h5v5h-5z"/></svg>
切片<span class="n">{{len .TileJobs}}</span>
</a>
<a class="nav-item" href="#system" data-view-link="system">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="10" cy="10" r="2.6"/><path d="M10 3v2M10 15v2M3 10h2M15 10h2M5 5l1.5 1.5M13.5 13.5L15 15M15 5l-1.5 1.5M6.5 13.5L5 15"/></svg>
服务
</a>
<div class="rail-foot">{{.SystemInfo.ServiceName}} · v{{.SystemInfo.ServiceVersion}}</div>
</aside>
<div class="main">
<header class="topbar">
<h1 id="view-title">总览</h1>
<span class="chip {{if .SystemInfo.Env}}is-ok{{end}}"><span class="dot"></span>{{if .SystemInfo.Env}}{{.SystemInfo.Env}}{{else}}未配置环境{{end}}</span>
<span class="chip">启动 {{fmtTime .SystemInfo.StartedAt}}</span>
<span class="spacer"></span>
<span class="chip" id="metrics-chip"><span class="dot"></span><span id="metrics-chip-text">指标加载中</span></span>
</header>
<main class="views" id="work">
{{if .Flash}}<div class="notice {{flashClass .Flash}}">{{.Flash}}</div>{{end}}
{{if .CreatedToken}}
<div class="mint">
<h2>访问令牌已创建</h2>
<p class="lead">明文仅显示这一次,服务端只保存哈希。请立即复制并交付给使用者。</p>
<code>{{.CreatedToken}}</code>
<div class="row-actions">
<button type="button" data-copy="{{.CreatedToken}}">复制令牌</button>
<span class="small muted" data-copy-feedback hidden>已复制到剪贴板</span>
</div>
</div>
{{end}}
<section class="view" id="view-overview" hidden>
<div class="split">
<div class="sec">
<div class="sec-head">
<h2>待处理</h2>
<span class="count" id="attention-count"></span>
</div>
<div class="band">
<ul class="attention" id="attention">
{{range .ScanRuns}}{{if eq .Status "failed"}}
<li><span class="badge danger">扫描失败</span><span class="what">{{.SourceCode}}</span><span class="why">{{index .Summary "error"}}</span><span class="at">{{fmtTime .StartedAt}}</span></li>
{{end}}{{end}}
{{range .PushRecords}}{{if eq .Status "failed"}}
<li><span class="badge danger">推送失败</span><span class="what">{{.TargetSystemCode}}</span><span class="why">{{.BasemapCode}} / {{.BasemapVersion}}</span><span class="at">{{fmtTime .PushedAt}}</span></li>
{{end}}{{end}}
{{range .VectorPushRecords}}{{if eq .Status "failed"}}
<li><span class="badge danger">推送失败</span><span class="what">{{.TargetSystemCode}}</span><span class="why">{{.VectorCode}}</span><span class="at">{{fmtTime .PushedAt}}</span></li>
{{end}}{{end}}
{{range .PushRecords}}{{if eq .Status "pending"}}
<li><span class="badge warning">推送进行中</span><span class="what">{{.TargetSystemCode}}</span><span class="why">{{.BasemapCode}} / {{.BasemapVersion}}</span><span class="at">{{fmtTime .PushedAt}}</span></li>
{{end}}{{end}}
{{range .VectorPushRecords}}{{if eq .Status "pending"}}
<li><span class="badge warning">推送进行中</span><span class="what">{{.TargetSystemCode}}</span><span class="why">{{.VectorCode}}</span><span class="at">{{fmtTime .PushedAt}}</span></li>
{{end}}{{end}}
{{range .Basemaps}}{{range .Versions}}{{$h := index $.VersionHealth .ID}}{{if and $h.Health (ne $h.Health "ok")}}
<li><span class="badge danger">底图缺失</span><span class="what">{{.BasemapCode}} / {{.Version}}</span><span class="why">{{if $h.Detail}}{{$h.Detail}}{{else}}路径不可用{{end}}</span><span class="at">巡检</span></li>
{{end}}{{end}}{{end}}
{{range .VectorAssets}}{{$h := index $.VectorHealth .ID}}{{if and $h.Health (ne $h.Health "ok")}}
<li><span class="badge danger">矢量缺失</span><span class="what">{{.Code}}</span><span class="why">{{if $h.Detail}}{{$h.Detail}}{{else}}文件不可用{{end}}</span><span class="at">巡检</span></li>
{{end}}{{end}}
<li class="attention-empty"><span class="why">没有失败的扫描或推送,资源也都在位。</span></li>
</ul>
</div>
<div class="toolbar" style="margin-top:10px">
<form method="post" action="/admin/health/check"><button type="submit" class="btn-ghost">立即巡检资源</button></form>
<span class="small muted">按目录内容核对每个底图版本与矢量文件是否还在原位。</span>
</div>
</div>
<div class="sec">
<div class="sec-head"><h2>近期扫描</h2><span class="count">越靠右越新</span></div>
<div class="band"><div class="band-body">
<div class="scan">
{{range .ScanRuns}}<i class="{{if eq .Status "failed"}}ramp-bad{{else if eq .Status "running"}}ramp-warn{{else}}ramp-ok{{end}}" title="{{.SourceCode}} · {{statusText .Status}} · 新增 {{.AddedCount}} 更新 {{.UpdatedCount}} 删除 {{.RemovedCount}} · {{fmtTime .StartedAt}}"></i>{{end}}
</div>
<div class="scan-scale"><span>最近 {{len .ScanRuns}} 次</span><span>现在</span></div>
<div class="legend">
<span><i style="background:var(--r-2)"></i>成功</span>
<span><i style="background:var(--r-3)"></i>进行中</span>
<span><i style="background:var(--r-5)"></i>失败</span>
</div>
</div></div>
</div>
</div>
<div class="sec" style="margin-top:24px">
<div class="sec-head">
<h2>服务指标</h2>
<span class="count">来自 /metrics,进程内计数</span>
<span class="spacer"></span>
<a class="small" href="/metrics" target="_blank" rel="noreferrer">查看原始输出</a>
</div>
<div class="metrics">
<div class="metric"><b id="m-req">-</b><span>请求总数</span></div>
<div class="metric"><b id="m-4xx">-</b><span>4xx 响应</span></div>
<div class="metric"><b id="m-5xx">-</b><span>5xx 响应</span></div>
<div class="metric"><b id="m-auth">-</b><span>鉴权失败</span></div>
<div class="metric"><b id="m-tiles">-</b><span>瓦片请求</span></div>
<div class="metric"><b id="m-miss">-</b><span>瓦片 404</span></div>
<div class="metric"><b id="m-up">-</b><span>运行时长</span></div>
</div>
<p class="field-note">指标在服务重启后归零。</p>
</div>
</section>
<section class="view" id="view-basemaps" hidden>
<div class="sec">
<div class="sec-head">
<h2>底图台账</h2>
<span class="count">{{len .Basemaps}} 个资源</span>
<span class="spacer"></span>
<input class="search" type="text" placeholder="筛查编码、名称、地址" data-table-filter="#basemaps-table">
</div>
<div class="band"><div class="t-wrap">
<table id="basemaps-table">
<thead><tr><th>资源</th><th>版本</th><th>默认</th><th>取图模板</th><th>验真</th></tr></thead>
<tbody>
{{range .Basemaps}}
<tr data-filterable="true">
<td>
<b>{{.Code}}</b>
<div class="small muted">{{.Name}}</div>
<div class="small muted">{{if .Description}}{{.Description}}{{else}}未填写资源说明{{end}}</div>
</td>
<td>
{{range .Versions}}
<div class="ver">
<div class="ver-head">
<span class="badge {{statusTone .Status}}">{{versionLabel .}}</span>
{{$h := index $.VersionHealth .ID}}{{if and $h.Health (ne $h.Health "ok")}}<span class="badge danger">文件缺失</span>{{else if $h.Health}}<span class="badge success">在位</span>{{end}}
<span class="small muted">级别 {{.MinZoom}}-{{.MaxZoom}}</span>
<span class="small muted">{{.TileFormat}}</span>
</div>
<div class="mono small break" style="margin-top:6px">{{.URLTemplate}}</div>
<div class="row-actions" style="margin-top:6px">
<button type="button" class="btn-quiet" data-copy="{{.URLTemplate}}">复制</button>
<span class="small muted" data-copy-feedback hidden>已复制</span>
</div>
</div>
{{end}}
</td>
<td class="nowrap">
<span class="mono">{{defaultVersion .Default}}</span>
{{if .Versions}}
<form method="post" action="/admin/basemaps/{{.Code}}/default" class="stack" style="margin-top:8px">
<select name="version">{{range .Versions}}<option value="{{.Version}}">{{.Version}}</option>{{end}}</select>
<button type="submit" class="btn-ghost">设为默认</button>
</form>
{{end}}
</td>
<td>
{{if .Default}}
<div class="mono small break">{{.Default.URLTemplate}}</div>
<div class="row-actions" style="margin-top:6px">
<button type="button" class="btn-quiet" data-copy="{{.Default.URLTemplate}}">复制</button>
<span class="small muted" data-copy-feedback hidden>已复制</span>
</div>
{{else}}<span class="muted">-</span>{{end}}
</td>
<td class="nowrap">
{{if .Default}}
<div class="stack">
<input type="text" placeholder="粘贴访问令牌" data-probe-token>
<button type="button" class="btn-ghost" data-probe-url="{{.Default.URLTemplate}}" data-probe-kind="tile">试拉一张</button>
<span class="test-out" data-probe-out hidden></span>
</div>
{{else}}<span class="muted">-</span>{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5"><div class="empty">还没有底图。先到「扫描」里执行一次目录扫描。</div></td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="5"><div class="empty">没有匹配的资源。</div></td></tr>
</tbody>
</table>
</div></div>
</div>
</section>
<section class="view" id="view-vectors" hidden>
<div class="sec">
<div class="sec-head">
<h2>矢量资源</h2>
<span class="count">{{len .VectorAssets}} 项</span>
<span class="spacer"></span>
<div class="toolbar">
<input class="search" type="text" placeholder="筛查编码、路径" data-table-filter="#vectors-table">
<form method="post" action="/admin/vectors/rescan"><button type="submit" class="btn-ghost">重新扫描</button></form>
</div>
</div>
<p class="field-note" style="margin:-6px 0 12px">扫描根目录:<span class="mono break">{{.SystemInfo.VectorScanRoot}}</span></p>
<div class="band"><div class="t-wrap">
<table id="vectors-table">
<thead><tr><th>资源</th><th>格式</th><th>大小</th><th>下载地址</th><th>验真</th></tr></thead>
<tbody>
{{range .VectorAssets}}
<tr data-filterable="true">
<td>
<b>{{.Code}}</b>
<div class="small muted">{{.Name}}</div>
<div class="mono small break">{{vectorPath .}}</div>
</td>
<td>
<span class="badge neutral">{{.Format}}</span>
{{$h := index $.VectorHealth .ID}}{{if and $h.Health (ne $h.Health "ok")}}<div style="margin-top:5px"><span class="badge danger">文件缺失</span></div>{{else if $h.Health}}<div style="margin-top:5px"><span class="badge success">在位</span></div>{{end}}
<div class="small muted" style="margin-top:5px">{{statusText .Status}}</div>
</td>
<td class="small nowrap">{{formatBytes .FileSize}}</td>
<td>
<div class="mono small break">{{.DownloadURL}}</div>
<div class="row-actions" style="margin-top:6px">
<button type="button" class="btn-quiet" data-copy="{{.DownloadURL}}">复制</button>
<span class="small muted" data-copy-feedback hidden>已复制</span>
</div>
</td>
<td class="nowrap">
<div class="stack">
<input type="text" placeholder="粘贴访问令牌" data-probe-token>
<button type="button" class="btn-ghost" data-probe-url="{{.DownloadURL}}" data-probe-kind="vector">试下载</button>
<span class="test-out" data-probe-out hidden></span>
</div>
</td>
</tr>
{{else}}
<tr><td colspan="5"><div class="empty">还没有矢量资源。把 GeoJSON 或 shapefile 放进扫描根目录后重新扫描。</div></td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="5"><div class="empty">没有匹配的资源。</div></td></tr>
</tbody>
</table>
</div></div>
</div>
</section>
<section class="view" id="view-tokens" hidden>
<div class="sec">
<details class="form" {{if .CreatedToken}}open{{end}}>
<summary>签发新令牌</summary>
<div class="band-body">
<form class="stack" method="post" action="/admin/tokens">
<div class="grid-form">
<div><label>令牌名称</label><input name="name" placeholder="viewer-a" required></div>
<div><label>有效时长(分钟)</label><input name="expires_at" inputmode="numeric" placeholder="留空使用默认有效期"><div class="field-note">留空走服务端默认值。</div></div>
<div><label>附加能力</label><label class="check"><input type="checkbox" name="scopes" value="slice"> 允许提交切片任务</label><div class="field-note">不勾选则只能读取已授权的底图与矢量。</div></div>
</div>
<div class="grid-form">
<div><label>底图编码</label><textarea name="basemap_codes" placeholder="gaode-image, google-image"></textarea><div class="field-note">逗号或换行分隔,写底图的 code。</div></div>
<div><label>矢量资源</label><textarea name="vector_codes" placeholder="全国行政区.geojson, landuse/parcel.shp"></textarea><div class="field-note">可写 code、相对路径或文件名。</div></div>
</div>
<div><button type="submit">签发令牌</button></div>
</form>
</div>
</details>
</div>
<div class="sec">
<div class="sec-head"><h2>已签发</h2><span class="count">{{len .Tokens}} 枚</span><span class="spacer"></span><input class="search" type="text" placeholder="筛查名称、前缀、授权" data-table-filter="#tokens-table"></div>
<div class="band"><div class="t-wrap">
<table id="tokens-table">
<thead><tr><th>令牌</th><th>授权范围</th><th>状态</th><th>有效期</th><th></th></tr></thead>
<tbody>
{{range .Tokens}}
<tr data-filterable="true">
<td>
<b>{{.Name}}</b>
<div class="mono small">{{.TokenPrefix}}</div>
<div class="small muted">创建 {{fmtTime .CreatedAt}}</div>
</td>
<td class="small">
<div><span class="muted">底图:</span>{{tokenGrants .Grants}}</div>
<div style="margin-top:4px"><span class="muted">矢量:</span>{{vectorTokenGrants .VectorGrants}}</div>
<div style="margin-top:4px"><span class="muted">能力:</span>{{if .Scopes}}{{range $i, $s := .Scopes}}{{if $i}} · {{end}}{{$s}}{{end}}{{else}}-{{end}}</div>
</td>
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
<td class="small nowrap">
<div>到期 {{fmtTimePtr .ExpiresAt}}</div>
<div class="muted">最近使用 {{fmtTimePtr .LastUsedAt}}</div>
</td>
<td>
<form class="inline" method="post" action="/admin/tokens/{{.ID}}/disable" data-confirm="停用后该令牌立即失效,正在用它取图的下游会开始收到 401。确认停用?">
<button type="submit" class="btn-danger">停用</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5"><div class="empty">还没有令牌。签发一枚才能对外提供取图地址。</div></td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="5"><div class="empty">没有匹配的令牌。</div></td></tr>
</tbody>
</table>
</div></div>
</div>
</section>
<section class="view" id="view-pushes" hidden>
<div class="split">
<div class="sec">
<details class="form">
<summary>推送底图</summary>
<div class="band-body">
<form class="stack" method="post" action="/admin/pushes">
<div class="grid-form">
<div><label>目标系统编码</label><input name="target_code" placeholder="portal-a" required></div>
<div><label>底图编码</label><input name="basemap_code" placeholder="gaode-image" required></div>
<div><label>版本</label><input name="version" value="current" required></div>
</div>
<div><button type="submit">提交推送</button></div>
<div class="field-note">推送在后台执行,提交后回到这里刷新查看结果。</div>
</form>
</div>
</details>
</div>
<div class="sec">
<details class="form">
<summary>推送矢量</summary>
<div class="band-body">
<form class="stack" method="post" action="/admin/vector-pushes">
<div class="grid-form">
<div><label>目标系统编码</label><input name="target_code" placeholder="portal-a" required></div>
<div><label>矢量资源编码</label><input name="vector_code" placeholder="admin-boundary" required></div>
</div>
<div><button type="submit">提交推送</button></div>
</form>
</div>
</details>
</div>
</div>
<div class="sec">
<div class="sec-head"><h2>目标系统</h2><span class="count">{{len .TargetSystems}} 个</span></div>
<div class="band">
<div class="band-body" style="border-bottom:1px solid var(--line)">
<form class="stack" method="post" action="/admin/targets">
<div class="grid-form">
<div><label>系统编码</label><input name="code" placeholder="portal-a" required></div>
<div><label>系统名称</label><input name="name" placeholder="业务门户 A"></div>
<div><label>回调地址</label><input name="callback_url" placeholder="http://target/api/catalog" required></div>
<div><label>请求方法</label><input name="callback_method" value="POST"></div>
</div>
<div><label>附加请求头</label><textarea name="headers" placeholder="X-App-Key: abc123"></textarea><div class="field-note">每行一条 <span class="mono">名字: 值</span>。</div></div>
<div><button type="submit">保存目标系统</button></div>
</form>
</div>
<div class="t-wrap">
<table>
<thead><tr><th>系统</th><th>回调</th><th>状态</th></tr></thead>
<tbody>
{{range .TargetSystems}}
<tr>
<td><b>{{.Code}}</b><div class="small muted">{{.Name}}</div></td>
<td>
<div class="small muted">{{.CallbackMethod}}</div>
<div class="mono small break">{{.CallbackURL}}</div>
<div class="row-actions" style="margin-top:6px">
<button type="button" class="btn-quiet" data-copy="{{.CallbackURL}}">复制</button>
<span class="small muted" data-copy-feedback hidden>已复制</span>
</div>
</td>
<td><span class="badge {{enabledTone .Enabled}}">{{enabledText .Enabled}}</span></td>
</tr>
{{else}}
<tr><td colspan="3"><div class="empty">还没有目标系统。</div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="sec">
<div class="sec-head"><h2>推送记录</h2><span class="count">最近 10 条</span></div>
<div class="band"><div class="t-wrap">
<table>
<thead><tr><th>类型</th><th>目标</th><th>资源</th><th>状态</th><th>时间</th><th></th></tr></thead>
<tbody>
{{range .PushRecords}}
<tr>
<td><span class="badge neutral">底图</span></td>
<td>{{.TargetSystemCode}}</td>
<td class="small">{{.BasemapCode}} / {{.BasemapVersion}}</td>
<td>
<span class="badge {{statusTone .Status}}">{{statusText .Status}}</span>
{{if gt .AttemptCount 0}}<div class="small muted" style="margin-top:4px">已尝试 {{.AttemptCount}} 次</div>{{end}}
{{if .NextAttemptAt}}<div class="small muted">下次 {{fmtTimePtr .NextAttemptAt}}</div>{{end}}
{{if .ErrorMessage}}<div class="small muted break" style="margin-top:4px">{{.ErrorMessage}}</div>{{end}}
</td>
<td class="small nowrap">{{fmtTime .PushedAt}}</td>
<td>
{{if or (eq .Status "failed") (eq .Status "retrying")}}
<form class="inline" method="post" action="/admin/push-records/{{.ID}}/retry"><button type="submit" class="btn-ghost">重推</button></form>
{{end}}
</td>
</tr>
{{end}}
{{range .VectorPushRecords}}
<tr>
<td><span class="badge neutral">矢量</span></td>
<td>{{.TargetSystemCode}}</td>
<td class="small">{{.VectorCode}}</td>
<td>
<span class="badge {{statusTone .Status}}">{{statusText .Status}}</span>
{{if gt .AttemptCount 0}}<div class="small muted" style="margin-top:4px">已尝试 {{.AttemptCount}} 次</div>{{end}}
{{if .NextAttemptAt}}<div class="small muted">下次 {{fmtTimePtr .NextAttemptAt}}</div>{{end}}
{{if .ErrorMessage}}<div class="small muted break" style="margin-top:4px">{{.ErrorMessage}}</div>{{end}}
</td>
<td class="small nowrap">{{fmtTime .PushedAt}}</td>
<td>
{{if or (eq .Status "failed") (eq .Status "retrying")}}
<form class="inline" method="post" action="/admin/vector-push-records/{{.ID}}/retry"><button type="submit" class="btn-ghost">重推</button></form>
{{end}}
</td>
</tr>
{{end}}
{{if and (eq (len .PushRecords) 0) (eq (len .VectorPushRecords) 0)}}
<tr><td colspan="6"><div class="empty">还没有推送记录。</div></td></tr>
{{end}}
</tbody>
</table>
</div></div>
</div>
</section>
<section class="view" id="view-scans" hidden>
<div class="sec">
<details class="form" {{if eq (len .ScanSources) 0}}open{{end}}>
<summary>新增扫描源</summary>
<div class="band-body">
<form class="stack" method="post" action="/admin/scan-sources">
<div class="grid-form">
<div><label>扫描源编码</label><input name="code" value="local-basemaps" required></div>
<div><label>扫描源名称</label><input name="name" value="本地底图目录"></div>
<div><label>根目录路径</label><input name="root_path" value="{{.SystemInfo.ScanRoot}}" required></div>
<div><label>清单文件名</label><input name="manifest_name" value="manifest.json"></div>
</div>
<div><button type="submit">保存扫描源</button></div>
</form>
</div>
</details>
</div>
<div class="sec">
<div class="sec-head"><h2>扫描源</h2><span class="count">{{len .ScanSources}} 个</span></div>
<div class="band"><div class="t-wrap">
<table>
<thead><tr><th>来源</th><th>根目录</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{{range .ScanSources}}
<tr>
<td><b>{{.Code}}</b><div class="small muted">{{.Name}}</div></td>
<td>
<div class="mono small break">{{.RootPath}}</div>
<div class="small muted">清单文件:{{.ManifestName}}</div>
</td>
<td><span class="badge {{enabledTone .Enabled}}">{{enabledText .Enabled}}</span></td>
<td>
<form class="inline" method="post" action="/admin/scan-sources/{{.Code}}/run" data-confirm="扫描会按目录内容重建这个来源下的版本台账。确认执行?">
<button type="submit" class="btn-ghost">立即扫描</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty">还没有扫描源。</div></td></tr>
{{end}}
</tbody>
</table>
</div></div>
</div>
<div class="sec">
<div class="sec-head"><h2>扫描记录</h2><span class="count">最近 10 次</span></div>
<div class="band">
<div class="band-body" style="border-bottom:1px solid var(--line)">
<div class="scan">
{{range .ScanRuns}}<i class="{{if eq .Status "failed"}}ramp-bad{{else if eq .Status "running"}}ramp-warn{{else}}ramp-ok{{end}}"></i>{{end}}
</div>
<div class="scan-scale"><span>更早</span><span>现在</span></div>
</div>
<div class="t-wrap">
<table>
<thead><tr><th>来源</th><th>变更</th><th>状态</th><th>开始时间</th></tr></thead>
<tbody>
{{range .ScanRuns}}
<tr>
<td><b>{{.SourceCode}}</b></td>
<td class="small">新增 {{.AddedCount}} · 更新 {{.UpdatedCount}} · 删除 {{.RemovedCount}}{{if index .Summary "unchanged_count"}} · 未变 {{index .Summary "unchanged_count"}}{{end}}</td>
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span>{{if index .Summary "error"}}<div class="small muted break" style="margin-top:4px">{{index .Summary "error"}}</div>{{end}}</td>
<td class="small nowrap">{{fmtTime .StartedAt}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty">还没有扫描记录。</div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</section>
<section class="view" id="view-tiles" hidden>
<div class="sec">
<details class="form">
<summary>提交切片任务</summary>
<div class="band-body">
<form class="stack" method="post" action="/admin/tile-jobs">
<div class="grid-form">
<div><label>源影像路径</label><input name="source_path" placeholder="D:\data\input\image.tif" required></div>
<div><label>目标底图编码</label><input name="target_code" placeholder="gaode-2026" required><div class="field-note">切片会落到扫描根目录下的这个子目录。</div></div>
</div>
<div class="grid-form">
<div><label>最小级别</label><input name="zoom_min" inputmode="numeric" value="0"></div>
<div><label>最大级别</label><input name="zoom_max" inputmode="numeric" value="12"></div>
<div><label>瓦片尺寸</label><input name="tile_size" inputmode="numeric" value="256"></div>
<div><label>瓦片格式</label><input name="tile_format" value="webp"></div>
</div>
<div class="grid-form">
<div><label>任务编码</label><input name="code" placeholder="留空自动生成"></div>
<div><label>任务名称</label><input name="name" placeholder="留空取文件名"></div>
<div><label>申请人</label><input name="requested_by" placeholder="ops"></div>
<div><label>完成回调地址</label><input name="callback_url" placeholder="http://caller/api/tile-jobs/done"><div class="field-note">可选,留空则只写记录。</div></div>
</div>
<div><button type="submit">提交切片</button></div>
<div class="field-note">
实际执行命令:<span class="mono break">{{if .SystemInfo.TileJobCommand}}{{.SystemInfo.TileJobCommand}}{{else}}gdal2tiles.py --xyz --zoom=&#123;zmin&#125;-&#123;zmax&#125; --tilesize=&#123;tilesize&#125; &#123;input&#125; &#123;output&#125;{{end}}</span>
</div>
</form>
</div>
</details>
</div>
<div class="sec">
<div class="sec-head"><h2>切片任务</h2><span class="count">最近 10 个</span></div>
<div class="band"><div class="t-wrap">
<table>
<thead><tr><th>任务</th><th>源影像</th><th>级别</th><th>状态</th><th>提交时间</th></tr></thead>
<tbody>
{{range .TileJobs}}
<tr>
<td>
<b>{{.Code}}</b>
<div class="small muted">{{.Name}}</div>
<div class="small muted">目标底图:{{.TargetCode}}{{if gt .SourceCount 1}} · {{.SourceCount}} 源镶嵌{{else}} · 单源{{end}}</div>
{{if gt (len .SourcePaths) 1}}<div class="mono small break">{{range $i, $s := .SourcePaths}}{{if $i}}<br>{{end}}{{$s}}{{end}}</div>{{end}}
</td>
<td class="mono small break">{{.SourcePath}}</td>
<td class="small nowrap">{{.ZoomMin}}-{{.ZoomMax}} · {{.TileSize}}px · {{.TileFormat}}</td>
<td>
<span class="badge {{statusTone .Status}}">{{statusText .Status}}</span>
{{if .Message}}<div class="small muted break" style="margin-top:4px">{{.Message}}</div>{{end}}
{{if .CallbackURL}}<div class="small muted break" style="margin-top:4px">回调:{{.CallbackURL}}{{if .CallbackStatus}} · {{statusText .CallbackStatus}}{{if .CallbackAttempts}}(已尝试 {{.CallbackAttempts}} 次){{end}}{{end}}</div>{{end}}
{{if .CallbackMessage}}<div class="small muted break">{{.CallbackMessage}}</div>{{end}}
{{if .LogPath}}<div class="small muted break" style="margin-top:4px">日志:{{.LogPath}}</div>{{end}}
</td>
<td class="small nowrap">{{fmtTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5"><div class="empty">还没有切片任务。提交一个会把影像切成 z/x/y 瓦片并自动扫描入库。</div></td></tr>
{{end}}
</tbody>
</table>
</div></div>
</div>
<div class="sec">
<div class="sec-head"><h2>图层源清单</h2><span class="count">{{len .Layers}} 个图层</span></div>
<p class="field-note" style="margin:-6px 0 12px">同一个图层可以持续累加影像:日常只提交新飞的影像;想把整个图层重建成无接缝镶嵌,就把下面列出的路径一起提交。</p>
<div class="band"><div class="t-wrap">
<table>
<thead><tr><th>图层</th><th>源影像</th><th>怎么用</th></tr></thead>
<tbody>
{{range .Layers}}
<tr>
<td>
<b>{{.TargetCode}}</b>
<div class="small muted">{{len .Sources}} 张影像</div>
<div class="small muted">渲染名由调用端决定</div>
</td>
<td>
{{range .Sources}}
<div class="mono small break">{{.SourcePath}}<span class="muted"> · {{fmtTime .AddedAt}}</span></div>
{{end}}
</td>
<td class="small muted">
{{if gt (len .Sources) 1}}镶嵌重建:走 VRT 逐像素拼接,无瓦片接缝,级别 {{.ZoomMin}}-{{.ZoomMax}}。<br>{{else}}目前只有 1 张影像,重建等同于按原级别重切一次({{.ZoomMin}}-{{.ZoomMax}})。<br>{{end}}
日常增量:只提交新飞的影像,新影像覆盖到哪算哪。
<div class="row-actions" style="margin-top:8px">
<form method="post" action="/admin/tile-jobs/rebuild" data-confirm="会用该图层的 {{len .Sources}} 张影像重建整个瓦片目录,级别 {{.ZoomMin}}-{{.ZoomMax}},格式 {{.TileFormat}}。任务执行期间旧瓦片会被覆盖。确认提交?">
<input type="hidden" name="target_code" value="{{.TargetCode}}">
<button type="submit" class="btn-ghost">重建为镶嵌</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr><td colspan="3"><div class="empty">还没有图层源记录。提交切片任务后,这里会列出每个图层由哪些影像构成。</div></td></tr>
{{end}}
</tbody>
</table>
</div></div>
</div>
</section>
<section class="view" id="view-system" hidden>
<div class="split">
<div class="sec">
<div class="sec-head"><h2>服务</h2></div>
<div class="band"><div class="band-body">
<dl class="kv">
<dt>服务名</dt><dd>{{.SystemInfo.ServiceName}}</dd>
<dt>版本</dt><dd>{{.SystemInfo.ServiceVersion}}</dd>
<dt>提交</dt><dd>{{if .SystemInfo.Commit}}{{.SystemInfo.Commit}}{{else}}-{{end}}</dd>
<dt>环境</dt><dd>{{if .SystemInfo.Env}}{{.SystemInfo.Env}}{{else}}未配置{{end}}</dd>
<dt>启动时间</dt><dd>{{fmtTime .SystemInfo.StartedAt}}</dd>
</dl>
</div></div>
</div>
<div class="sec">
<div class="sec-head"><h2>对外地址</h2></div>
<div class="band"><div class="band-body">
<dl class="kv">
<dt>API</dt><dd>{{.SystemInfo.APIBaseURL}}</dd>
<dt>瓦片</dt><dd>{{.SystemInfo.TileBaseURL}}</dd>
<dt>矢量</dt><dd>{{.SystemInfo.VectorBaseURL}}</dd>
<dt>SQLite</dt><dd>{{.SystemInfo.SQLitePath}}</dd>
<dt>底图根目录</dt><dd>{{.SystemInfo.ScanRoot}}</dd>
<dt>矢量根目录</dt><dd>{{.SystemInfo.VectorScanRoot}}</dd>
</dl>
</div></div>
</div>
</div>
<div class="sec">
<div class="sec-head"><h2>端点</h2></div>
<div class="band"><div class="band-body">
<div class="legend">
<span><a href="/health" target="_blank" rel="noreferrer">/health</a> 存活探针</span>
<span><a href="/ready" target="_blank" rel="noreferrer">/ready</a> 就绪探针</span>
<span><a href="/metrics" target="_blank" rel="noreferrer">/metrics</a> Prometheus 指标</span>
<span><a href="/api/v1/system" target="_blank" rel="noreferrer">/api/v1/system</a> 服务信息</span>
</div>
</div></div>
</div>
</section>
</main>
</div>
</div>
<script>
(function () {
"use strict";
var TITLES = {
overview: "总览", basemaps: "底图台账", vectors: "矢量资源", tokens: "访问令牌",
pushes: "推送", scans: "扫描", tiles: "影像切片", system: "服务"
};
var VIEWS = Object.keys(TITLES);
function currentView() {
var id = (window.location.hash || "").replace(/^#/, "");
return VIEWS.indexOf(id) === -1 ? "overview" : id;
}
function show(view, animate) {
VIEWS.forEach(function (name) {
var section = document.getElementById("view-" + name);
if (section) section.hidden = name !== view;
});
Array.prototype.slice.call(document.querySelectorAll("[data-view-link]")).forEach(function (link) {
if (link.getAttribute("data-view-link") === view) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
});
var title = document.getElementById("view-title");
if (title) title.textContent = TITLES[view];
if (animate) {
var section = document.getElementById("view-" + view);
if (section) {
section.classList.remove("sweep");
void section.offsetWidth;
section.classList.add("sweep");
}
}
document.title = TITLES[view] + " · 底图网关";
}
window.addEventListener("hashchange", function () { show(currentView(), true); });
show(currentView(), false);
var initial = document.body.getAttribute("data-view");
if (initial && VIEWS.indexOf(initial) !== -1 && currentView() !== initial) {
window.location.hash = initial;
show(initial, true);
}
var attention = document.getElementById("attention");
if (attention) {
var real = attention.querySelectorAll("li:not(.attention-empty)");
var emptyRow = attention.querySelector(".attention-empty");
if (emptyRow) emptyRow.hidden = real.length > 0;
var counter = document.getElementById("attention-count");
if (counter) {
counter.textContent = real.length === 0 ? "无" : real.length + " 项需要处理";
counter.style.color = real.length === 0 ? "" : "var(--bad)";
}
}
function fallbackCopy(text) {
var area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "readonly");
area.style.position = "absolute";
area.style.left = "-9999px";
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
}
Array.prototype.slice.call(document.querySelectorAll("[data-copy]")).forEach(function (button) {
var original = button.textContent;
button.addEventListener("click", function () {
var text = button.getAttribute("data-copy") || "";
var feedback = button.parentElement ? button.parentElement.querySelector("[data-copy-feedback]") : null;
Promise.resolve()
.then(function () {
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
fallbackCopy(text);
})
.then(function () {
button.textContent = "已复制";
if (feedback) feedback.hidden = false;
window.setTimeout(function () {
button.textContent = original;
if (feedback) feedback.hidden = true;
}, 1400);
})
.catch(function () {
button.textContent = "复制失败";
window.setTimeout(function () { button.textContent = original; }, 1400);
});
});
});
function applyFilter(input) {
var table = document.querySelector(input.getAttribute("data-table-filter"));
if (!table) return;
var rows = Array.prototype.slice.call(table.querySelectorAll("tbody tr[data-filterable='true']"));
var emptyRow = table.querySelector("tbody .filter-empty");
var keyword = input.value.trim().toLowerCase();
var visible = 0;
rows.forEach(function (row) {
var match = keyword === "" || (row.textContent || "").toLowerCase().indexOf(keyword) !== -1;
row.hidden = !match;
if (match) visible += 1;
});
if (emptyRow) emptyRow.hidden = !(rows.length > 0 && keyword !== "" && visible === 0);
}
Array.prototype.slice.call(document.querySelectorAll("[data-table-filter]")).forEach(function (input) {
input.addEventListener("input", function () { applyFilter(input); });
applyFilter(input);
});
Array.prototype.slice.call(document.querySelectorAll("[data-confirm]")).forEach(function (form) {
form.addEventListener("submit", function (event) {
if (!window.confirm(form.getAttribute("data-confirm"))) event.preventDefault();
});
});
function probe(button) {
var cell = button.closest("td") || button.parentElement;
var tokenInput = cell ? cell.querySelector("[data-probe-token]") : null;
var out = cell ? cell.querySelector("[data-probe-out]") : null;
var template = button.getAttribute("data-probe-url") || "";
var kind = button.getAttribute("data-probe-kind");
var token = tokenInput ? tokenInput.value.trim() : "";
if (!out) return;
out.hidden = false;
out.className = "test-out";
if (!template) { out.textContent = "没有可用的地址"; return; }
if (!token) { out.className = "test-out bad"; out.textContent = "先填写访问令牌"; return; }
var url = template.replace("{z}", "0").replace("{x}", "0").replace("{y}", "0");
var started = Date.now();
out.textContent = "请求中…";
fetch(url, { headers: { Authorization: "Bearer " + token } })
.then(function (response) {
var ms = Date.now() - started;
out.className = "test-out " + (response.ok ? "ok" : "bad");
var label = kind === "vector" ? "下载" : "取图";
out.textContent = label + (response.ok ? "成功 " : "失败 ") + response.status + " · " + ms + "ms" +
(response.status === 401 ? " · 令牌无效或过期" : "") +
(response.status === 403 ? " · 令牌没有这个资源的权限" : "") +
(response.status === 404 ? " · 地址不存在或资源已下线" : "");
})
.catch(function (err) {
out.className = "test-out bad";
out.textContent = "请求失败:" + err.message;
});
}
Array.prototype.slice.call(document.querySelectorAll("[data-probe-url]")).forEach(function (button) {
button.addEventListener("click", function () { probe(button); });
});
function readMetric(text, name) {
var pattern = "^" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s+([0-9.eE+-]+)";
var match = text.match(new RegExp(pattern, "m"));
return match ? Number(match[1]) : null;
}
function fmtDuration(seconds) {
if (seconds === null || isNaN(seconds)) return "-";
var s = Math.floor(seconds);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
if (d > 0) return d + "d " + h + "h";
if (h > 0) return h + "h " + m + "m";
return m + "m";
}
function setMetric(id, value) {
var el = document.getElementById(id);
if (el && value !== null) el.textContent = value;
}
fetch("/metrics")
.then(function (response) { return response.text(); })
.then(function (text) {
var four = readMetric(text, "basemap_pusher_requests_by_status_total{class=\"4xx\"}");
var five = readMetric(text, "basemap_pusher_requests_by_status_total{class=\"5xx\"}");
var auth = readMetric(text, "basemap_pusher_auth_failures_total");
var tiles = readMetric(text, "basemap_pusher_tile_requests_total");
var miss = readMetric(text, "basemap_pusher_tile_misses_total");
var up = readMetric(text, "basemap_pusher_uptime_seconds");
setMetric("m-req", readMetric(text, "basemap_pusher_requests_total"));
setMetric("m-4xx", four);
setMetric("m-5xx", five);
setMetric("m-auth", auth);
setMetric("m-tiles", tiles);
setMetric("m-miss", miss);
setMetric("m-up", fmtDuration(up));
var fiveCard = document.getElementById("m-5xx");
if (fiveCard && five > 0) fiveCard.parentElement.classList.add("is-bad");
var missCard = document.getElementById("m-miss");
if (missCard && tiles > 0) {
var rate = miss / tiles;
if (rate > 0.25) missCard.parentElement.classList.add("is-bad");
else if (rate > 0.05) missCard.parentElement.classList.add("is-warn");
}
var chip = document.getElementById("metrics-chip");
var chipText = document.getElementById("metrics-chip-text");
if (chip && chipText) {
chipText.textContent = "瓦片 404 " + (miss === null ? "-" : miss) + " / " + (tiles === null ? "-" : tiles);
chip.classList.add(five > 0 ? "is-bad" : "is-ok");
}
})
.catch(function () {
var chipText = document.getElementById("metrics-chip-text");
if (chipText) chipText.textContent = "指标不可用";
});
})();
</script>
</body>
</html>`