fix: serve local vue assets

This commit is contained in:
ZuoZuo 2026-04-22 00:31:57 +08:00
parent bfe41148d8
commit f33e07d4f3
4 changed files with 259 additions and 203 deletions

View File

@ -157,42 +157,61 @@ class MonitorHandler(BaseHTTPRequestHandler):
return return
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802
self._dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
self._dispatch(send_body=False)
def _dispatch(self, send_body: bool) -> None:
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path in {"/", "/index.html"}: if parsed.path in {"/", "/index.html"}:
return self.serve_static("index.html", "text/html; charset=utf-8") return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
if parsed.path == "/app.css": if parsed.path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8") return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
if parsed.path == "/app.js": if parsed.path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8") return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
if parsed.path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
if parsed.path == "/api/health": if parsed.path == "/api/health":
return self.send_payload(HTTPStatus.OK, response_json({"status": "ok", "time": utc_now()}), "application/json; charset=utf-8") return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now()}),
"application/json; charset=utf-8",
send_body=send_body,
)
if parsed.path == "/api/monitor/services": if parsed.path == "/api/monitor/services":
payload = get_monitor_payload() payload = get_monitor_payload()
return self.send_payload(HTTPStatus.OK, response_json(payload), "application/json; charset=utf-8") return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
if parsed.path == "/favicon.ico": if parsed.path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon") return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found") return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
def serve_static(self, name: str, content_type: str) -> None: def serve_static(self, name: str, content_type: str, send_body: bool) -> None:
path = STATIC_DIR / name path = STATIC_DIR / name
if not path.exists(): if not path.exists():
return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing") return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing", send_body=send_body)
self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type) self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type, send_body=send_body)
def send_error_payload(self, status: HTTPStatus, message: str) -> None: def send_error_payload(self, status: HTTPStatus, message: str, send_body: bool) -> None:
self.send_payload( self.send_payload(
status, status,
response_json({"status": int(status), "error": message}), response_json({"status": int(status), "error": message}),
"application/json; charset=utf-8", "application/json; charset=utf-8",
send_body=send_body,
) )
def send_payload(self, status: HTTPStatus, body: bytes, content_type: str) -> None: def send_payload(self, status: HTTPStatus, body: bytes, content_type: str, send_body: bool) -> None:
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
if body: if send_body and body:
self.wfile.write(body) self.wfile.write(body)

View File

