This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
.codex
|
.codex
|
||||||
.omc
|
.omc
|
||||||
.vscode
|
.vscode
|
||||||
|
node_modules
|
||||||
|
cmd/gateway/admin_static/js
|
||||||
|
|
||||||
logs
|
logs
|
||||||
data
|
data
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
|||||||
.vscode
|
.vscode
|
||||||
|
node_modules/
|
||||||
|
cmd/gateway/admin_static/js/
|
||||||
|
|
||||||
config.toml
|
config.toml
|
||||||
/gateway*
|
/gateway*
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,5 +1,17 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY package.json package-lock.json tsconfig.admin.json ./
|
||||||
|
RUN --mount=type=cache,id=mc-gateway-npm,target=/root/.npm,sharing=locked \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
COPY cmd/gateway/admin_frontend ./cmd/gateway/admin_frontend
|
||||||
|
COPY cmd/gateway/admin_static/index.html cmd/gateway/admin_static/app.css ./cmd/gateway/admin_static/
|
||||||
|
RUN npm run build:admin
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build
|
FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
@@ -12,6 +24,7 @@ RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
|||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \
|
||||||
mkdir -p /out \
|
mkdir -p /out \
|
||||||
@@ -22,8 +35,10 @@ FROM alpine:3.22
|
|||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway
|
COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway
|
||||||
|
COPY --from=admin-frontend /src/cmd/gateway/admin_static /usr/share/mc-gateway/admin_static
|
||||||
|
|
||||||
ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
||||||
|
ENV MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
|
||||||
|
|
||||||
EXPOSE 25565/tcp
|
EXPOSE 25565/tcp
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -19,6 +19,7 @@ mc-gateway 默认不依赖配置文件。直接启动后会在 `25565` 端口同
|
|||||||
| `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 |
|
| `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 |
|
||||||
| `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
|
| `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
|
||||||
| `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 |
|
| `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 |
|
||||||
|
| `MC_GATEWAY_ADMIN_STATIC_DIR` | `cmd/gateway/admin_static` | Admin 前端静态文件目录;Docker 镜像中为 `/usr/share/mc-gateway/admin_static` |
|
||||||
| `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 |
|
| `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 |
|
||||||
| `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
|
| `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
|
||||||
|
|
||||||
@@ -56,9 +57,21 @@ MC_GATEWAY_ADMIN_PASSWORD=change-me
|
|||||||
MC_GATEWAY_TCP_ADMIN_PORT=25565
|
MC_GATEWAY_TCP_ADMIN_PORT=25565
|
||||||
MC_GATEWAY_ADMIN_PATH=/admin/
|
MC_GATEWAY_ADMIN_PATH=/admin/
|
||||||
MC_GATEWAY_ADMIN_API_PREFIX=/admin/api
|
MC_GATEWAY_ADMIN_API_PREFIX=/admin/api
|
||||||
|
MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
|
||||||
MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Admin 前端开发
|
||||||
|
|
||||||
|
Admin 前端源码位于 `cmd/gateway/admin_frontend/src`,使用 TypeScript 拆分为原生 ES modules。构建产物输出到 `cmd/gateway/admin_static/js`,Go 服务不会 embed 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录透传静态响应。
|
||||||
|
|
||||||
|
修改前端后运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run build:admin
|
||||||
|
```
|
||||||
|
|
||||||
`master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry:
|
`master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
|
|||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||||
t.Fatalf("custom admin page does not contain API prefix: %s", resp.Body.String())
|
t.Fatalf("custom admin page does not reference runtime config: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/config.js", "", nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("custom admin config status = %d, body=%s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
|
||||||
|
t.Fatalf("custom admin config body = %q", resp.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)
|
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)
|
||||||
|
|||||||
8
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
8
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { el } from "./dom.js";
|
||||||
|
import { localizeMessage } from "./i18n.js";
|
||||||
|
|
||||||
|
export function showAlert(message: unknown): void {
|
||||||
|
const box = el("alert");
|
||||||
|
box.textContent = localizeMessage(message);
|
||||||
|
box.classList.toggle("hidden", !message);
|
||||||
|
}
|
||||||
46
cmd/gateway/admin_frontend/src/api.ts
Normal file
46
cmd/gateway/admin_frontend/src/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
interface APIOptions {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
if (state.token) {
|
||||||
|
headers.Authorization = `Bearer ${state.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(state.apiBase + path, {
|
||||||
|
method: options.method || "GET",
|
||||||
|
headers,
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: unknown = {};
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = { error: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(apiErrorMessage(data, res.statusText));
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(data: unknown, fallback: string): string {
|
||||||
|
if (data && typeof data === "object" && "error" in data) {
|
||||||
|
const error = (data as { error?: unknown }).error;
|
||||||
|
if (typeof error === "string" && error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
13
cmd/gateway/admin_frontend/src/config.ts
Normal file
13
cmd/gateway/admin_frontend/src/config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RuntimeConfig } from "./types.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
MCGatewayAdmin?: Partial<RuntimeConfig>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeConfig(): RuntimeConfig {
|
||||||
|
return {
|
||||||
|
apiPrefix: window.MCGatewayAdmin?.apiPrefix || "/admin/api",
|
||||||
|
};
|
||||||
|
}
|
||||||
53
cmd/gateway/admin_frontend/src/dom.ts
Normal file
53
cmd/gateway/admin_frontend/src/dom.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`missing element #${id}`);
|
||||||
|
}
|
||||||
|
return node as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHTML(value: unknown): string {
|
||||||
|
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
"'": "'",
|
||||||
|
}[ch] || ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeAttr(value: unknown): string {
|
||||||
|
return escapeHTML(value).replace(/`/g, "`");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stat(label: string, value: unknown): string {
|
||||||
|
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badge(text: string, off = false): string {
|
||||||
|
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => void>(fn: T, wait: number): T {
|
||||||
|
let id = 0;
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
clearTimeout(id);
|
||||||
|
id = window.setTimeout(() => fn(...args), wait);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormInput(form: HTMLFormElement, name: string): HTMLInputElement {
|
||||||
|
const field = form.elements.namedItem(name);
|
||||||
|
if (!(field instanceof HTMLInputElement)) {
|
||||||
|
throw new Error(`missing input ${name}`);
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormSelect(form: HTMLFormElement, name: string): HTMLSelectElement {
|
||||||
|
const field = form.elements.namedItem(name);
|
||||||
|
if (!(field instanceof HTMLSelectElement)) {
|
||||||
|
throw new Error(`missing select ${name}`);
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
293
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
293
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { el } from "./dom.js";
|
||||||
|
import { languageStorageKey, state } from "./state.js";
|
||||||
|
|
||||||
|
const defaultLanguage = "en";
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en: {
|
||||||
|
activeConnections: "Active",
|
||||||
|
activeUser: "Active",
|
||||||
|
actions: "Actions",
|
||||||
|
actor: "Actor",
|
||||||
|
adminSubtitle: "Admin",
|
||||||
|
audit: "Audit",
|
||||||
|
cancel: "Cancel",
|
||||||
|
createAdmin: "Create admin",
|
||||||
|
dataShards: "Data shards",
|
||||||
|
delete: "Delete",
|
||||||
|
deleteDefaultRouteConfirm: "Delete default route?",
|
||||||
|
deleteUserConfirm: "Delete user {username}?",
|
||||||
|
dialErrors: "Dial errors",
|
||||||
|
disabled: "Disabled",
|
||||||
|
edit: "Edit",
|
||||||
|
enabled: "Enabled",
|
||||||
|
failed: "Failed",
|
||||||
|
host: "Host",
|
||||||
|
initialAdmin: "Initial admin",
|
||||||
|
language: "Language",
|
||||||
|
languageChinese: "中文",
|
||||||
|
languageEnglish: "English",
|
||||||
|
login: "Login",
|
||||||
|
logout: "Logout",
|
||||||
|
message: "Message",
|
||||||
|
metrics: "Metrics",
|
||||||
|
misses: "Misses",
|
||||||
|
newRoute: "New route",
|
||||||
|
newUser: "New user",
|
||||||
|
noHits: "No hits",
|
||||||
|
note: "Note",
|
||||||
|
parityShards: "Parity shards",
|
||||||
|
password: "Password",
|
||||||
|
path: "Path",
|
||||||
|
port: "Port",
|
||||||
|
protocols: "Protocols",
|
||||||
|
restart: "Restart",
|
||||||
|
restartRequired: "restart required",
|
||||||
|
result: "Result",
|
||||||
|
role: "Role",
|
||||||
|
roleAdmin: "Admin",
|
||||||
|
roleGuest: "Guest",
|
||||||
|
roleMember: "Member",
|
||||||
|
route: "Route",
|
||||||
|
routeHits: "Route hits",
|
||||||
|
routeSearch: "Search host, upstream, note",
|
||||||
|
routes: "Routes",
|
||||||
|
save: "Save",
|
||||||
|
services: "Services",
|
||||||
|
setupSubtitle: "Setup",
|
||||||
|
success: "Success",
|
||||||
|
target: "Target",
|
||||||
|
time: "Time",
|
||||||
|
total: "Total",
|
||||||
|
upstream: "Upstream",
|
||||||
|
uptime: "Uptime",
|
||||||
|
user: "User",
|
||||||
|
username: "Username",
|
||||||
|
users: "Users",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
activeConnections: "活跃连接",
|
||||||
|
activeUser: "启用",
|
||||||
|
actions: "操作",
|
||||||
|
actor: "操作者",
|
||||||
|
adminSubtitle: "管理后台",
|
||||||
|
audit: "审计",
|
||||||
|
cancel: "取消",
|
||||||
|
createAdmin: "创建管理员",
|
||||||
|
dataShards: "数据分片",
|
||||||
|
delete: "删除",
|
||||||
|
deleteDefaultRouteConfirm: "确认删除默认路由?",
|
||||||
|
deleteUserConfirm: "确认删除用户 {username}?",
|
||||||
|
dialErrors: "连接上游失败",
|
||||||
|
disabled: "禁用",
|
||||||
|
edit: "编辑",
|
||||||
|
enabled: "启用",
|
||||||
|
failed: "失败",
|
||||||
|
host: "主机",
|
||||||
|
initialAdmin: "初始化管理员",
|
||||||
|
language: "语言",
|
||||||
|
languageChinese: "中文",
|
||||||
|
languageEnglish: "English",
|
||||||
|
login: "登录",
|
||||||
|
logout: "退出登录",
|
||||||
|
message: "消息",
|
||||||
|
metrics: "指标",
|
||||||
|
misses: "未命中",
|
||||||
|
newRoute: "新建路由",
|
||||||
|
newUser: "新建用户",
|
||||||
|
noHits: "暂无命中",
|
||||||
|
note: "备注",
|
||||||
|
parityShards: "校验分片",
|
||||||
|
password: "密码",
|
||||||
|
path: "路径",
|
||||||
|
port: "端口",
|
||||||
|
protocols: "协议",
|
||||||
|
restart: "重启",
|
||||||
|
restartRequired: "需要重启",
|
||||||
|
result: "结果",
|
||||||
|
role: "角色",
|
||||||
|
roleAdmin: "管理员",
|
||||||
|
roleGuest: "访客",
|
||||||
|
roleMember: "成员",
|
||||||
|
route: "路由",
|
||||||
|
routeHits: "路由命中",
|
||||||
|
routeSearch: "搜索主机、上游、备注",
|
||||||
|
routes: "路由",
|
||||||
|
save: "保存",
|
||||||
|
services: "服务",
|
||||||
|
setupSubtitle: "初始化",
|
||||||
|
success: "成功",
|
||||||
|
target: "目标",
|
||||||
|
time: "时间",
|
||||||
|
total: "总数",
|
||||||
|
upstream: "上游",
|
||||||
|
uptime: "运行时间",
|
||||||
|
user: "用户",
|
||||||
|
username: "用户名",
|
||||||
|
users: "用户",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TranslationKey = keyof typeof translations.en;
|
||||||
|
type Language = keyof typeof translations;
|
||||||
|
|
||||||
|
const localizedMessages: Partial<Record<Language, Record<string, string>>> = {
|
||||||
|
zh: {
|
||||||
|
"admin API prefix cannot be under static asset path": "管理 API 前缀不能位于静态资源路径下",
|
||||||
|
"admin API prefix cannot equal admin page path": "管理 API 前缀不能等于管理页面路径",
|
||||||
|
"cannot delete the last enabled admin": "不能删除最后一个启用的管理员",
|
||||||
|
"cannot remove the last enabled admin": "不能移除最后一个启用的管理员",
|
||||||
|
"created initial admin": "已创建初始管理员",
|
||||||
|
"host is required": "主机不能为空",
|
||||||
|
"host must not contain /": "主机不能包含 /",
|
||||||
|
"host must not contain whitespace": "主机不能包含空白字符",
|
||||||
|
"initial admin has already been created": "初始管理员已创建",
|
||||||
|
"invalid JSON": "JSON 无效",
|
||||||
|
"invalid path segment": "路径片段无效",
|
||||||
|
"invalid username or password": "用户名或密码无效",
|
||||||
|
"login required": "需要登录",
|
||||||
|
"login success": "登录成功",
|
||||||
|
"logout success": "退出登录成功",
|
||||||
|
"method not allowed": "方法不允许",
|
||||||
|
"not found": "未找到",
|
||||||
|
"password hash is required": "密码哈希不能为空",
|
||||||
|
"password is required": "密码不能为空",
|
||||||
|
"permission denied": "没有权限",
|
||||||
|
"port must be an integer from 1 to 65535": "端口必须是 1 到 65535 之间的整数",
|
||||||
|
"restart is not implemented": "暂未实现重启",
|
||||||
|
"route deleted": "路由已删除",
|
||||||
|
"route saved": "路由已保存",
|
||||||
|
"service restart is not implemented; restart the gateway process": "暂未实现服务重启;请重启网关进程",
|
||||||
|
"service saved": "服务已保存",
|
||||||
|
"tcp_admin cannot be disabled": "tcp_admin 不能被禁用",
|
||||||
|
"upstream host is required": "上游主机不能为空",
|
||||||
|
"upstream is required": "上游不能为空",
|
||||||
|
"upstream must be host:port": "上游必须是 host:port 格式",
|
||||||
|
"upstream port must be an integer from 1 to 65535": "上游端口必须是 1 到 65535 之间的整数",
|
||||||
|
"user created": "用户已创建",
|
||||||
|
"user deleted": "用户已删除",
|
||||||
|
"user is disabled": "用户已禁用",
|
||||||
|
"user not found": "用户不存在",
|
||||||
|
"user updated": "用户已更新",
|
||||||
|
"username is required": "用户名不能为空",
|
||||||
|
"username must not contain whitespace or /": "用户名不能包含空白字符或 /",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizedPrefixes: Partial<Record<Language, Array<[string, string]>>> = {
|
||||||
|
zh: [
|
||||||
|
["invalid JSON: ", "JSON 无效:"],
|
||||||
|
["invalid role ", "无效角色 "],
|
||||||
|
["unknown service ", "未知服务 "],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auditActions: Partial<Record<Language, Record<string, string>>> = {
|
||||||
|
zh: {
|
||||||
|
login: "登录",
|
||||||
|
logout: "退出登录",
|
||||||
|
route_delete: "删除路由",
|
||||||
|
route_upsert: "保存路由",
|
||||||
|
service_restart: "重启服务",
|
||||||
|
service_update: "更新服务",
|
||||||
|
setup: "初始化",
|
||||||
|
user_create: "创建用户",
|
||||||
|
user_delete: "删除用户",
|
||||||
|
user_patch: "更新用户",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const targetTypes: Partial<Record<Language, Record<string, string>>> = {
|
||||||
|
zh: {
|
||||||
|
route: "路由",
|
||||||
|
service: "服务",
|
||||||
|
session: "会话",
|
||||||
|
user: "用户",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initializeLanguage(): void {
|
||||||
|
state.language = resolveInitialLanguage();
|
||||||
|
applyLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeLanguage(next: string, rerender: () => void): void {
|
||||||
|
if (!isLanguage(next) || next === state.language) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.language = next;
|
||||||
|
localStorage.setItem(languageStorageKey, next);
|
||||||
|
applyLanguage();
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLanguage(): void {
|
||||||
|
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
|
||||||
|
el<HTMLSelectElement>("languageSelect").value = state.language;
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((node) => {
|
||||||
|
node.textContent = t(node.dataset.i18n as TranslationKey);
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLInputElement>("[data-i18n-placeholder]").forEach((node) => {
|
||||||
|
node.placeholder = t(node.dataset.i18nPlaceholder as TranslationKey);
|
||||||
|
});
|
||||||
|
if (!el("setupView").classList.contains("hidden")) {
|
||||||
|
setSubtitle("setupSubtitle");
|
||||||
|
} else if (!el("loginView").classList.contains("hidden")) {
|
||||||
|
setSubtitle("login");
|
||||||
|
} else {
|
||||||
|
setSubtitle("adminSubtitle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSubtitle(key: TranslationKey): void {
|
||||||
|
el("subtitle").textContent = t(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: TranslationKey, values: Record<string, string> = {}): string {
|
||||||
|
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
|
||||||
|
let text: string = translations[lang][key] || translations[defaultLanguage][key] || key;
|
||||||
|
for (const [name, value] of Object.entries(values)) {
|
||||||
|
text = text.replaceAll(`{${name}}`, value);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeMessage(message: unknown): string {
|
||||||
|
if (!message) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
|
||||||
|
const text = String(message);
|
||||||
|
const messages = localizedMessages[lang] || {};
|
||||||
|
if (messages[text]) {
|
||||||
|
return messages[text];
|
||||||
|
}
|
||||||
|
const prefixes = localizedPrefixes[lang] || [];
|
||||||
|
for (const [source, replacement] of prefixes) {
|
||||||
|
if (text.startsWith(source)) {
|
||||||
|
return replacement + text.slice(source.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditAction(action: string): string {
|
||||||
|
return auditActions[state.language as Language]?.[action] || action;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTargetType(targetType: string): string {
|
||||||
|
return targetTypes[state.language as Language]?.[targetType] || targetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialLanguage(): Language {
|
||||||
|
const stored = localStorage.getItem(languageStorageKey);
|
||||||
|
if (isLanguage(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
const browserLanguage = (navigator.language || "").toLowerCase();
|
||||||
|
return browserLanguage.startsWith("zh") ? "zh" : defaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLanguage(value: unknown): value is Language {
|
||||||
|
return typeof value === "string" && value in translations;
|
||||||
|
}
|
||||||
188
cmd/gateway/admin_frontend/src/main.ts
Normal file
188
cmd/gateway/admin_frontend/src/main.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { api } from "./api.js";
|
||||||
|
import { showAlert } from "./alerts.js";
|
||||||
|
import { runtimeConfig } from "./config.js";
|
||||||
|
import { debounce, el } from "./dom.js";
|
||||||
|
import { changeLanguage, initializeLanguage, setSubtitle } from "./i18n.js";
|
||||||
|
import { isAdmin, isMember, renderSessionUser } from "./session.js";
|
||||||
|
import { setToken, state } from "./state.js";
|
||||||
|
import type { LoginResponse, SetupStatus, User } from "./types.js";
|
||||||
|
import { loadAudit } from "./views/audit.js";
|
||||||
|
import { loadMetrics } from "./views/metrics.js";
|
||||||
|
import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js";
|
||||||
|
import { loadServices, renderServices } from "./views/services.js";
|
||||||
|
import { loadStatus } from "./views/status.js";
|
||||||
|
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
state.apiBase = runtimeConfig().apiPrefix;
|
||||||
|
initializeLanguage();
|
||||||
|
bindEvents();
|
||||||
|
try {
|
||||||
|
const setup = await api<SetupStatus>("/setup");
|
||||||
|
if (setup.required) {
|
||||||
|
setView("setupView");
|
||||||
|
setSubtitle("setupSubtitle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.token) {
|
||||||
|
setView("loginView");
|
||||||
|
setSubtitle("login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.user = await api<User>("/me");
|
||||||
|
await showApp();
|
||||||
|
} catch {
|
||||||
|
setToken("");
|
||||||
|
setView("loginView");
|
||||||
|
setSubtitle("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents(): void {
|
||||||
|
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
|
||||||
|
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
|
||||||
|
});
|
||||||
|
el<HTMLFormElement>("setupForm").addEventListener("submit", submitSetup);
|
||||||
|
el<HTMLFormElement>("loginForm").addEventListener("submit", submitLogin);
|
||||||
|
el("logoutBtn").addEventListener("click", logout);
|
||||||
|
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
|
||||||
|
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
|
||||||
|
el("newUserBtn").addEventListener("click", () => openUserDialog());
|
||||||
|
el<HTMLFormElement>("routeForm").addEventListener("submit", saveRoute);
|
||||||
|
el<HTMLFormElement>("userForm").addEventListener("submit", saveUser);
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => button.closest("dialog")?.close());
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (button.dataset.tab) {
|
||||||
|
selectTab(button.dataset.tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSetup(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||||
|
try {
|
||||||
|
await api("/setup", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
username: form.get("username"),
|
||||||
|
password: form.get("password"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
showAlert("");
|
||||||
|
setView("loginView");
|
||||||
|
setSubtitle("login");
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLogin(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||||
|
try {
|
||||||
|
const data = await api<LoginResponse>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
username: form.get("username"),
|
||||||
|
password: form.get("password"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setToken(data.token);
|
||||||
|
state.user = data.user;
|
||||||
|
showAlert("");
|
||||||
|
await showApp();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api("/auth/logout", { method: "POST", body: {} });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setToken("");
|
||||||
|
state.user = null;
|
||||||
|
setView("loginView");
|
||||||
|
setSubtitle("login");
|
||||||
|
renderSessionUser();
|
||||||
|
el("logoutBtn").classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showApp(): Promise<void> {
|
||||||
|
setView("appView");
|
||||||
|
setSubtitle("adminSubtitle");
|
||||||
|
renderSessionUser();
|
||||||
|
el("logoutBtn").classList.remove("hidden");
|
||||||
|
applyRoleVisibility();
|
||||||
|
await loadRoutes();
|
||||||
|
if (isMember()) {
|
||||||
|
await loadStatus();
|
||||||
|
await loadServices();
|
||||||
|
await loadMetrics();
|
||||||
|
}
|
||||||
|
if (isAdmin()) {
|
||||||
|
await loadUsers();
|
||||||
|
await loadAudit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoleVisibility(): void {
|
||||||
|
const member = isMember();
|
||||||
|
const admin = isAdmin();
|
||||||
|
el("statusGrid").classList.toggle("hidden", !member);
|
||||||
|
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||||
|
toggleTab("services", member);
|
||||||
|
toggleTab("metrics", member);
|
||||||
|
toggleTab("users", admin);
|
||||||
|
toggleTab("audit", admin);
|
||||||
|
selectTab("routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTab(name: string, visible: boolean): void {
|
||||||
|
document.querySelector(`[data-tab="${name}"]`)?.classList.toggle("hidden", !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(name: string): void {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.tab === name);
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((panel) => {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
});
|
||||||
|
el(`${name}Tab`).classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(name: string): void {
|
||||||
|
for (const id of ["setupView", "loginView", "appView"]) {
|
||||||
|
el(id).classList.toggle("hidden", id !== name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerenderCurrentView(): void {
|
||||||
|
renderSessionUser();
|
||||||
|
renderRoutes();
|
||||||
|
renderServices();
|
||||||
|
renderUsers();
|
||||||
|
if (isMember()) {
|
||||||
|
loadStatus();
|
||||||
|
loadMetrics();
|
||||||
|
}
|
||||||
|
if (isAdmin()) {
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
26
cmd/gateway/admin_frontend/src/session.ts
Normal file
26
cmd/gateway/admin_frontend/src/session.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { el } from "./dom.js";
|
||||||
|
import { t } from "./i18n.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import type { Role } from "./types.js";
|
||||||
|
|
||||||
|
export function isAdmin(): boolean {
|
||||||
|
return state.user?.role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMember(): boolean {
|
||||||
|
return state.user?.role === "admin" || state.user?.role === "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSessionUser(): void {
|
||||||
|
el("sessionUser").textContent = state.user ? `${state.user.username} (${formatRole(state.user.role)})` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRole(role: Role | string): string {
|
||||||
|
const keys: Partial<Record<string, "roleAdmin" | "roleMember" | "roleGuest">> = {
|
||||||
|
admin: "roleAdmin",
|
||||||
|
member: "roleMember",
|
||||||
|
guest: "roleGuest",
|
||||||
|
};
|
||||||
|
const key = keys[role];
|
||||||
|
return key ? t(key) : role;
|
||||||
|
}
|
||||||
33
cmd/gateway/admin_frontend/src/state.ts
Normal file
33
cmd/gateway/admin_frontend/src/state.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { RouteRecord, ServiceRecord, User } from "./types.js";
|
||||||
|
|
||||||
|
export const tokenStorageKey = "mcGatewayAdminToken";
|
||||||
|
export const languageStorageKey = "mcGatewayAdminLanguage";
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
apiBase: string;
|
||||||
|
language: string;
|
||||||
|
token: string;
|
||||||
|
user: User | null;
|
||||||
|
routes: RouteRecord[];
|
||||||
|
services: ServiceRecord[];
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const state: AppState = {
|
||||||
|
apiBase: "/admin/api",
|
||||||
|
language: "",
|
||||||
|
token: sessionStorage.getItem(tokenStorageKey) || "",
|
||||||
|
user: null,
|
||||||
|
routes: [],
|
||||||
|
services: [],
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setToken(token: string): void {
|
||||||
|
state.token = token;
|
||||||
|
if (token) {
|
||||||
|
sessionStorage.setItem(tokenStorageKey, token);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(tokenStorageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
Normal file
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export type Role = "admin" | "member" | "guest";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRecord {
|
||||||
|
host: string;
|
||||||
|
upstream: string;
|
||||||
|
enabled: boolean;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceRecord {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
restart_required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Metrics {
|
||||||
|
total_connections?: number;
|
||||||
|
active_connections?: number;
|
||||||
|
tcp_connections?: number;
|
||||||
|
websocket_connections?: number;
|
||||||
|
route_misses?: number;
|
||||||
|
upstream_dial_errors?: number;
|
||||||
|
route_hits?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
created_at: number;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
target_type: string;
|
||||||
|
target_id: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
apiPrefix: string;
|
||||||
|
}
|
||||||
27
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
27
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { badge, el, escapeHTML } from "../dom.js";
|
||||||
|
import { formatAuditAction, formatTargetType, localizeMessage, t } from "../i18n.js";
|
||||||
|
import type { AuditLog } from "../types.js";
|
||||||
|
|
||||||
|
interface AuditResponse {
|
||||||
|
audit_logs?: AuditLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAudit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api<AuditResponse>("/audit-logs");
|
||||||
|
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
|
||||||
|
<td>${escapeHTML(item.actor)}</td>
|
||||||
|
<td>${escapeHTML(formatAuditAction(item.action))}</td>
|
||||||
|
<td>${escapeHTML(formatTargetType(item.target_type))}:${escapeHTML(item.target_id)}</td>
|
||||||
|
<td>${badge(item.success ? t("success") : t("failed"), !item.success)}</td>
|
||||||
|
<td>${escapeHTML(localizeMessage(item.message || ""))}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
25
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { el, escapeHTML, stat } from "../dom.js";
|
||||||
|
import { t } from "../i18n.js";
|
||||||
|
import type { Metrics } from "../types.js";
|
||||||
|
|
||||||
|
export async function loadMetrics(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api<Metrics>("/metrics");
|
||||||
|
el("metricsGrid").innerHTML = [
|
||||||
|
stat(t("total"), data.total_connections),
|
||||||
|
stat(t("activeConnections"), data.active_connections),
|
||||||
|
stat("TCP", data.tcp_connections),
|
||||||
|
stat("WebSocket", data.websocket_connections),
|
||||||
|
stat(t("misses"), data.route_misses),
|
||||||
|
stat(t("dialErrors"), data.upstream_dial_errors),
|
||||||
|
].join("");
|
||||||
|
const hits = data.route_hits || {};
|
||||||
|
el("routeHits").innerHTML = Object.keys(hits).length
|
||||||
|
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
|
||||||
|
: `<span class="chip">${escapeHTML(t("noHits"))}</span>`;
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
108
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||||
|
import { t } from "../i18n.js";
|
||||||
|
import { isMember } from "../session.js";
|
||||||
|
import { state } from "../state.js";
|
||||||
|
import type { RouteRecord } from "../types.js";
|
||||||
|
|
||||||
|
interface RoutesResponse {
|
||||||
|
routes?: RouteRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRoutes(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const q = encodeURIComponent(el<HTMLInputElement>("routeSearch").value || "");
|
||||||
|
const data = await api<RoutesResponse>(q ? `/routes?q=${q}` : "/routes");
|
||||||
|
state.routes = data.routes || [];
|
||||||
|
renderRoutes();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRoutes(): void {
|
||||||
|
const canWrite = isMember();
|
||||||
|
el("routesBody").innerHTML = state.routes.map((route) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHTML(route.host)}</td>
|
||||||
|
<td>${escapeHTML(route.upstream)}</td>
|
||||||
|
<td>${badge(route.enabled ? t("enabled") : t("disabled"), !route.enabled)}</td>
|
||||||
|
<td>${escapeHTML(route.note || "")}</td>
|
||||||
|
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-edit-route]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
|
||||||
|
openRouteDialog(route || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-delete-route]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const host = button.dataset.deleteRoute;
|
||||||
|
if (host) {
|
||||||
|
removeRoute(host);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRouteDialog(route: RouteRecord | null = null): void {
|
||||||
|
const form = el<HTMLFormElement>("routeForm");
|
||||||
|
form.reset();
|
||||||
|
form.dataset.originalHost = route ? route.host : "";
|
||||||
|
getFormInput(form, "host").disabled = Boolean(route);
|
||||||
|
if (route) {
|
||||||
|
getFormInput(form, "host").value = route.host;
|
||||||
|
getFormInput(form, "upstream").value = route.upstream;
|
||||||
|
getFormInput(form, "enabled").checked = route.enabled;
|
||||||
|
getFormInput(form, "note").value = route.note || "";
|
||||||
|
} else {
|
||||||
|
getFormInput(form, "enabled").checked = true;
|
||||||
|
}
|
||||||
|
el<HTMLDialogElement>("routeDialog").showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRoute(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget as HTMLFormElement;
|
||||||
|
const host = form.dataset.originalHost || getFormInput(form, "host").value;
|
||||||
|
try {
|
||||||
|
await api(`/routes/${encodeURIComponent(host)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
upstream: getFormInput(form, "upstream").value,
|
||||||
|
enabled: getFormInput(form, "enabled").checked,
|
||||||
|
note: getFormInput(form, "note").value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
el<HTMLDialogElement>("routeDialog").close();
|
||||||
|
await loadRoutes();
|
||||||
|
showAlert("");
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRoute(host: string): Promise<void> {
|
||||||
|
if (host === "default" && !confirm(t("deleteDefaultRouteConfirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
|
||||||
|
await loadRoutes();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeActions(route: RouteRecord): string {
|
||||||
|
return `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">${escapeHTML(t("edit"))}</button>
|
||||||
|
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">${escapeHTML(t("delete"))}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
161
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
161
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||||
|
import { t } from "../i18n.js";
|
||||||
|
import { isAdmin } from "../session.js";
|
||||||
|
import { state } from "../state.js";
|
||||||
|
import type { ServiceRecord } from "../types.js";
|
||||||
|
|
||||||
|
interface ServicesResponse {
|
||||||
|
services?: ServiceRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceNames: Record<string, string> = {
|
||||||
|
tcp_admin: "TCP/Admin",
|
||||||
|
kcp: "KCP",
|
||||||
|
quic: "QUIC",
|
||||||
|
websocket: "WebSocket",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadServices(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api<ServicesResponse>("/services");
|
||||||
|
state.services = data.services || [];
|
||||||
|
renderServices();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderServices(): void {
|
||||||
|
el("servicesGrid").innerHTML = state.services.map((service) => `
|
||||||
|
<article class="service">
|
||||||
|
<div>
|
||||||
|
<span>${escapeHTML(formatServiceName(service.name))}</span>
|
||||||
|
<strong>${serviceStatusText(service)}</strong>
|
||||||
|
</div>
|
||||||
|
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLFormElement>("[data-service-form]").forEach((form) => {
|
||||||
|
form.addEventListener("submit", saveService);
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-restart-service]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const name = button.dataset.restartService;
|
||||||
|
if (name) {
|
||||||
|
restartService(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceSummary(service: ServiceRecord): string {
|
||||||
|
return `<div><span>${escapeHTML(t("port"))}</span><strong>${service.port}</strong></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceForm(service: ServiceRecord): string {
|
||||||
|
const disabled = service.name === "tcp_admin" ? "disabled" : "";
|
||||||
|
const optionFields = serviceOptionFields(service);
|
||||||
|
return `
|
||||||
|
<form data-service-form="${escapeAttr(service.name)}">
|
||||||
|
<label class="inline">
|
||||||
|
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
|
||||||
|
${escapeHTML(t("enabled"))}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
${escapeHTML(t("port"))}
|
||||||
|
<input name="port" type="number" min="1" max="65535" value="${service.port}">
|
||||||
|
</label>
|
||||||
|
${optionFields}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="submit">${escapeHTML(t("save"))}</button>
|
||||||
|
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">${escapeHTML(t("restart"))}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceOptionFields(service: ServiceRecord): string {
|
||||||
|
const options = service.options || {};
|
||||||
|
if (service.name === "kcp") {
|
||||||
|
return `
|
||||||
|
<label>${escapeHTML(t("dataShards"))}<input name="data_shards" type="number" min="1" value="${numberOption(options, "data_shards", 10)}"></label>
|
||||||
|
<label>${escapeHTML(t("parityShards"))}<input name="parity_shards" type="number" min="1" value="${numberOption(options, "parity_shards", 3)}"></label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (service.name === "quic") {
|
||||||
|
const protocols = arrayOption(options, "application_protocols").join(",");
|
||||||
|
return `<label>${escapeHTML(t("protocols"))}<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
|
||||||
|
}
|
||||||
|
if (service.name === "websocket") {
|
||||||
|
return `<label>${escapeHTML(t("path"))}<input name="path" value="${escapeAttr(stringOption(options, "path", "/"))}"></label>`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveService(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget as HTMLFormElement;
|
||||||
|
const name = form.dataset.serviceForm;
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
if (name === "kcp") {
|
||||||
|
options.data_shards = Number(getFormInput(form, "data_shards").value);
|
||||||
|
options.parity_shards = Number(getFormInput(form, "parity_shards").value);
|
||||||
|
} else if (name === "quic") {
|
||||||
|
options.application_protocols = getFormInput(form, "application_protocols").value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
} else if (name === "websocket") {
|
||||||
|
options.path = getFormInput(form, "path").value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/services/${encodeURIComponent(name)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
enabled: name === "tcp_admin" ? true : getFormInput(form, "enabled").checked,
|
||||||
|
port: Number(getFormInput(form, "port").value),
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loadServices();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartService(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
|
||||||
|
await loadServices();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatServiceName(name: string): string {
|
||||||
|
return serviceNames[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceStatusText(service: ServiceRecord): string {
|
||||||
|
const status = service.enabled ? t("enabled") : t("disabled");
|
||||||
|
return escapeHTML(service.restart_required ? `${status} / ${t("restartRequired")}` : status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOption(options: Record<string, unknown>, key: string, fallback: number): number {
|
||||||
|
const value = options[key];
|
||||||
|
return typeof value === "number" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOption(options: Record<string, unknown>, key: string, fallback: string): string {
|
||||||
|
const value = options[key];
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayOption(options: Record<string, unknown>, key: string): string[] {
|
||||||
|
const value = options[key];
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||||
|
}
|
||||||
25
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
25
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { el, stat } from "../dom.js";
|
||||||
|
import { t } from "../i18n.js";
|
||||||
|
|
||||||
|
interface StatusResponse {
|
||||||
|
pid?: number;
|
||||||
|
uptime_seconds?: number;
|
||||||
|
db_path?: string;
|
||||||
|
tcp_admin_port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await api<StatusResponse>("/status");
|
||||||
|
el("statusGrid").innerHTML = [
|
||||||
|
stat("PID", status.pid),
|
||||||
|
stat(t("uptime"), `${status.uptime_seconds}s`),
|
||||||
|
stat("SQLite", status.db_path),
|
||||||
|
stat("TCP/Admin", status.tcp_admin_port),
|
||||||
|
].join("");
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
106
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { api } from "../api.js";
|
||||||
|
import { showAlert } from "../alerts.js";
|
||||||
|
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
|
||||||
|
import { t } from "../i18n.js";
|
||||||
|
import { formatRole } from "../session.js";
|
||||||
|
import { state } from "../state.js";
|
||||||
|
import type { User } from "../types.js";
|
||||||
|
|
||||||
|
interface UsersResponse {
|
||||||
|
users?: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUsers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api<UsersResponse>("/users");
|
||||||
|
state.users = data.users || [];
|
||||||
|
renderUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUsers(): void {
|
||||||
|
el("usersBody").innerHTML = state.users.map((user) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHTML(user.username)}</td>
|
||||||
|
<td>${escapeHTML(formatRole(user.role))}</td>
|
||||||
|
<td>${badge(user.disabled ? t("disabled") : t("activeUser"), user.disabled)}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">${escapeHTML(t("edit"))}</button>
|
||||||
|
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">${escapeHTML(t("delete"))}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-edit-user]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const user = state.users.find((item) => item.username === button.dataset.editUser);
|
||||||
|
openUserDialog(user || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-delete-user]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const username = button.dataset.deleteUser;
|
||||||
|
if (username) {
|
||||||
|
removeUser(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openUserDialog(user: User | null = null): void {
|
||||||
|
const form = el<HTMLFormElement>("userForm");
|
||||||
|
form.reset();
|
||||||
|
form.dataset.originalUsername = user ? user.username : "";
|
||||||
|
getFormInput(form, "username").disabled = Boolean(user);
|
||||||
|
getFormInput(form, "password").required = !user;
|
||||||
|
if (user) {
|
||||||
|
getFormInput(form, "username").value = user.username;
|
||||||
|
getFormSelect(form, "role").value = user.role;
|
||||||
|
getFormInput(form, "disabled").checked = user.disabled;
|
||||||
|
} else {
|
||||||
|
getFormSelect(form, "role").value = "member";
|
||||||
|
}
|
||||||
|
el<HTMLDialogElement>("userDialog").showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUser(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget as HTMLFormElement;
|
||||||
|
const username = form.dataset.originalUsername || getFormInput(form, "username").value;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
role: getFormSelect(form, "role").value,
|
||||||
|
disabled: getFormInput(form, "disabled").checked,
|
||||||
|
};
|
||||||
|
if (getFormInput(form, "password").value) {
|
||||||
|
body.password = getFormInput(form, "password").value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (form.dataset.originalUsername) {
|
||||||
|
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
|
||||||
|
} else {
|
||||||
|
body.username = username;
|
||||||
|
await api("/users", { method: "POST", body });
|
||||||
|
}
|
||||||
|
el<HTMLDialogElement>("userDialog").close();
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(username: string): Promise<void> {
|
||||||
|
if (!confirm(t("deleteUserConfirm", { username }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed admin_static/index.html admin_static/app.css admin_static/app.js
|
|
||||||
var adminStaticFS embed.FS
|
|
||||||
|
|
||||||
func newGatewayHTTPHandler() http.Handler {
|
func newGatewayHTTPHandler() http.Handler {
|
||||||
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
|
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
|
||||||
AdminPath: adminStartup.AdminPath,
|
AdminPath: adminStartup.AdminPath,
|
||||||
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
|
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
|
||||||
Assets: adminStaticFS,
|
|
||||||
APIHandler: newAdminAPIHandler(),
|
APIHandler: newAdminAPIHandler(),
|
||||||
WebSocketEnabled: config.WebSocket.Enable,
|
WebSocketEnabled: config.WebSocket.Enable,
|
||||||
WebSocketPath: normalizedWebSocketPath(),
|
WebSocketPath: normalizedWebSocketPath(),
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ label.inline input {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-field select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-view {
|
.auth-view {
|
||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -355,6 +367,10 @@ dialog h2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar input {
|
.toolbar input {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,579 +0,0 @@
|
|||||||
const apiBase = document.body.dataset.apiPrefix || "/admin/api";
|
|
||||||
const state = {
|
|
||||||
token: sessionStorage.getItem("mcGatewayAdminToken") || "",
|
|
||||||
user: null,
|
|
||||||
routes: [],
|
|
||||||
services: [],
|
|
||||||
users: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
function showAlert(message) {
|
|
||||||
const box = el("alert");
|
|
||||||
box.textContent = message;
|
|
||||||
box.classList.toggle("hidden", !message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setView(name) {
|
|
||||||
for (const id of ["setupView", "loginView", "appView"]) {
|
|
||||||
el(id).classList.toggle("hidden", id !== name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const headers = { "Accept": "application/json" };
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
if (state.token) {
|
|
||||||
headers.Authorization = `Bearer ${state.token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(apiBase + path, {
|
|
||||||
method: options.method || "GET",
|
|
||||||
headers,
|
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data = {};
|
|
||||||
const text = await res.text();
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
data = { error: text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || res.statusText);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot() {
|
|
||||||
bindEvents();
|
|
||||||
try {
|
|
||||||
const setup = await api("/setup");
|
|
||||||
if (setup.required) {
|
|
||||||
setView("setupView");
|
|
||||||
el("subtitle").textContent = "Setup";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.token) {
|
|
||||||
setView("loginView");
|
|
||||||
el("subtitle").textContent = "Login";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
state.user = await api("/me");
|
|
||||||
await showApp();
|
|
||||||
} catch {
|
|
||||||
sessionStorage.removeItem("mcGatewayAdminToken");
|
|
||||||
state.token = "";
|
|
||||||
setView("loginView");
|
|
||||||
el("subtitle").textContent = "Login";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
el("setupForm").addEventListener("submit", submitSetup);
|
|
||||||
el("loginForm").addEventListener("submit", submitLogin);
|
|
||||||
el("logoutBtn").addEventListener("click", logout);
|
|
||||||
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
|
|
||||||
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
|
|
||||||
el("newUserBtn").addEventListener("click", () => openUserDialog());
|
|
||||||
el("routeForm").addEventListener("submit", saveRoute);
|
|
||||||
el("userForm").addEventListener("submit", saveUser);
|
|
||||||
|
|
||||||
for (const button of document.querySelectorAll("[data-close]")) {
|
|
||||||
button.addEventListener("click", () => button.closest("dialog").close());
|
|
||||||
}
|
|
||||||
for (const button of document.querySelectorAll(".tabs button")) {
|
|
||||||
button.addEventListener("click", () => selectTab(button.dataset.tab));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitSetup(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
try {
|
|
||||||
await api("/setup", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
username: form.get("username"),
|
|
||||||
password: form.get("password"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
showAlert("");
|
|
||||||
setView("loginView");
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitLogin(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
try {
|
|
||||||
const data = await api("/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
username: form.get("username"),
|
|
||||||
password: form.get("password"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.token = data.token;
|
|
||||||
state.user = data.user;
|
|
||||||
sessionStorage.setItem("mcGatewayAdminToken", state.token);
|
|
||||||
showAlert("");
|
|
||||||
await showApp();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await api("/auth/logout", { method: "POST", body: {} });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
sessionStorage.removeItem("mcGatewayAdminToken");
|
|
||||||
state.token = "";
|
|
||||||
state.user = null;
|
|
||||||
setView("loginView");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showApp() {
|
|
||||||
setView("appView");
|
|
||||||
el("subtitle").textContent = "Admin";
|
|
||||||
el("sessionUser").textContent = `${state.user.username} (${state.user.role})`;
|
|
||||||
el("logoutBtn").classList.remove("hidden");
|
|
||||||
applyRoleVisibility();
|
|
||||||
await loadRoutes();
|
|
||||||
if (isMember()) {
|
|
||||||
await loadStatus();
|
|
||||||
await loadServices();
|
|
||||||
await loadMetrics();
|
|
||||||
}
|
|
||||||
if (isAdmin()) {
|
|
||||||
await loadUsers();
|
|
||||||
await loadAudit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRoleVisibility() {
|
|
||||||
const member = isMember();
|
|
||||||
const admin = isAdmin();
|
|
||||||
el("statusGrid").classList.toggle("hidden", !member);
|
|
||||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
|
||||||
toggleTab("services", member);
|
|
||||||
toggleTab("metrics", member);
|
|
||||||
toggleTab("users", admin);
|
|
||||||
toggleTab("audit", admin);
|
|
||||||
selectTab("routes");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTab(name, visible) {
|
|
||||||
document.querySelector(`[data-tab="${name}"]`).classList.toggle("hidden", !visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTab(name) {
|
|
||||||
for (const button of document.querySelectorAll(".tabs button")) {
|
|
||||||
button.classList.toggle("active", button.dataset.tab === name);
|
|
||||||
}
|
|
||||||
for (const panel of document.querySelectorAll(".tab-panel")) {
|
|
||||||
panel.classList.add("hidden");
|
|
||||||
}
|
|
||||||
el(`${name}Tab`).classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
try {
|
|
||||||
const status = await api("/status");
|
|
||||||
el("statusGrid").innerHTML = [
|
|
||||||
stat("PID", status.pid),
|
|
||||||
stat("Uptime", `${status.uptime_seconds}s`),
|
|
||||||
stat("SQLite", status.db_path),
|
|
||||||
stat("TCP/Admin", status.tcp_admin_port),
|
|
||||||
].join("");
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRoutes() {
|
|
||||||
try {
|
|
||||||
const q = encodeURIComponent(el("routeSearch").value || "");
|
|
||||||
const data = await api(q ? `/routes?q=${q}` : "/routes");
|
|
||||||
state.routes = data.routes || [];
|
|
||||||
renderRoutes();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRoutes() {
|
|
||||||
const canWrite = isMember();
|
|
||||||
el("routesBody").innerHTML = state.routes.map((route) => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHTML(route.host)}</td>
|
|
||||||
<td>${escapeHTML(route.upstream)}</td>
|
|
||||||
<td>${badge(route.enabled ? "Enabled" : "Disabled", !route.enabled)}</td>
|
|
||||||
<td>${escapeHTML(route.note || "")}</td>
|
|
||||||
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
for (const button of document.querySelectorAll("[data-edit-route]")) {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
|
|
||||||
openRouteDialog(route);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const button of document.querySelectorAll("[data-delete-route]")) {
|
|
||||||
button.addEventListener("click", () => removeRoute(button.dataset.deleteRoute));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeActions(route) {
|
|
||||||
return `
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">Edit</button>
|
|
||||||
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">Delete</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRouteDialog(route = null) {
|
|
||||||
const form = el("routeForm");
|
|
||||||
form.reset();
|
|
||||||
form.dataset.originalHost = route ? route.host : "";
|
|
||||||
form.elements.host.disabled = Boolean(route);
|
|
||||||
if (route) {
|
|
||||||
form.elements.host.value = route.host;
|
|
||||||
form.elements.upstream.value = route.upstream;
|
|
||||||
form.elements.enabled.checked = route.enabled;
|
|
||||||
form.elements.note.value = route.note || "";
|
|
||||||
} else {
|
|
||||||
form.elements.enabled.checked = true;
|
|
||||||
}
|
|
||||||
el("routeDialog").showModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRoute(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const host = form.dataset.originalHost || form.elements.host.value;
|
|
||||||
try {
|
|
||||||
await api(`/routes/${encodeURIComponent(host)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: {
|
|
||||||
upstream: form.elements.upstream.value,
|
|
||||||
enabled: form.elements.enabled.checked,
|
|
||||||
note: form.elements.note.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
el("routeDialog").close();
|
|
||||||
await loadRoutes();
|
|
||||||
showAlert("");
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRoute(host) {
|
|
||||||
if (host === "default" && !confirm("Delete default route?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
|
|
||||||
await loadRoutes();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadServices() {
|
|
||||||
try {
|
|
||||||
const data = await api("/services");
|
|
||||||
state.services = data.services || [];
|
|
||||||
renderServices();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderServices() {
|
|
||||||
el("servicesGrid").innerHTML = state.services.map((service) => `
|
|
||||||
<article class="service">
|
|
||||||
<div>
|
|
||||||
<span>${escapeHTML(service.name)}</span>
|
|
||||||
<strong>${service.enabled ? "Enabled" : "Disabled"}${service.restart_required ? " / restart required" : ""}</strong>
|
|
||||||
</div>
|
|
||||||
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
|
|
||||||
</article>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
for (const form of document.querySelectorAll("[data-service-form]")) {
|
|
||||||
form.addEventListener("submit", saveService);
|
|
||||||
}
|
|
||||||
for (const button of document.querySelectorAll("[data-restart-service]")) {
|
|
||||||
button.addEventListener("click", () => restartService(button.dataset.restartService));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceSummary(service) {
|
|
||||||
return `<div><span>Port</span><strong>${service.port}</strong></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceForm(service) {
|
|
||||||
const disabled = service.name === "tcp_admin" ? "disabled" : "";
|
|
||||||
const optionFields = serviceOptionFields(service);
|
|
||||||
return `
|
|
||||||
<form data-service-form="${escapeAttr(service.name)}">
|
|
||||||
<label class="inline">
|
|
||||||
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Port
|
|
||||||
<input name="port" type="number" min="1" max="65535" value="${service.port}">
|
|
||||||
</label>
|
|
||||||
${optionFields}
|
|
||||||
<div class="row-actions">
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">Restart</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceOptionFields(service) {
|
|
||||||
const options = service.options || {};
|
|
||||||
if (service.name === "kcp") {
|
|
||||||
return `
|
|
||||||
<label>Data shards<input name="data_shards" type="number" min="1" value="${options.data_shards || 10}"></label>
|
|
||||||
<label>Parity shards<input name="parity_shards" type="number" min="1" value="${options.parity_shards || 3}"></label>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
if (service.name === "quic") {
|
|
||||||
const protocols = Array.isArray(options.application_protocols) ? options.application_protocols.join(",") : "";
|
|
||||||
return `<label>Protocols<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
|
|
||||||
}
|
|
||||||
if (service.name === "websocket") {
|
|
||||||
return `<label>Path<input name="path" value="${escapeAttr(options.path || "/")}"></label>`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveService(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const name = form.dataset.serviceForm;
|
|
||||||
const options = {};
|
|
||||||
if (name === "kcp") {
|
|
||||||
options.data_shards = Number(form.elements.data_shards.value);
|
|
||||||
options.parity_shards = Number(form.elements.parity_shards.value);
|
|
||||||
} else if (name === "quic") {
|
|
||||||
options.application_protocols = form.elements.application_protocols.value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
||||||
} else if (name === "websocket") {
|
|
||||||
options.path = form.elements.path.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api(`/services/${encodeURIComponent(name)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: {
|
|
||||||
enabled: name === "tcp_admin" ? true : form.elements.enabled.checked,
|
|
||||||
port: Number(form.elements.port.value),
|
|
||||||
options,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await loadServices();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartService(name) {
|
|
||||||
try {
|
|
||||||
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
|
|
||||||
await loadServices();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
try {
|
|
||||||
const data = await api("/users");
|
|
||||||
state.users = data.users || [];
|
|
||||||
renderUsers();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsers() {
|
|
||||||
el("usersBody").innerHTML = state.users.map((user) => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHTML(user.username)}</td>
|
|
||||||
<td>${escapeHTML(user.role)}</td>
|
|
||||||
<td>${badge(user.disabled ? "Disabled" : "Active", user.disabled)}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">Edit</button>
|
|
||||||
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
for (const button of document.querySelectorAll("[data-edit-user]")) {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const user = state.users.find((item) => item.username === button.dataset.editUser);
|
|
||||||
openUserDialog(user);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const button of document.querySelectorAll("[data-delete-user]")) {
|
|
||||||
button.addEventListener("click", () => removeUser(button.dataset.deleteUser));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUserDialog(user = null) {
|
|
||||||
const form = el("userForm");
|
|
||||||
form.reset();
|
|
||||||
form.dataset.originalUsername = user ? user.username : "";
|
|
||||||
form.elements.username.disabled = Boolean(user);
|
|
||||||
form.elements.password.required = !user;
|
|
||||||
if (user) {
|
|
||||||
form.elements.username.value = user.username;
|
|
||||||
form.elements.role.value = user.role;
|
|
||||||
form.elements.disabled.checked = user.disabled;
|
|
||||||
} else {
|
|
||||||
form.elements.role.value = "member";
|
|
||||||
}
|
|
||||||
el("userDialog").showModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveUser(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const username = form.dataset.originalUsername || form.elements.username.value;
|
|
||||||
const body = {
|
|
||||||
role: form.elements.role.value,
|
|
||||||
disabled: form.elements.disabled.checked,
|
|
||||||
};
|
|
||||||
if (form.elements.password.value) {
|
|
||||||
body.password = form.elements.password.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (form.dataset.originalUsername) {
|
|
||||||
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
|
|
||||||
} else {
|
|
||||||
body.username = username;
|
|
||||||
await api("/users", { method: "POST", body });
|
|
||||||
}
|
|
||||||
el("userDialog").close();
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeUser(username) {
|
|
||||||
if (!confirm(`Delete user ${username}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMetrics() {
|
|
||||||
try {
|
|
||||||
const data = await api("/metrics");
|
|
||||||
el("metricsGrid").innerHTML = [
|
|
||||||
stat("Total", data.total_connections),
|
|
||||||
stat("Active", data.active_connections),
|
|
||||||
stat("TCP", data.tcp_connections),
|
|
||||||
stat("WebSocket", data.websocket_connections),
|
|
||||||
stat("Misses", data.route_misses),
|
|
||||||
stat("Dial errors", data.upstream_dial_errors),
|
|
||||||
].join("");
|
|
||||||
const hits = data.route_hits || {};
|
|
||||||
el("routeHits").innerHTML = Object.keys(hits).length
|
|
||||||
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
|
|
||||||
: `<span class="chip">No hits</span>`;
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAudit() {
|
|
||||||
try {
|
|
||||||
const data = await api("/audit-logs");
|
|
||||||
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
|
|
||||||
<tr>
|
|
||||||
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
|
|
||||||
<td>${escapeHTML(item.actor)}</td>
|
|
||||||
<td>${escapeHTML(item.action)}</td>
|
|
||||||
<td>${escapeHTML(item.target_type)}:${escapeHTML(item.target_id)}</td>
|
|
||||||
<td>${badge(item.success ? "Success" : "Failed", !item.success)}</td>
|
|
||||||
<td>${escapeHTML(item.message || "")}</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
} catch (err) {
|
|
||||||
showAlert(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stat(label, value) {
|
|
||||||
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function badge(text, off = false) {
|
|
||||||
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdmin() {
|
|
||||||
return state.user && state.user.role === "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMember() {
|
|
||||||
return state.user && (state.user.role === "admin" || state.user.role === "member");
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce(fn, wait) {
|
|
||||||
let id = 0;
|
|
||||||
return (...args) => {
|
|
||||||
clearTimeout(id);
|
|
||||||
id = setTimeout(() => fn(...args), wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHTML(value) {
|
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
"\"": """,
|
|
||||||
"'": "'",
|
|
||||||
}[ch]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(value) {
|
|
||||||
return escapeHTML(value).replace(/`/g, "`");
|
|
||||||
}
|
|
||||||
|
|
||||||
boot();
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>mc-gateway admin</title>
|
<title>mc-gateway admin</title>
|
||||||
<link rel="stylesheet" href="app.css">
|
<link rel="stylesheet" href="app.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-api-prefix="__ADMIN_API_PREFIX__">
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -14,8 +14,15 @@
|
|||||||
<p id="subtitle">Admin</p>
|
<p id="subtitle">Admin</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="session">
|
<div class="session">
|
||||||
|
<label class="language-field">
|
||||||
|
<span data-i18n="language">Language</span>
|
||||||
|
<select id="languageSelect" aria-label="Language">
|
||||||
|
<option value="en" data-i18n="languageEnglish">English</option>
|
||||||
|
<option value="zh" data-i18n="languageChinese">中文</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<span id="sessionUser"></span>
|
<span id="sessionUser"></span>
|
||||||
<button id="logoutBtn" class="ghost hidden" type="button">Logout</button>
|
<button id="logoutBtn" class="ghost hidden" type="button" data-i18n="logout">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -23,31 +30,31 @@
|
|||||||
|
|
||||||
<section id="setupView" class="auth-view hidden">
|
<section id="setupView" class="auth-view hidden">
|
||||||
<form id="setupForm" class="panel compact">
|
<form id="setupForm" class="panel compact">
|
||||||
<h2>Initial admin</h2>
|
<h2 data-i18n="initialAdmin">Initial admin</h2>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span data-i18n="username">Username</span>
|
||||||
<input name="username" autocomplete="username" value="admin">
|
<input name="username" autocomplete="username" value="admin">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
<span data-i18n="password">Password</span>
|
||||||
<input name="password" autocomplete="new-password" type="password" required>
|
<input name="password" autocomplete="new-password" type="password" required>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Create admin</button>
|
<button type="submit" data-i18n="createAdmin">Create admin</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="loginView" class="auth-view hidden">
|
<section id="loginView" class="auth-view hidden">
|
||||||
<form id="loginForm" class="panel compact">
|
<form id="loginForm" class="panel compact">
|
||||||
<h2>Login</h2>
|
<h2 data-i18n="login">Login</h2>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span data-i18n="username">Username</span>
|
||||||
<input name="username" autocomplete="username" required>
|
<input name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
<span data-i18n="password">Password</span>
|
||||||
<input name="password" autocomplete="current-password" type="password" required>
|
<input name="password" autocomplete="current-password" type="password" required>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Login</button>
|
<button type="submit" data-i18n="login">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -55,27 +62,27 @@
|
|||||||
<section id="statusGrid" class="status-grid"></section>
|
<section id="statusGrid" class="status-grid"></section>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button data-tab="routes" class="active" type="button">Routes</button>
|
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
|
||||||
<button data-tab="services" type="button">Services</button>
|
<button data-tab="services" type="button" data-i18n="services">Services</button>
|
||||||
<button data-tab="users" type="button">Users</button>
|
<button data-tab="users" type="button" data-i18n="users">Users</button>
|
||||||
<button data-tab="metrics" type="button">Metrics</button>
|
<button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
|
||||||
<button data-tab="audit" type="button">Audit</button>
|
<button data-tab="audit" type="button" data-i18n="audit">Audit</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section id="routesTab" class="tab-panel">
|
<section id="routesTab" class="tab-panel">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input id="routeSearch" placeholder="Search host, upstream, note">
|
<input id="routeSearch" placeholder="Search host, upstream, note" data-i18n-placeholder="routeSearch">
|
||||||
<button id="newRouteBtn" type="button">New route</button>
|
<button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Host</th>
|
<th data-i18n="host">Host</th>
|
||||||
<th>Upstream</th>
|
<th data-i18n="upstream">Upstream</th>
|
||||||
<th>Enabled</th>
|
<th data-i18n="enabled">Enabled</th>
|
||||||
<th>Note</th>
|
<th data-i18n="note">Note</th>
|
||||||
<th class="actions">Actions</th>
|
<th class="actions" data-i18n="actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="routesBody"></tbody>
|
<tbody id="routesBody"></tbody>
|
||||||
@@ -89,16 +96,16 @@
|
|||||||
|
|
||||||
<section id="usersTab" class="tab-panel hidden">
|
<section id="usersTab" class="tab-panel hidden">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="newUserBtn" type="button">New user</button>
|
<button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th data-i18n="username">Username</th>
|
||||||
<th>Role</th>
|
<th data-i18n="role">Role</th>
|
||||||
<th>Disabled</th>
|
<th data-i18n="disabled">Disabled</th>
|
||||||
<th class="actions">Actions</th>
|
<th class="actions" data-i18n="actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="usersBody"></tbody>
|
<tbody id="usersBody"></tbody>
|
||||||
@@ -109,7 +116,7 @@
|
|||||||
<section id="metricsTab" class="tab-panel hidden">
|
<section id="metricsTab" class="tab-panel hidden">
|
||||||
<div id="metricsGrid" class="status-grid"></div>
|
<div id="metricsGrid" class="status-grid"></div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Route hits</h2>
|
<h2 data-i18n="routeHits">Route hits</h2>
|
||||||
<div id="routeHits" class="chips"></div>
|
<div id="routeHits" class="chips"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -119,12 +126,12 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th data-i18n="time">Time</th>
|
||||||
<th>Actor</th>
|
<th data-i18n="actor">Actor</th>
|
||||||
<th>Action</th>
|
<th data-i18n="action">Action</th>
|
||||||
<th>Target</th>
|
<th data-i18n="target">Target</th>
|
||||||
<th>Result</th>
|
<th data-i18n="result">Result</th>
|
||||||
<th>Message</th>
|
<th data-i18n="message">Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="auditBody"></tbody>
|
<tbody id="auditBody"></tbody>
|
||||||
@@ -136,60 +143,61 @@
|
|||||||
|
|
||||||
<dialog id="routeDialog">
|
<dialog id="routeDialog">
|
||||||
<form id="routeForm" method="dialog">
|
<form id="routeForm" method="dialog">
|
||||||
<h2>Route</h2>
|
<h2 data-i18n="route">Route</h2>
|
||||||
<label>
|
<label>
|
||||||
Host
|
<span data-i18n="host">Host</span>
|
||||||
<input name="host" required>
|
<input name="host" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Upstream
|
<span data-i18n="upstream">Upstream</span>
|
||||||
<input name="upstream" required placeholder="127.0.0.1:25565">
|
<input name="upstream" required placeholder="127.0.0.1:25565">
|
||||||
</label>
|
</label>
|
||||||
<label class="inline">
|
<label class="inline">
|
||||||
<input name="enabled" type="checkbox" checked>
|
<input name="enabled" type="checkbox" checked>
|
||||||
Enabled
|
<span data-i18n="enabled">Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Note
|
<span data-i18n="note">Note</span>
|
||||||
<input name="note">
|
<input name="note">
|
||||||
</label>
|
</label>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" data-close>Cancel</button>
|
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||||
<button type="submit">Save</button>
|
<button type="submit" data-i18n="save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="userDialog">
|
<dialog id="userDialog">
|
||||||
<form id="userForm" method="dialog">
|
<form id="userForm" method="dialog">
|
||||||
<h2>User</h2>
|
<h2 data-i18n="user">User</h2>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span data-i18n="username">Username</span>
|
||||||
<input name="username" required>
|
<input name="username" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Role
|
<span data-i18n="role">Role</span>
|
||||||
<select name="role">
|
<select name="role">
|
||||||
<option value="admin">Admin</option>
|
<option value="admin" data-i18n="roleAdmin">Admin</option>
|
||||||
<option value="member">Member</option>
|
<option value="member" data-i18n="roleMember">Member</option>
|
||||||
<option value="guest">Guest</option>
|
<option value="guest" data-i18n="roleGuest">Guest</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
<span data-i18n="password">Password</span>
|
||||||
<input name="password" type="password">
|
<input name="password" type="password">
|
||||||
</label>
|
</label>
|
||||||
<label class="inline">
|
<label class="inline">
|
||||||
<input name="disabled" type="checkbox">
|
<input name="disabled" type="checkbox">
|
||||||
Disabled
|
<span data-i18n="disabled">Disabled</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" data-close>Cancel</button>
|
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||||
<button type="submit">Save</button>
|
<button type="submit" data-i18n="save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="config.js"></script>
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -101,8 +101,12 @@ func TestParseStartupConfigReturnsErrors(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "api prefix under asset path",
|
name: "api prefix under js asset path",
|
||||||
env: map[string]string{adminEnvAPIPrefix: "/admin/app.js/api"},
|
env: map[string]string{adminEnvAPIPrefix: "/admin/js/api"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "api prefix under config asset path",
|
||||||
|
env: map[string]string{adminEnvAPIPrefix: "/admin/config.js/api"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}"
|
MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}"
|
||||||
MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
|
MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
|
||||||
MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}"
|
MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}"
|
||||||
|
MC_GATEWAY_ADMIN_STATIC_DIR: "${MC_GATEWAY_ADMIN_STATIC_DIR:-/usr/share/mc-gateway/admin_static}"
|
||||||
MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}"
|
MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}"
|
||||||
MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
|
MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
|
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
|
||||||
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
|
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
|
||||||
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
|
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
|
||||||
|
| Admin 静态目录 | `cmd/gateway/admin_static` | `MC_GATEWAY_ADMIN_STATIC_DIR` |
|
||||||
| KCP | 默认禁用 | 后台配置 |
|
| KCP | 默认禁用 | 后台配置 |
|
||||||
| QUIC | 默认禁用 | 后台配置 |
|
| QUIC | 默认禁用 | 后台配置 |
|
||||||
| WebSocket | 默认禁用 | 后台配置 |
|
| WebSocket | 默认禁用 | 后台配置 |
|
||||||
@@ -67,7 +68,8 @@
|
|||||||
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`。
|
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`。
|
||||||
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`。
|
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`。
|
||||||
- `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api`。
|
- `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api`。
|
||||||
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下。
|
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下,例如 `config.js` 或 `js/`。
|
||||||
|
- `MC_GATEWAY_ADMIN_STATIC_DIR` 指向 Admin 前端构建产物目录;Docker 镜像内使用 `/usr/share/mc-gateway/admin_static`。
|
||||||
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`。
|
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`。
|
||||||
|
|
||||||
启动流程:
|
启动流程:
|
||||||
@@ -130,7 +132,7 @@ Accept
|
|||||||
|
|
||||||
## HTTP 路由与页面
|
## HTTP 路由与页面
|
||||||
|
|
||||||
页面使用 Go `embed` 打包到单个二进制,不引入 Node 构建链。
|
Admin 前端源码使用 TypeScript 拆分,构建为原生 ES modules。Go 不再 `embed` 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录读取并透传静态响应。运行时 API 前缀通过动态 `config.js` 响应注入。
|
||||||
|
|
||||||
建议目录:
|
建议目录:
|
||||||
|
|
||||||
@@ -140,10 +142,11 @@ cmd/gateway/admin_api.go
|
|||||||
cmd/gateway/admin_auth.go
|
cmd/gateway/admin_auth.go
|
||||||
cmd/gateway/admin_db.go
|
cmd/gateway/admin_db.go
|
||||||
cmd/gateway/admin_static.go
|
cmd/gateway/admin_static.go
|
||||||
|
cmd/gateway/admin_frontend/src/
|
||||||
cmd/gateway/admin_static/
|
cmd/gateway/admin_static/
|
||||||
index.html
|
index.html
|
||||||
app.css
|
app.css
|
||||||
app.js
|
js/
|
||||||
```
|
```
|
||||||
|
|
||||||
API 路由:
|
API 路由:
|
||||||
@@ -152,7 +155,8 @@ API 路由:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
|
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
|
||||||
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
|
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
|
||||||
| GET | `/admin/app.js` | 公开页面 | 页面脚本 |
|
| GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
|
||||||
|
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
|
||||||
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
|
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
|
||||||
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
|
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
|
||||||
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |
|
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func validateAdminPaths(adminPath, apiPrefix string) error {
|
|||||||
return errors.New("admin API prefix cannot equal admin page path")
|
return errors.New("admin API prefix cannot equal admin page path")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, asset := range []string{"app.css", "app.js"} {
|
for _, asset := range []string{"app.css", "config.js", "js"} {
|
||||||
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
|
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
|
||||||
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
|
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
|
||||||
return errors.New("admin API prefix cannot be under static asset path")
|
return errors.New("admin API prefix cannot be under static asset path")
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ func TestParseReturnsErrors(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "api prefix under asset path",
|
name: "api prefix under js asset path",
|
||||||
env: map[string]string{EnvAPIPrefix: "/admin/app.js/api"},
|
env: map[string]string{EnvAPIPrefix: "/admin/js/api"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "api prefix under config asset path",
|
||||||
|
env: map[string]string{EnvAPIPrefix: "/admin/config.js/api"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
package adminhttp
|
package adminhttp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
staticIndexFile = "admin_static/index.html"
|
defaultStaticDir = "cmd/gateway/admin_static"
|
||||||
staticCSSFile = "admin_static/app.css"
|
staticConfigFile = "config.js"
|
||||||
staticJSFile = "admin_static/app.js"
|
staticIndexFile = "index.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GatewayHandlerOptions struct {
|
type GatewayHandlerOptions struct {
|
||||||
AdminPath string
|
AdminPath string
|
||||||
AdminAPIPrefix string
|
AdminAPIPrefix string
|
||||||
Assets fs.FS
|
StaticDir string
|
||||||
APIHandler http.HandlerFunc
|
APIHandler http.HandlerFunc
|
||||||
WebSocketEnabled bool
|
WebSocketEnabled bool
|
||||||
WebSocketPath string
|
WebSocketPath string
|
||||||
@@ -68,40 +71,67 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandle
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Path == opts.AdminPath {
|
if r.URL.Path == opts.AdminPath {
|
||||||
serveAdminIndex(w, opts)
|
serveAdminFile(w, r, opts, staticIndexFile)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
||||||
switch rel {
|
if rel == staticConfigFile {
|
||||||
case "app.css":
|
serveAdminConfig(w, opts)
|
||||||
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
|
|
||||||
case "app.js":
|
|
||||||
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
|
||||||
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
|
|
||||||
if err != nil {
|
|
||||||
WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
|
serveAdminFile(w, r, opts, rel)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
_, _ = w.Write([]byte(html))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
|
func serveAdminConfig(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||||
data, err := fs.ReadFile(assets, name)
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
if err != nil {
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(`window.MCGatewayAdmin={"apiPrefix":` + strconv.Quote(opts.AdminAPIPrefix) + `};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAdminFile(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions, rel string) {
|
||||||
|
name, ok := cleanStaticPath(rel)
|
||||||
|
if !ok {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", contentType)
|
file := filepath.Join(staticDir(opts), name)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
info, err := os.Stat(file)
|
||||||
_, _ = w.Write(data)
|
if err != nil || info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.ServeFile(w, r, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanStaticPath(rel string) (string, bool) {
|
||||||
|
if rel == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cleaned := path.Clean("/" + rel)
|
||||||
|
if cleaned == "/" || strings.HasPrefix(cleaned, "/../") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(cleaned, "/")
|
||||||
|
if name == staticConfigFile {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticDir(opts GatewayHandlerOptions) string {
|
||||||
|
if strings.TrimSpace(opts.StaticDir) != "" {
|
||||||
|
return opts.StaticDir
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("MC_GATEWAY_ADMIN_STATIC_DIR")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(defaultStaticDir); err == nil {
|
||||||
|
return defaultStaticDir
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("admin_static"); err == nil {
|
||||||
|
return "admin_static"
|
||||||
|
}
|
||||||
|
return defaultStaticDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeJSONRequest(t *testing.T) {
|
func TestDecodeJSONRequest(t *testing.T) {
|
||||||
@@ -117,16 +118,16 @@ func TestRequestSourceIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||||
assets := fstest.MapFS{
|
staticDir := writeTestAdminStatic(t, map[string]string{
|
||||||
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
|
"index.html": "<html><script src=\"config.js\"></script></html>",
|
||||||
staticCSSFile: {Data: []byte(`body{color:red}`)},
|
"app.css": "body{color:red}",
|
||||||
staticJSFile: {Data: []byte(`console.log("admin")`)},
|
"js/main.js": `console.log("admin")`,
|
||||||
}
|
})
|
||||||
apiCalled := false
|
apiCalled := false
|
||||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||||
AdminPath: "/ops/",
|
AdminPath: "/ops/",
|
||||||
AdminAPIPrefix: "/ops/api",
|
AdminAPIPrefix: "/ops/api",
|
||||||
Assets: assets,
|
StaticDir: staticDir,
|
||||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
apiCalled = true
|
apiCalled = true
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -149,11 +150,8 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
|||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||||
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
|
t.Fatalf("admin page = %q, want static index", resp.Body.String())
|
||||||
}
|
|
||||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
|
||||||
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
@@ -163,6 +161,33 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
|||||||
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
|
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/ops/js/main.js", nil)
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `console.log("admin")`) {
|
||||||
|
t.Fatalf("js response status=%d body=%q", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("js Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/ops/js/", nil)
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("js directory status=%d, want %d", resp.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/ops/config.js", nil)
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
|
||||||
|
t.Fatalf("config response status=%d body=%q", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("config Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
||||||
handler.ServeHTTP(resp, req)
|
handler.ServeHTTP(resp, req)
|
||||||
@@ -182,13 +207,11 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
||||||
assets := fstest.MapFS{
|
staticDir := writeTestAdminStatic(t, map[string]string{"index.html": ""})
|
||||||
staticIndexFile: {Data: []byte(``)},
|
|
||||||
}
|
|
||||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||||
AdminPath: "/admin/",
|
AdminPath: "/admin/",
|
||||||
AdminAPIPrefix: "/admin/api",
|
AdminAPIPrefix: "/admin/api",
|
||||||
Assets: assets,
|
StaticDir: staticDir,
|
||||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
||||||
WebSocketEnabled: true,
|
WebSocketEnabled: true,
|
||||||
WebSocketPath: "/ws",
|
WebSocketPath: "/ws",
|
||||||
@@ -205,6 +228,21 @@ func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeTestAdminStatic(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, data := range files {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
|
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
26
package-lock.json
generated
Normal file
26
package-lock.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "mc-gateway",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build:admin": "tsc -p tsconfig.admin.json",
|
||||||
|
"check:admin": "tsc -p tsconfig.admin.json --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tsconfig.admin.json
Normal file
19
tsconfig.admin.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false,
|
||||||
|
"lib": ["DOM", "ES2022"],
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"outDir": "cmd/gateway/admin_static/js",
|
||||||
|
"removeComments": true,
|
||||||
|
"rootDir": "cmd/gateway/admin_frontend/src",
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022"
|
||||||
|
},
|
||||||
|
"include": ["cmd/gateway/admin_frontend/src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user