This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
.codex
|
||||
.omc
|
||||
.vscode
|
||||
node_modules
|
||||
cmd/gateway/admin_static/js
|
||||
|
||||
logs
|
||||
data
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
.vscode
|
||||
node_modules/
|
||||
cmd/gateway/admin_static/js/
|
||||
|
||||
config.toml
|
||||
/gateway*
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
ARG TARGETOS
|
||||
@@ -12,6 +24,7 @@ RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
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 \
|
||||
mkdir -p /out \
|
||||
@@ -22,8 +35,10 @@ FROM alpine:3.22
|
||||
WORKDIR /data
|
||||
|
||||
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_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
|
||||
|
||||
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_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
|
||||
| `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_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
|
||||
|
||||
@@ -56,9 +57,21 @@ MC_GATEWAY_ADMIN_PASSWORD=change-me
|
||||
MC_GATEWAY_TCP_ADMIN_PORT=25565
|
||||
MC_GATEWAY_ADMIN_PATH=/admin/
|
||||
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
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```text
|
||||
|
||||
@@ -122,8 +122,16 @@ func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
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"`) {
|
||||
t.Fatalf("custom admin page does not contain API prefix: %s", resp.Body.String())
|
||||
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
|
||||
"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 {
|
||||
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
|
||||
AdminPath: adminStartup.AdminPath,
|
||||
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
|
||||
Assets: adminStaticFS,
|
||||
APIHandler: newAdminAPIHandler(),
|
||||
WebSocketEnabled: config.WebSocket.Enable,
|
||||
WebSocketPath: normalizedWebSocketPath(),
|
||||
|
||||
@@ -126,6 +126,18 @@ label.inline input {
|
||||
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 {
|
||||
min-height: calc(100vh - 120px);
|
||||
display: grid;
|
||||
@@ -355,6 +367,10 @@ dialog h2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
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>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body data-api-prefix="__ADMIN_API_PREFIX__">
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
@@ -14,8 +14,15 @@
|
||||
<p id="subtitle">Admin</p>
|
||||
</div>
|
||||
<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>
|
||||
<button id="logoutBtn" class="ghost hidden" type="button">Logout</button>
|
||||
<button id="logoutBtn" class="ghost hidden" type="button" data-i18n="logout">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -23,31 +30,31 @@
|
||||
|
||||
<section id="setupView" class="auth-view hidden">
|
||||
<form id="setupForm" class="panel compact">
|
||||
<h2>Initial admin</h2>
|
||||
<h2 data-i18n="initialAdmin">Initial admin</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" value="admin">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="new-password" type="password" required>
|
||||
</label>
|
||||
<button type="submit">Create admin</button>
|
||||
<button type="submit" data-i18n="createAdmin">Create admin</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="loginView" class="auth-view hidden">
|
||||
<form id="loginForm" class="panel compact">
|
||||
<h2>Login</h2>
|
||||
<h2 data-i18n="login">Login</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="current-password" type="password" required>
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
<button type="submit" data-i18n="login">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -55,27 +62,27 @@
|
||||
<section id="statusGrid" class="status-grid"></section>
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="routes" class="active" type="button">Routes</button>
|
||||
<button data-tab="services" type="button">Services</button>
|
||||
<button data-tab="users" type="button">Users</button>
|
||||
<button data-tab="metrics" type="button">Metrics</button>
|
||||
<button data-tab="audit" type="button">Audit</button>
|
||||
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
|
||||
<button data-tab="services" type="button" data-i18n="services">Services</button>
|
||||
<button data-tab="users" type="button" data-i18n="users">Users</button>
|
||||
<button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
|
||||
<button data-tab="audit" type="button" data-i18n="audit">Audit</button>
|
||||
</nav>
|
||||
|
||||
<section id="routesTab" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<input id="routeSearch" placeholder="Search host, upstream, note">
|
||||
<button id="newRouteBtn" type="button">New route</button>
|
||||
<input id="routeSearch" placeholder="Search host, upstream, note" data-i18n-placeholder="routeSearch">
|
||||
<button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Upstream</th>
|
||||
<th>Enabled</th>
|
||||
<th>Note</th>
|
||||
<th class="actions">Actions</th>
|
||||
<th data-i18n="host">Host</th>
|
||||
<th data-i18n="upstream">Upstream</th>
|
||||
<th data-i18n="enabled">Enabled</th>
|
||||
<th data-i18n="note">Note</th>
|
||||
<th class="actions" data-i18n="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="routesBody"></tbody>
|
||||
@@ -89,16 +96,16 @@
|
||||
|
||||
<section id="usersTab" class="tab-panel hidden">
|
||||
<div class="toolbar">
|
||||
<button id="newUserBtn" type="button">New user</button>
|
||||
<button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Disabled</th>
|
||||
<th class="actions">Actions</th>
|
||||
<th data-i18n="username">Username</th>
|
||||
<th data-i18n="role">Role</th>
|
||||
<th data-i18n="disabled">Disabled</th>
|
||||
<th class="actions" data-i18n="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
@@ -109,7 +116,7 @@
|
||||
<section id="metricsTab" class="tab-panel hidden">
|
||||
<div id="metricsGrid" class="status-grid"></div>
|
||||
<div class="panel">
|
||||
<h2>Route hits</h2>
|
||||
<h2 data-i18n="routeHits">Route hits</h2>
|
||||
<div id="routeHits" class="chips"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -119,12 +126,12 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Result</th>
|
||||
<th>Message</th>
|
||||
<th data-i18n="time">Time</th>
|
||||
<th data-i18n="actor">Actor</th>
|
||||
<th data-i18n="action">Action</th>
|
||||
<th data-i18n="target">Target</th>
|
||||
<th data-i18n="result">Result</th>
|
||||
<th data-i18n="message">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditBody"></tbody>
|
||||
@@ -136,60 +143,61 @@
|
||||
|
||||
<dialog id="routeDialog">
|
||||
<form id="routeForm" method="dialog">
|
||||
<h2>Route</h2>
|
||||
<h2 data-i18n="route">Route</h2>
|
||||
<label>
|
||||
Host
|
||||
<span data-i18n="host">Host</span>
|
||||
<input name="host" required>
|
||||
</label>
|
||||
<label>
|
||||
Upstream
|
||||
<span data-i18n="upstream">Upstream</span>
|
||||
<input name="upstream" required placeholder="127.0.0.1:25565">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input name="enabled" type="checkbox" checked>
|
||||
Enabled
|
||||
<span data-i18n="enabled">Enabled</span>
|
||||
</label>
|
||||
<label>
|
||||
Note
|
||||
<span data-i18n="note">Note</span>
|
||||
<input name="note">
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||
<button type="submit" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="userDialog">
|
||||
<form id="userForm" method="dialog">
|
||||
<h2>User</h2>
|
||||
<h2 data-i18n="user">User</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Role
|
||||
<span data-i18n="role">Role</span>
|
||||
<select name="role">
|
||||
<option value="admin">Admin</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="guest">Guest</option>
|
||||
<option value="admin" data-i18n="roleAdmin">Admin</option>
|
||||
<option value="member" data-i18n="roleMember">Member</option>
|
||||
<option value="guest" data-i18n="roleGuest">Guest</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input name="disabled" type="checkbox">
|
||||
Disabled
|
||||
<span data-i18n="disabled">Disabled</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||
<button type="submit" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -101,8 +101,12 @@ func TestParseStartupConfigReturnsErrors(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api prefix under asset path",
|
||||
env: map[string]string{adminEnvAPIPrefix: "/admin/app.js/api"},
|
||||
name: "api prefix under js asset path",
|
||||
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_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
|
||||
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_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
|
||||
volumes:
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
|
||||
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
|
||||
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
|
||||
| Admin 静态目录 | `cmd/gateway/admin_static` | `MC_GATEWAY_ADMIN_STATIC_DIR` |
|
||||
| KCP | 默认禁用 | 后台配置 |
|
||||
| QUIC | 默认禁用 | 后台配置 |
|
||||
| WebSocket | 默认禁用 | 后台配置 |
|
||||
@@ -67,7 +68,8 @@
|
||||
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`。
|
||||
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`。
|
||||
- `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`。
|
||||
|
||||
启动流程:
|
||||
@@ -130,7 +132,7 @@ Accept
|
||||
|
||||
## 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_db.go
|
||||
cmd/gateway/admin_static.go
|
||||
cmd/gateway/admin_frontend/src/
|
||||
cmd/gateway/admin_static/
|
||||
index.html
|
||||
app.css
|
||||
app.js
|
||||
js/
|
||||
```
|
||||
|
||||
API 路由:
|
||||
@@ -152,7 +155,8 @@ API 路由:
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
|
||||
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
|
||||
| GET | `/admin/app.js` | 公开页面 | 页面脚本 |
|
||||
| GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
|
||||
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
|
||||
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
|
||||
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
|
||||
| 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")
|
||||
}
|
||||
|
||||
for _, asset := range []string{"app.css", "app.js"} {
|
||||
for _, asset := range []string{"app.css", "config.js", "js"} {
|
||||
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
|
||||
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
|
||||
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",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/app.js/api"},
|
||||
name: "api prefix under js asset path",
|
||||
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
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
staticIndexFile = "admin_static/index.html"
|
||||
staticCSSFile = "admin_static/app.css"
|
||||
staticJSFile = "admin_static/app.js"
|
||||
defaultStaticDir = "cmd/gateway/admin_static"
|
||||
staticConfigFile = "config.js"
|
||||
staticIndexFile = "index.html"
|
||||
)
|
||||
|
||||
type GatewayHandlerOptions struct {
|
||||
AdminPath string
|
||||
AdminAPIPrefix string
|
||||
Assets fs.FS
|
||||
StaticDir string
|
||||
APIHandler http.HandlerFunc
|
||||
WebSocketEnabled bool
|
||||
WebSocketPath string
|
||||
@@ -68,40 +71,67 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandle
|
||||
}
|
||||
|
||||
if r.URL.Path == opts.AdminPath {
|
||||
serveAdminIndex(w, opts)
|
||||
serveAdminFile(w, r, opts, staticIndexFile)
|
||||
return
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
||||
switch rel {
|
||||
case "app.css":
|
||||
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())
|
||||
if rel == staticConfigFile {
|
||||
serveAdminConfig(w, opts)
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
serveAdminFile(w, r, opts, rel)
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
|
||||
data, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
func serveAdminConfig(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
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)
|
||||
return
|
||||
}
|
||||
file := filepath.Join(staticDir(opts), name)
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDecodeJSONRequest(t *testing.T) {
|
||||
@@ -117,16 +118,16 @@ func TestRequestSourceIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
|
||||
staticCSSFile: {Data: []byte(`body{color:red}`)},
|
||||
staticJSFile: {Data: []byte(`console.log("admin")`)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{
|
||||
"index.html": "<html><script src=\"config.js\"></script></html>",
|
||||
"app.css": "body{color:red}",
|
||||
"js/main.js": `console.log("admin")`,
|
||||
})
|
||||
apiCalled := false
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/ops/",
|
||||
AdminAPIPrefix: "/ops/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
apiCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -149,11 +150,8 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
||||
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
|
||||
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||
t.Fatalf("admin page = %q, want static index", resp.Body.String())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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()
|
||||
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
@@ -182,13 +207,11 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(``)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{"index.html": ""})
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/admin/",
|
||||
AdminAPIPrefix: "/admin/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
||||
WebSocketEnabled: true,
|
||||
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) {
|
||||
tests := []struct {
|
||||
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