@ -1,105 +1,113 @@
const { createApp } = Vue; const { createApp } = Vue;
createApp({ createApp({
data() { data() {
return { return {
loading: false, loading: false,
error: "", error: "",
filter: "all", filter: "all",
keyword: "", keyword: "",
payload: { payload: {
updatedAt: "", updatedAt: "",
summary: { total: 0, ok: 0, down: 0 }, summary: { total: 0, ok: 0, down: 0 },
items: [], items: [],
}, },
timer: null, timer: null,
}; };
},
computed: {
summary() {
return this.payload.summary || { total: 0, ok: 0, down: 0 };
}, },
formattedUpdatedAt() { computed: {
if (!this.payload.updatedAt) { summary() {
return "-"; return this.payload.summary || { total: 0, ok: 0, down: 0 };
} },
const date = new Date(this.payload.updatedAt); formattedUpdatedAt() {
return Number.isNaN(date.getTime()) ? this.payload.updatedAt : date.toLocaleString(); if (!this.payload.updatedAt) {
return "-";
}
const date = new Date(this.payload.updatedAt);
return Number.isNaN(date.getTime()) ? this.payload.updatedAt : date.toLocaleString();
},
filteredItems() {
const keyword = this.keyword.toLowerCase();
return (this.payload.items || []).filter((item) => {
if (this.filter === "down" && item.ok) {
return false;
}
if (this.filter === "java" && item.kind !== "java") {
return false;
}
if (this.filter === "golang" && item.kind !== "golang") {
return false;
}
if (!keyword) {
return true;
}
return [item.host, item.ip, item.service, item.kind, item.group]
.join(" ")
.toLowerCase()
.includes(keyword);
});
},
groupedHosts() {
const map = new Map();
for (const item of this.filteredItems) {
if (!map.has(item.host)) {
map.set(item.host, {
host: item.host,
ip: item.ip,
group: item.group,
groupLabel: this.groupLabel(item.group),
downCount: 0,
items: [],
});
}
const group = map.get(item.host);
group.items.push(item);
if (!item.ok) {
group.downCount += 1;
}
}
return [...map.values()];
},
}, },
filteredItems() { methods: {
const keyword = this.keyword.toLowerCase(); groupLabel(group) {
return (this.payload.items || []).filter((item) => { return {
if (this.filter === "down" && item.ok) { app: "应用节点",
return false; pay: "支付节点",
} gateway: "网关节点",
if (this.filter === "java" && item.kind !== "java") { }[group] || group;
return false; },
} async refresh() {
if (this.filter === "golang" && item.kind !== "golang") { this.loading = true;
return false; this.error = "";
} try {
if (!keyword) { const response = await fetch("/api/monitor/services", { cache: "no-store" });
return true; if (!response.ok) {
} throw new Error(`HTTP ${response.status}`);
return [item.host, item.ip, item.service, item.kind, item.group] }
.join(" ") this.payload = await response.json();
.toLowerCase() } catch (error) {
.includes(keyword); this.error = error instanceof Error ? error.message : String(error);
}); } finally {
this.loading = false;
}
},
}, },
groupedHosts() { mounted() {
const map = new Map(); const root = document.getElementById("app");
for (const item of this.filteredItems) { const bootIndicator = document.getElementById("boot-indicator");
if (!map.has(item.host)) { if (root) {
map.set(item.host, { root.removeAttribute("v-cloak");
host: item.host,
ip: item.ip,
group: item.group,
groupLabel: this.groupLabel(item.group),
downCount: 0,
items: [],
});
} }
const group = map.get(item.host); if (bootIndicator) {
group.items.push(item); bootIndicator.hidden = true;
if (!item.ok) {
group.downCount += 1;
} }
} this.refresh();
return [...map.values()]; this.timer = window.setInterval(() => this.refresh(), 10000);
}, },
}, beforeUnmount() {
methods: { if (this.timer) {
groupLabel(group) { window.clearInterval(this.timer);
return {
app: "应用节点",
pay: "支付节点",
gateway: "网关节点",
}[group] || group;
},
async refresh() {
this.loading = true;
this.error = "";
try {
const response = await fetch("/api/monitor/services", { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
} }
this.payload = await response.json();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
}, },
},
mounted() {
this.refresh();
this.timer = window.setInterval(() => this.refresh(), 10000);
},
beforeUnmount() {
if (this.timer) {
window.clearInterval(this.timer);
}
},
}).mount("#app"); }).mount("#app");

View File

@ -1,107 +1,123 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor</title> <title>HY App Monitor</title>
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="/app.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script defer src="/vue.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
</head> </head>
<body>
<div id="app" v-cloak class="shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">HY / Deploy Monitor</p>
<h1>内网服务实时巡检面板</h1>
<p class="hero-text">
页面本身不直接请求内网服务。所有探测都由 deploy 机器代发,适合直接通过公网 6666 访问。
</p>
</div>
<div class="hero-side">
<div class="stat-block">
<span>总服务数</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="stat-block ok">
<span>正常</span>
<strong>{{ summary.ok }}</strong>
</div>
<div class="stat-block bad">
<span>异常</span>
<strong>{{ summary.down }}</strong>
</div>
</div>
</header>
<section class="toolbar"> <body>
<div class="toolbar-left"> <div id="boot-indicator" class="boot-indicator">
<button :class="['filter-chip', filter === 'all' ? 'active' : '']" @click="filter = 'all'">全部</button> <strong>HY App Monitor</strong>
<button :class="['filter-chip', filter === 'down' ? 'active' : '']" @click="filter = 'down'">只看异常</button> <span>正在加载页面资源,请稍候。</span>
<button :class="['filter-chip', filter === 'java' ? 'active' : '']" @click="filter = 'java'">Java</button>
<button :class="['filter-chip', filter === 'golang' ? 'active' : '']" @click="filter = 'golang'">Golang</button>
</div>
<div class="toolbar-right">
<input v-model.trim="keyword" class="search" placeholder="搜索主机 / 服务 / IP" />
<button class="refresh" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '立即刷新' }}</button>
</div>
</section>
<section class="meta-strip">
<span>最后刷新:{{ formattedUpdatedAt }}</span>
<span v-if="error" class="bad-text">加载失败:{{ error }}</span>
<span v-else>自动刷新10 秒</span>
</section>
<section class="hosts">
<article v-for="group in groupedHosts" :key="group.host" class="host-panel">
<header class="host-header">
<div>
<h2>{{ group.host }}</h2>
<p>{{ group.ip }} · {{ group.groupLabel }}</p>
</div>
<div class="host-badges">
<span class="badge neutral">{{ group.items.length }} 项</span>
<span :class="['badge', group.downCount > 0 ? 'bad' : 'ok']">
{{ group.downCount > 0 ? `${group.downCount} 异常` : '全部正常' }}
</span>
</div>
</header>
<div class="service-grid">
<div v-for="item in group.items" :key="item.host + '-' + item.service" class="service-card">
<div class="service-top">
<div>
<div class="service-name">{{ item.service }}</div>
<div class="service-kind">{{ item.kind }}</div>
</div>
<div :class="['status-pill', item.ok ? 'ok' : 'bad']">
<span class="dot"></span>
{{ item.ok ? 'UP' : 'DOWN' }}
</div>
</div>
<div class="service-data">
<div>
<small>HTTP</small>
<strong>{{ item.statusCode || '-' }}</strong>
</div>
<div>
<small>耗时</small>
<strong>{{ item.latencyMs }}ms</strong>
</div>
<div>
<small>端口</small>
<strong>{{ item.port }}</strong>
</div>
</div>
<div class="service-url">{{ item.url }}</div>
<div class="service-detail">{{ item.detail || '无返回内容' }}</div>
</div>
</div>
</article>
</section>
</div> </div>
</body> <noscript>
<div class="boot-indicator noscript-indicator">
<strong>浏览器已禁用 JavaScript</strong>
<span>当前页面依赖脚本渲染,请启用 JavaScript 后再访问。</span>
</div>
</noscript>
<div id="app" v-cloak class="shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">HY / Deploy Monitor</p>
<h1>内网服务实时巡检面板</h1>
<p class="hero-text">
页面本身不直接请求内网服务。所有探测都由 deploy 机器代发,适合直接通过公网 6666 访问。
</p>
</div>
<div class="hero-side">
<div class="stat-block">
<span>总服务数</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="stat-block ok">
<span>正常</span>
<strong>{{ summary.ok }}</strong>
</div>
<div class="stat-block bad">
<span>异常</span>
<strong>{{ summary.down }}</strong>
</div>
</div>
</header>
<section class="toolbar">
<div class="toolbar-left">
<button :class="['filter-chip', filter === 'all' ? 'active' : '']" @click="filter = 'all'">全部</button>
<button :class="['filter-chip', filter === 'down' ? 'active' : '']"
@click="filter = 'down'">只看异常</button>
<button :class="['filter-chip', filter === 'java' ? 'active' : '']"
@click="filter = 'java'">Java</button>
<button :class="['filter-chip', filter === 'golang' ? 'active' : '']"
@click="filter = 'golang'">Golang</button>
</div>
<div class="toolbar-right">
<input v-model.trim="keyword" class="search" placeholder="搜索主机 / 服务 / IP" />
<button class="refresh" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '立即刷新' }}</button>
</div>
</section>
<section class="meta-strip">
<span>最后刷新:{{ formattedUpdatedAt }}</span>
<span v-if="error" class="bad-text">加载失败:{{ error }}</span>
<span v-else>自动刷新10 秒</span>
</section>
<section class="hosts">
<article v-for="group in groupedHosts" :key="group.host" class="host-panel">
<header class="host-header">
<div>
<h2>{{ group.host }}</h2>
<p>{{ group.ip }} · {{ group.groupLabel }}</p>
</div>
<div class="host-badges">
<span class="badge neutral">{{ group.items.length }} 项</span>
<span :class="['badge', group.downCount > 0 ? 'bad' : 'ok']">
{{ group.downCount > 0 ? `${group.downCount} 异常` : '全部正常' }}
</span>
</div>
</header>
<div class="service-grid">
<div v-for="item in group.items" :key="item.host + '-' + item.service" class="service-card">
<div class="service-top">
<div>
<div class="service-name">{{ item.service }}</div>
<div class="service-kind">{{ item.kind }}</div>
</div>
<div :class="['status-pill', item.ok ? 'ok' : 'bad']">
<span class="dot"></span>
{{ item.ok ? 'UP' : 'DOWN' }}
</div>
</div>
<div class="service-data">
<div>
<small>HTTP</small>
<strong>{{ item.statusCode || '-' }}</strong>
</div>
<div>
<small>耗时</small>
<strong>{{ item.latencyMs }}ms</strong>
</div>
<div>
<small>端口</small>
<strong>{{ item.port }}</strong>
</div>
</div>
<div class="service-url">{{ item.url }}</div>
<div class="service-detail">{{ item.detail || '无返回内容' }}</div>
</div>
</div>
</article>
</section>
</div>
</body>
</html> </html>

13
static/vue.js Normal file

File diff suppressed because one or more lines are too long