fix: serve local vue assets
This commit is contained in:
parent
bfe41148d8
commit
f33e07d4f3
45
server.py
45
server.py
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
194
static/app.js
194
static/app.js
@ -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");
|
||||||
|
|||||||
@ -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
13
static/vue.js
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user