From b822993489a477d7ae1c9bbb00ccea176168dfd1 Mon Sep 17 00:00:00 2001 From: tursom Date: Fri, 26 Jun 2026 11:38:19 +0800 Subject: [PATCH] feat(plugin): add admin plugin management UI --- cmd/gateway/admin_api.go | 3 + cmd/gateway/admin_api_test.go | 150 ++++ cmd/gateway/admin_frontend/src/api.ts | 5 +- cmd/gateway/admin_frontend/src/i18n.ts | 28 + cmd/gateway/admin_frontend/src/main.ts | 7 + cmd/gateway/admin_frontend/src/state.ts | 12 +- cmd/gateway/admin_frontend/src/types.d.ts | 55 -- cmd/gateway/admin_frontend/src/types.ts | 177 +++++ .../admin_frontend/src/views/plugins.ts | 652 ++++++++++++++++ cmd/gateway/admin_plugin_handlers.go | 463 +++++++++++- cmd/gateway/admin_static/app.css | 151 +++- cmd/gateway/admin_static/index.html | 33 + cmd/gateway/main_test.go | 13 +- internal/admindb/db.go | 27 +- internal/adminhttp/api.go | 19 + internal/adminhttp/api_test.go | 7 + internal/adminhttp/requests.go | 22 + internal/adminuser/user.go | 2 + internal/adminuser/user_test.go | 4 + internal/pluginmanager/artifact.go | 15 +- internal/pluginmanager/manager.go | 711 +++++++++++++++++- internal/pluginmanager/manager_test.go | 144 +++- internal/pluginmanager/repository.go | 150 ++++ internal/pluginmanager/types.go | 72 ++ 24 files changed, 2834 insertions(+), 88 deletions(-) delete mode 100644 cmd/gateway/admin_frontend/src/types.d.ts create mode 100644 cmd/gateway/admin_frontend/src/types.ts create mode 100644 cmd/gateway/admin_frontend/src/views/plugins.ts diff --git a/cmd/gateway/admin_api.go b/cmd/gateway/admin_api.go index e2b035a..416e645 100644 --- a/cmd/gateway/admin_api.go +++ b/cmd/gateway/admin_api.go @@ -38,6 +38,9 @@ func newAdminAPIHandler() http.HandlerFunc { PluginsList: handleAdminPluginsList, PluginItem: handleAdminPluginItem, PluginAction: handleAdminPluginAction, + PluginConfig: handleAdminPluginConfig, + PluginSecrets: handleAdminPluginSecrets, + PluginRollback: handleAdminPluginRollback, PluginDraining: handleAdminPluginDraining, PluginDispatch: handleAdminPluginDispatchPlan, }) diff --git a/cmd/gateway/admin_api_test.go b/cmd/gateway/admin_api_test.go index 0b3c8b3..e1299d5 100644 --- a/cmd/gateway/admin_api_test.go +++ b/cmd/gateway/admin_api_test.go @@ -2,12 +2,16 @@ package main import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" + "strconv" "strings" "testing" + + "github.com/tursom/mc-gateway/internal/pluginmanager" ) func TestAdminSetupLoginAndPermissions(t *testing.T) { @@ -215,6 +219,134 @@ func TestAdminUserPatchInvalidatesExistingSession(t *testing.T) { } } +func TestAdminPluginPhase4API(t *testing.T) { + handler := newAdminTestHandlerWithAdmin(t) + pluginsManager = pluginmanager.New(pluginmanager.Options{ + DB: adminDB, + ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"), + Adapter: gatewayTestPluginAdapter{}, + }) + adminToken := adminTestLogin(t, handler, "admin", "secret") + resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{ + "username": "member", + "role": "member", + "password": "member-secret", + }) + if resp.Code != http.StatusCreated { + t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String()) + } + memberToken := adminTestLogin(t, handler, "member", "member-secret") + + artifact := uploadGatewayPhase4Artifact(t, "phase4-plugin") + if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase4-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{"token":"old","host":"a"}`, 10); err != nil { + t.Fatalf("SetDesired() error = %v", err) + } + + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins", memberToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("member plugins list status = %d, body=%s", resp.Code, resp.Body.String()) + } + + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", memberToken, map[string]any{ + "name": "api_token", + "value": "member-secret-value", + }) + if resp.Code != http.StatusForbidden { + t.Fatalf("member secret write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String()) + } + + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "name": "api_token", + "value": "super-secret-value", + "reload_required": true, + }) + if resp.Code != http.StatusOK { + t.Fatalf("secret write status = %d, body=%s", resp.Code, resp.Body.String()) + } + if strings.Contains(resp.Body.String(), "super-secret-value") { + t.Fatalf("secret response leaked value: %s", resp.Body.String()) + } + + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/config/dry-run", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "config_json": `{"token":"new","host":"b"}`, + }) + if resp.Code != http.StatusOK { + t.Fatalf("config dry-run status = %d, body=%s", resp.Code, resp.Body.String()) + } + if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") { + t.Fatalf("dry-run leaked sensitive value: %s", resp.Body.String()) + } + + resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/plugins/phase4-plugin/config", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "config_json": `{"token":"new","host":"b"}`, + }) + if resp.Code != http.StatusOK { + t.Fatalf("config update status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots", adminToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("snapshots status = %d, body=%s", resp.Code, resp.Body.String()) + } + snapshotID := firstSnapshotID(t, resp) + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots/"+strconv.FormatInt(snapshotID, 10)+"/diff", adminToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("snapshot diff status = %d, body=%s", resp.Code, resp.Body.String()) + } + if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") { + t.Fatalf("snapshot diff leaked sensitive value: %s", resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/rollback/config", adminToken, map[string]any{ + "snapshot_id": snapshotID, + }) + if resp.Code != http.StatusOK { + t.Fatalf("config rollback status = %d, body=%s", resp.Code, resp.Body.String()) + } + + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String()) + } + if strings.Contains(resp.Body.String(), "super-secret-value") { + t.Fatalf("audit leaked secret value: %s", resp.Body.String()) + } +} + +func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord { + t.Helper() + var manifest pluginmanager.Manifest + if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil { + t.Fatalf("Unmarshal manifest error = %v", err) + } + manifest.ConfigSchema = json.RawMessage(`{"type":"object","properties":{"token":{"type":"string","sensitive":true},"host":{"type":"string"}}}`) + manifest.Secrets = []pluginmanager.SecretSpec{{ + Name: "api_token", + Required: false, + Type: "api_token", + Rotation: pluginmanager.SecretRotation{ + Reload: "reload_required", + }, + }} + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("Marshal manifest error = %v", err) + } + artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{ + SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{ + "manifest.json": manifestBytes, + "plugin.so": []byte("fake plugin bytes " + pluginID), + }), + FileName: pluginID + ".mcgp", + Actor: "admin", + }) + if err != nil { + t.Fatalf("UploadArtifact() error = %v", err) + } + return artifact +} + func newAdminTestHandler(t *testing.T) http.Handler { t.Helper() t.Cleanup(saveGatewayState(t)) @@ -287,3 +419,21 @@ func adminTestJSON(t *testing.T, resp *httptest.ResponseRecorder) map[string]any } return body } + +func firstSnapshotID(t *testing.T, resp *httptest.ResponseRecorder) int64 { + t.Helper() + body := adminTestJSON(t, resp) + snapshots, ok := body["snapshots"].([]any) + if !ok || len(snapshots) == 0 { + t.Fatalf("snapshots = %#v, want at least one", body["snapshots"]) + } + first, ok := snapshots[0].(map[string]any) + if !ok { + t.Fatalf("first snapshot = %#v", snapshots[0]) + } + id, ok := first["id"].(float64) + if !ok || id <= 0 { + t.Fatalf("snapshot id = %#v", first["id"]) + } + return int64(id) +} diff --git a/cmd/gateway/admin_frontend/src/api.ts b/cmd/gateway/admin_frontend/src/api.ts index 9ad3fa9..d189e2c 100644 --- a/cmd/gateway/admin_frontend/src/api.ts +++ b/cmd/gateway/admin_frontend/src/api.ts @@ -3,11 +3,12 @@ import { state } from "./state.js"; interface APIOptions { method?: string; body?: unknown; + formData?: FormData; } export async function api(path: string, options: APIOptions = {}): Promise { const headers: Record = { Accept: "application/json" }; - if (options.body !== undefined) { + if (options.body !== undefined && !options.formData) { headers["Content-Type"] = "application/json"; } if (state.token) { @@ -17,7 +18,7 @@ export async function api(path: string, options: APIOptions = {}): Promise const res = await fetch(state.apiBase + path, { method: options.method || "GET", headers, - body: options.body === undefined ? undefined : JSON.stringify(options.body), + body: options.formData || (options.body === undefined ? undefined : JSON.stringify(options.body)), }); let data: unknown = {}; diff --git a/cmd/gateway/admin_frontend/src/i18n.ts b/cmd/gateway/admin_frontend/src/i18n.ts index 94affa6..0a3a94f 100644 --- a/cmd/gateway/admin_frontend/src/i18n.ts +++ b/cmd/gateway/admin_frontend/src/i18n.ts @@ -9,7 +9,9 @@ const translations = { activeUser: "Active", actions: "Actions", actor: "Actor", + active: "Active", adminSubtitle: "Admin", + artifact: "Artifact", audit: "Audit", cancel: "Cancel", createAdmin: "Create admin", @@ -17,11 +19,15 @@ const translations = { delete: "Delete", deleteDefaultRouteConfirm: "Delete default route?", deleteUserConfirm: "Delete user {username}?", + desired: "Desired", + desiredArtifact: "Desired artifact", dialErrors: "Dial errors", disabled: "Disabled", edit: "Edit", enabled: "Enabled", + extensions: "Extensions", failed: "Failed", + health: "Health", host: "Host", initialAdmin: "Initial admin", language: "Language", @@ -32,6 +38,7 @@ const translations = { message: "Message", metrics: "Metrics", misses: "Misses", + name: "Name", newRoute: "New route", newUser: "New user", noHits: "No hits", @@ -41,6 +48,10 @@ const translations = { path: "Path", port: "Port", protocols: "Protocols", + pluginID: "Plugin ID", + plugins: "Plugins", + priority: "Priority", + refresh: "Refresh", restart: "Restart", restartRequired: "restart required", result: "Result", @@ -52,6 +63,7 @@ const translations = { routeHits: "Route hits", routeSearch: "Search host, upstream, note", routes: "Routes", + runtime: "Runtime", save: "Save", services: "Services", setupSubtitle: "Setup", @@ -60,17 +72,21 @@ const translations = { time: "Time", total: "Total", upstream: "Upstream", + uploadPlugin: "Upload plugin", uptime: "Uptime", user: "User", username: "Username", users: "Users", + version: "Version", }, zh: { activeConnections: "活跃连接", activeUser: "启用", actions: "操作", actor: "操作者", + active: "当前", adminSubtitle: "管理后台", + artifact: "Artifact", audit: "审计", cancel: "取消", createAdmin: "创建管理员", @@ -78,11 +94,15 @@ const translations = { delete: "删除", deleteDefaultRouteConfirm: "确认删除默认路由?", deleteUserConfirm: "确认删除用户 {username}?", + desired: "期望", + desiredArtifact: "期望 Artifact", dialErrors: "连接上游失败", disabled: "禁用", edit: "编辑", enabled: "启用", + extensions: "扩展点", failed: "失败", + health: "健康", host: "主机", initialAdmin: "初始化管理员", language: "语言", @@ -93,6 +113,7 @@ const translations = { message: "消息", metrics: "指标", misses: "未命中", + name: "名称", newRoute: "新建路由", newUser: "新建用户", noHits: "暂无命中", @@ -102,6 +123,10 @@ const translations = { path: "路径", port: "端口", protocols: "协议", + pluginID: "插件 ID", + plugins: "插件", + priority: "优先级", + refresh: "刷新", restart: "重启", restartRequired: "需要重启", result: "结果", @@ -113,6 +138,7 @@ const translations = { routeHits: "路由命中", routeSearch: "搜索主机、上游、备注", routes: "路由", + runtime: "运行时", save: "保存", services: "服务", setupSubtitle: "初始化", @@ -121,10 +147,12 @@ const translations = { time: "时间", total: "总数", upstream: "上游", + uploadPlugin: "上传插件", uptime: "运行时间", user: "用户", username: "用户名", users: "用户", + version: "版本", }, } as const; diff --git a/cmd/gateway/admin_frontend/src/main.ts b/cmd/gateway/admin_frontend/src/main.ts index 6d6238b..9c55353 100644 --- a/cmd/gateway/admin_frontend/src/main.ts +++ b/cmd/gateway/admin_frontend/src/main.ts @@ -8,6 +8,7 @@ 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 { bindPluginEvents, loadPlugins, renderPluginDetail, renderPlugins } from "./views/plugins.js"; import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js"; import { loadServices, renderServices } from "./views/services.js"; import { loadStatus } from "./views/status.js"; @@ -56,6 +57,7 @@ function bindEvents(): void { el("newUserBtn").addEventListener("click", () => openUserDialog()); el("routeForm").addEventListener("submit", saveRoute); el("userForm").addEventListener("submit", saveUser); + bindPluginEvents(); document.querySelectorAll("[data-close]").forEach((button) => { button.addEventListener("click", () => button.closest("dialog")?.close()); @@ -132,6 +134,7 @@ async function showApp(): Promise { await loadStatus(); await loadServices(); await loadMetrics(); + await loadPlugins(); } if (isAdmin()) { await loadUsers(); @@ -145,6 +148,7 @@ function applyRoleVisibility(): void { el("statusGrid").classList.toggle("hidden", !member); el("newRouteBtn").classList.toggle("hidden", !member); toggleTab("services", member); + toggleTab("plugins", member); toggleTab("metrics", member); toggleTab("users", admin); toggleTab("audit", admin); @@ -176,9 +180,12 @@ function rerenderCurrentView(): void { renderRoutes(); renderServices(); renderUsers(); + renderPlugins(); + renderPluginDetail(); if (isMember()) { loadStatus(); loadMetrics(); + loadPlugins(); } if (isAdmin()) { loadAudit(); diff --git a/cmd/gateway/admin_frontend/src/state.ts b/cmd/gateway/admin_frontend/src/state.ts index 72f256c..b355219 100644 --- a/cmd/gateway/admin_frontend/src/state.ts +++ b/cmd/gateway/admin_frontend/src/state.ts @@ -1,4 +1,4 @@ -import type { RouteRecord, ServiceRecord, User } from "./types.js"; +import type { PluginArtifact, PluginBuild, PluginView, RouteRecord, ServiceRecord, User } from "./types.js"; export const tokenStorageKey = "mcGatewayAdminToken"; export const languageStorageKey = "mcGatewayAdminLanguage"; @@ -11,6 +11,11 @@ export interface AppState { routes: RouteRecord[]; services: ServiceRecord[]; users: User[]; + plugins: PluginView[]; + pluginArtifacts: PluginArtifact[]; + pluginBuilds: PluginBuild[]; + selectedPluginID: string; + selectedArtifactID: string; } export const state: AppState = { @@ -21,6 +26,11 @@ export const state: AppState = { routes: [], services: [], users: [], + plugins: [], + pluginArtifacts: [], + pluginBuilds: [], + selectedPluginID: "", + selectedArtifactID: "", }; export function setToken(token: string): void { diff --git a/cmd/gateway/admin_frontend/src/types.d.ts b/cmd/gateway/admin_frontend/src/types.d.ts deleted file mode 100644 index 57c1fe2..0000000 --- a/cmd/gateway/admin_frontend/src/types.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -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; - 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; -} - -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; -} diff --git a/cmd/gateway/admin_frontend/src/types.ts b/cmd/gateway/admin_frontend/src/types.ts new file mode 100644 index 0000000..c3409d8 --- /dev/null +++ b/cmd/gateway/admin_frontend/src/types.ts @@ -0,0 +1,177 @@ +export type Role = "admin" | "member" | "guest"; + +export interface User { + username: string; + role: Role; + disabled: boolean; + permissions?: Record; +} + +export interface RouteRecord { + host: string; + upstream: string; + enabled: boolean; + note?: string; +} + +export interface ServiceRecord { + name: string; + enabled: boolean; + port: number; + options?: Record; + 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; +} + +export interface AuditLog { + created_at: number; + actor: string; + action: string; + target_type: string; + target_id: string; + success: boolean; + message?: string; +} + +export interface PluginArtifact { + id: string; + plugin_id: string; + version: string; + file_name?: string; + sha256: string; + package_sha256?: string; + artifact_type: string; + runtime_type: string; + status: string; + metadata_json?: string; + capabilities_summary_json?: string; + extension_points_json?: string; + api_version?: string; + go_version?: string; + go_os?: string; + go_arch?: string; + uploaded_by?: string; + error?: string; + created_at?: number; + updated_at?: number; +} + +export interface PluginBuild { + id: number; + plugin_id: string; + source_id: string; + artifact_id: string; + status: string; + builder_type: string; + go_version?: string; + go_os?: string; + go_arch?: string; + source_sha256?: string; + artifact_sha256?: string; + log_summary?: string; + error?: string; + duration_ms?: number; + created_at?: number; + updated_at?: number; +} + +export interface PluginSecret { + plugin_id: string; + name: string; + current_version: number; + previous_version: number; + reload_required: boolean; + hot_reload: boolean; + updated_by?: string; + updated_at?: number; +} + +export interface PluginSnapshot { + id: number; + plugin_id: string; + artifact_id: string; + config_json: string; + desired_state: string; + priority: number; + desired_generation: number; + created_by?: string; + created_at?: number; +} + +export interface PluginProxyConnection { + id: number; + plugin_id: string; + artifact_id: string; + handler_id: string; + started_at: number; + duration_ms: number; + draining: boolean; +} + +export interface PluginView { + id: string; + name?: string; + version?: string; + artifact_type?: string; + runtime_type?: string; + desired_state: string; + runtime_state: string; + desired_artifact_id: string; + active_artifact_id: string; + loaded_artifact_id: string; + desired_artifact?: PluginArtifact; + active_artifact?: PluginArtifact; + loaded_artifact?: PluginArtifact; + extension_points?: string[]; + priority: number; + scope?: unknown; + rollout?: unknown; + restart_required?: boolean; + health?: string; + last_error?: string; + runtime_summary?: Record; + dispatch_summary?: unknown[]; + capabilities_summary?: Record; + minecraft?: unknown; + config_json?: string; + config_schema?: Record; + secrets?: PluginSecret[]; + snapshots?: PluginSnapshot[]; + builds?: PluginBuild[]; + artifacts?: PluginArtifact[]; + manifest?: Record; + active_proxy_connections?: number; + proxy_connections?: PluginProxyConnection[]; + updated_at?: number; +} + +export interface PluginDryRunResult { + ok: boolean; + restart_required: boolean; + hot_reload: boolean; + sensitive_paths: string[]; + redacted_config_json: string; + redacted_diff_json: string; +} + +export interface SetupStatus { + required: boolean; +} + +export interface LoginResponse { + token: string; + user: User; +} + +export interface RuntimeConfig { + apiPrefix: string; +} diff --git a/cmd/gateway/admin_frontend/src/views/plugins.ts b/cmd/gateway/admin_frontend/src/views/plugins.ts new file mode 100644 index 0000000..fbe11b9 --- /dev/null +++ b/cmd/gateway/admin_frontend/src/views/plugins.ts @@ -0,0 +1,652 @@ +import { api } from "../api.js"; +import { showAlert } from "../alerts.js"; +import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js"; +import { isAdmin } from "../session.js"; +import { state } from "../state.js"; +import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginProxyConnection, PluginSecret, PluginSnapshot, PluginView } from "../types.js"; + +interface PluginsResponse { + plugins?: PluginView[]; +} + +interface ArtifactsResponse { + artifacts?: PluginArtifact[]; +} + +interface BuildsResponse { + builds?: PluginBuild[]; +} + +interface PluginResponse { + plugin?: PluginView; +} + +interface DryRunResponse { + result?: PluginDryRunResult; +} + +interface SnapshotDiffResponse { + diff?: { + redacted_diff_json?: string; + sensitive_paths?: string[]; + restart_required?: boolean; + }; +} + +export async function loadPlugins(): Promise { + try { + const [data, artifacts, builds] = await Promise.all([ + api("/plugins"), + api("/plugin-artifacts"), + api("/plugin-builds"), + ]); + state.plugins = data.plugins || []; + state.pluginArtifacts = artifacts.artifacts || []; + state.pluginBuilds = builds.builds || []; + const firstPlugin = state.plugins[0]; + if (!state.selectedPluginID && firstPlugin) { + state.selectedPluginID = firstPlugin.id; + } + renderPlugins(); + if (state.selectedPluginID) { + await loadPluginDetail(state.selectedPluginID); + } else { + renderPluginDetail(null); + } + } catch (err) { + showAlert((err as Error).message); + } +} + +export function renderPlugins(): void { + const managed = new Set(state.plugins.map((plugin) => plugin.id)); + const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id)); + el("pluginsBody").innerHTML = state.plugins.map((plugin) => ` + + + ${escapeHTML(plugin.name || "")} + ${escapeHTML(plugin.version || "")} + ${escapeHTML(plugin.artifact_type || "")} + ${escapeHTML(plugin.runtime_type || "")} + ${escapeHTML(plugin.desired_state)} + ${badge(plugin.runtime_state, plugin.runtime_state !== "enabled")} + ${escapeHTML(shortID(plugin.desired_artifact_id))} + ${escapeHTML((plugin.extension_points || []).join(", "))} + ${escapeHTML(String(plugin.priority))} + ${badge(plugin.restart_required ? "yes" : "no", !plugin.restart_required)} + ${badge(plugin.health || "", plugin.health !== "healthy")} + + `).concat(unmanagedArtifacts.map((artifact) => ` + + + ${escapeHTML(artifact.file_name || "")} + ${escapeHTML(artifact.version || "")} + ${escapeHTML(artifact.artifact_type)} + ${escapeHTML(artifact.runtime_type || "")} + ${escapeHTML(artifact.status)} + ${badge("not managed", true)} + ${escapeHTML(shortID(artifact.id))} + ${escapeHTML(jsonList(artifact.extension_points_json))} + + ${badge("no", false)} + ${badge(artifact.error ? "error" : "pending", Boolean(artifact.error))} + + `)).join(""); + document.querySelectorAll("[data-plugin-detail]").forEach((button) => { + button.addEventListener("click", () => { + const id = button.dataset.pluginDetail; + if (id) { + state.selectedPluginID = id; + state.selectedArtifactID = ""; + loadPluginDetail(id); + } + }); + }); + document.querySelectorAll("[data-artifact-detail]").forEach((button) => { + button.addEventListener("click", () => { + state.selectedArtifactID = button.dataset.artifactDetail || ""; + state.selectedPluginID = ""; + renderPlugins(); + renderPluginDetail(null); + }); + }); +} + +export async function loadPluginDetail(pluginID: string): Promise { + try { + const data = await api(`/plugins/${encodeURIComponent(pluginID)}`); + if (data.plugin) { + state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin); + if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) { + state.plugins.push(data.plugin); + } + state.selectedPluginID = data.plugin.id; + renderPlugins(); + renderPluginDetail(data.plugin); + } + } catch (err) { + showAlert((err as Error).message); + } +} + +export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void { + const detail = el("pluginDetail"); + if (!plugin) { + detail.innerHTML = uploadInventoryDetail(); + bindInventoryEvents(); + return; + } + const canWrite = isAdmin(); + detail.innerHTML = ` +
+
+

${escapeHTML(plugin.name || plugin.id)}

+

${escapeHTML(plugin.id)} · ${escapeHTML(plugin.version || "")}

+
+
${canWrite ? pluginActionButtons(plugin) : ""}
+
+
+ ${detailStat("Runtime", plugin.runtime_state)} + ${detailStat("Desired", plugin.desired_state)} + ${detailStat("Active", shortID(plugin.active_artifact_id))} + ${detailStat("Desired artifact", shortID(plugin.desired_artifact_id))} + ${detailStat("Loaded", shortID(plugin.loaded_artifact_id))} + ${detailStat("Restart", plugin.restart_required ? "required" : "not required")} + ${detailStat("Health", plugin.health || "")} + ${detailStat("Active proxy", plugin.active_proxy_connections || 0)} +
+ ${plugin.last_error ? `
${escapeHTML(plugin.last_error)}
` : ""} +
+
+

Manifest

+
+
Runtime
${escapeHTML(plugin.runtime_type || "")}
+
Extensions
${escapeHTML((plugin.extension_points || []).join(", "))}
+
Scope
${escapeHTML(formatJSON(plugin.scope))}
+
Rollout
${escapeHTML(formatJSON(plugin.rollout))}
+
Minecraft
${escapeHTML(formatJSON(plugin.minecraft))}
+
+
+
+

Config

+ +
${canWrite ? ` + + + ` : ""}
+

+      
+
+

Secrets

+
${(plugin.secrets || []).map(secretChip).join("") || `No secrets`}
+ ${canWrite ? ` +
+ + + + + +
+ ` : ""} +
+
+

Artifacts

+ ${artifactList(plugin.artifacts || [], plugin)} +
+
+

Builds

+ ${buildList(plugin.builds || [], plugin, canWrite)} +
+
+

Snapshots

+ ${snapshotList(plugin.snapshots || [], canWrite)} +
+
+

Dispatch plan

+
${escapeHTML(formatJSON(plugin.dispatch_summary || []))}
+
+
+

Active proxy connections

+ ${proxyConnectionList(plugin.proxy_connections || [])} +
+
+ `; + bindPluginDetailEvents(plugin); +} + +export function bindPluginEvents(): void { + el("pluginUploadInput").addEventListener("change", uploadPluginPackage); + el("refreshPluginsBtn").addEventListener("click", loadPlugins); +} + +async function uploadPluginPackage(event: Event): Promise { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + if (!file) { + return; + } + const formData = new FormData(); + formData.set("artifact", file); + try { + await api("/plugin-artifacts", { method: "POST", formData }); + input.value = ""; + await loadPlugins(); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +function bindPluginDetailEvents(plugin: PluginView): void { + document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin)); + document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin)); + const secretForm = document.getElementById("pluginSecretForm"); + if (secretForm instanceof HTMLFormElement) { + secretForm.addEventListener("submit", (event) => saveSecret(event, plugin)); + } + document.querySelectorAll("[data-plugin-action]").forEach((button) => { + button.addEventListener("click", () => runPluginAction(plugin.id, button.dataset.pluginAction || "")); + }); + document.querySelectorAll("[data-artifact-rollback]").forEach((button) => { + button.addEventListener("click", () => rollbackArtifact(plugin.id, button.dataset.artifactRollback || "")); + }); + document.querySelectorAll("[data-build-action]").forEach((button) => { + button.addEventListener("click", () => runBuildAction(plugin.id, Number(button.dataset.buildId || "0"), button.dataset.buildAction || "")); + }); + document.querySelectorAll("[data-snapshot-rollback]").forEach((button) => { + button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotRollback || "0"), false)); + }); + document.querySelectorAll("[data-snapshot-full-rollback]").forEach((button) => { + button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotFullRollback || "0"), true)); + }); + document.querySelectorAll("[data-snapshot-diff]").forEach((button) => { + button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0"))); + }); +} + +async function dryRunConfig(plugin: PluginView): Promise { + try { + const data = await api(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, { + method: "POST", + body: { + artifact_id: plugin.desired_artifact_id, + config_json: configEditorValue(), + }, + }); + el("pluginDryRunResult").textContent = formatJSON(data.result || {}); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function saveConfig(plugin: PluginView): Promise { + try { + await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, { + method: "PUT", + body: { + artifact_id: plugin.desired_artifact_id, + desired_state: plugin.desired_state, + priority: plugin.priority, + config_json: configEditorValue(), + }, + }); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + try { + await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, { + method: "POST", + body: { + name: getFormInput(form, "name").value, + artifact_id: plugin.desired_artifact_id, + value: getFormInput(form, "value").value, + reload_required: getFormInput(form, "reload_required").checked, + hot_reload: getFormInput(form, "hot_reload").checked, + }, + }); + form.reset(); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function runPluginAction(pluginID: string, action: string): Promise { + if (!action) { + return; + } + try { + await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} }); + if (action === "delete") { + state.selectedPluginID = ""; + await loadPlugins(); + } else { + await loadPluginDetail(pluginID); + } + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function createDesiredFromArtifact(artifact: PluginArtifact): Promise { + try { + await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, { + method: "PUT", + body: { + artifact_id: artifact.id, + desired_state: "disabled", + priority: Number(getFormInput(el("artifactDesiredForm"), "priority").value || "100"), + config_json: el("artifactConfigEditor").value || "{}", + }, + }); + state.selectedArtifactID = ""; + state.selectedPluginID = artifact.plugin_id; + await loadPlugins(); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function rollbackArtifact(pluginID: string, artifactID: string): Promise { + try { + await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, { + method: "POST", + body: { artifact_id: artifactID }, + }); + await loadPluginDetail(pluginID); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function runBuildAction(pluginID: string, buildID: number, action: string): Promise { + if (!buildID || !action) { + return; + } + try { + await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} }); + await loadPluginDetail(pluginID); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesired: boolean): Promise { + if (!snapshotID) { + return; + } + try { + await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, { + method: "POST", + body: { snapshot_id: snapshotID, full_desired: fullDesired }, + }); + await loadPluginDetail(pluginID); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise { + if (!snapshotID) { + return; + } + try { + const data = await api(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`); + el("pluginDryRunResult").textContent = formatJSON(data.diff || {}); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +function uploadInventoryDetail(): string { + const artifact = selectedArtifact(); + if (!artifact) { + return ` +
+
+

Uploaded artifacts

+ ${artifactInventoryList(state.pluginArtifacts)} +
+
+

Builds

+ ${buildInventoryList(state.pluginBuilds)} +
+
+ `; + } + const canWrite = isAdmin() && artifact.artifact_type === "binary"; + return ` +
+
+

${escapeHTML(artifact.plugin_id)}

+

${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(shortID(artifact.id))}

+
+
+
+
+

Artifact

+
+
Status
${escapeHTML(artifact.status)}
+
Runtime
${escapeHTML(artifact.runtime_type || "")}
+
Go/API
${escapeHTML(`${artifact.go_version || ""} ${artifact.api_version || ""}`)}
+
SHA256
${escapeHTML(artifact.sha256)}
+
Extensions
${escapeHTML(jsonList(artifact.extension_points_json))}
+
Error
${escapeHTML(artifact.error || "")}
+
+
+
+

Create desired state

+ ${canWrite ? ` +
+ + + +
+ ` : `
Only binary artifacts can be used as plugin desired state.
`} +
+
+

Builds

+ ${buildInventoryList(state.pluginBuilds.filter((build) => build.plugin_id === artifact.plugin_id))} +
+
+ `; +} + +function bindInventoryEvents(): void { + const artifact = selectedArtifact(); + const form = document.getElementById("artifactDesiredForm"); + if (artifact && form instanceof HTMLFormElement) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + createDesiredFromArtifact(artifact); + }); + } + document.querySelectorAll("[data-artifact-detail]").forEach((button) => { + button.addEventListener("click", () => { + state.selectedArtifactID = button.dataset.artifactDetail || ""; + renderPlugins(); + renderPluginDetail(null); + }); + }); +} + +function artifactInventoryList(artifacts: PluginArtifact[]): string { + if (artifacts.length === 0) { + return `
No artifacts
`; + } + return `
${artifacts.map((artifact) => ` +
+ ${escapeHTML(artifact.plugin_id)} + ${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)} + ${escapeHTML(shortID(artifact.id))} + +
+ `).join("")}
`; +} + +function buildInventoryList(builds: PluginBuild[]): string { + if (builds.length === 0) { + return `
No builds
`; + } + return `
${builds.map((build) => ` +
+ ${escapeHTML(build.plugin_id)} #${escapeHTML(build.id)} + ${badge(build.status, build.status !== "succeeded")} + ${escapeHTML(shortID(build.artifact_id || build.source_id))} + ${escapeHTML(build.log_summary || build.error || "")} +
+ `).join("")}
`; +} + +function selectedPlugin(): PluginView | null { + return state.plugins.find((plugin) => plugin.id === state.selectedPluginID) || null; +} + +function selectedArtifact(): PluginArtifact | null { + return state.pluginArtifacts.find((artifact) => artifact.id === state.selectedArtifactID) || null; +} + +function pluginActionButtons(plugin: PluginView): string { + return ` + + + + + `; +} + +function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string { + if (artifacts.length === 0) { + return `
No artifacts
`; + } + return `
${artifacts.map((artifact) => ` +
+ ${escapeHTML(shortID(artifact.id))} + ${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)} + ${escapeHTML(artifact.go_version || "")} + ${isAdmin() && artifact.artifact_type === "binary" && artifact.id !== plugin.desired_artifact_id ? `` : ""} +
+ `).join("")}
`; +} + +function buildList(builds: PluginBuild[], plugin: PluginView, canWrite: boolean): string { + if (builds.length === 0) { + return `
No builds
`; + } + return `
${builds.map((build) => ` +
+ #${escapeHTML(build.id)} + ${badge(build.status, build.status !== "succeeded")} + ${escapeHTML(shortID(build.artifact_id || build.source_id))} + ${escapeHTML(build.log_summary || build.error || "")} + ${canWrite ? buildActions(build, plugin) : ""} +
+ `).join("")}
`; +} + +function buildActions(build: PluginBuild, plugin: PluginView): string { + if (build.status === "queued") { + return ``; + } + if (build.status === "running") { + return ``; + } + if (build.status === "failed" || build.status === "canceled") { + return ``; + } + if (build.status === "succeeded" && build.artifact_id && build.artifact_id !== plugin.desired_artifact_id) { + return ``; + } + return ""; +} + +function snapshotList(snapshots: PluginSnapshot[], canWrite: boolean): string { + if (snapshots.length === 0) { + return `
No snapshots
`; + } + return `
${snapshots.map((snapshot) => ` +
+ #${escapeHTML(snapshot.id)} + gen ${escapeHTML(snapshot.desired_generation)} · ${escapeHTML(snapshot.desired_state)} · ${escapeHTML(shortID(snapshot.artifact_id))} + + ${canWrite ? ` + + + ` : ""} +
+ `).join("")}
`; +} + +function proxyConnectionList(connections: PluginProxyConnection[]): string { + if (connections.length === 0) { + return `
No active proxy connections
`; + } + return `
${connections.map((conn) => ` +
+ #${escapeHTML(conn.id)} + ${escapeHTML(shortID(conn.artifact_id))} · ${escapeHTML(conn.handler_id)} + ${escapeHTML(conn.duration_ms)}ms + ${badge(conn.draining ? "draining" : "active", conn.draining)} +
+ `).join("")}
`; +} + +function secretChip(secret: PluginSecret): string { + const reload = secret.reload_required ? "reload required" : "hot reload"; + return `${escapeHTML(secret.name)} v${escapeHTML(secret.current_version)} · prev ${escapeHTML(secret.previous_version)} · ${escapeHTML(reload)}`; +} + +function detailStat(label: string, value: unknown): string { + return `
${escapeHTML(label)}${escapeHTML(String(value ?? ""))}
`; +} + +function configEditorValue(): string { + return el("pluginConfigEditor").value; +} + +function shortID(id?: string): string { + return id ? id.slice(0, 12) : ""; +} + +function prettyJSON(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +function jsonList(raw?: string): string { + if (!raw) { + return ""; + } + try { + const value = JSON.parse(raw); + return Array.isArray(value) ? value.join(", ") : String(value); + } catch { + return raw; + } +} + +function formatJSON(value: unknown): string { + try { + return JSON.stringify(value ?? {}, null, 2); + } catch { + return String(value ?? ""); + } +} diff --git a/cmd/gateway/admin_plugin_handlers.go b/cmd/gateway/admin_plugin_handlers.go index bb41c3d..8125b56 100644 --- a/cmd/gateway/admin_plugin_handlers.go +++ b/cmd/gateway/admin_plugin_handlers.go @@ -265,7 +265,12 @@ func handleAdminPluginsList(w http.ResponseWriter, r *http.Request) { adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) return } - adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugins": plugins}) + views, err := pluginViews(r, plugins) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugins": views}) } func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID string) { @@ -277,6 +282,11 @@ func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID s adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") return } + if strings.HasSuffix(rawPluginID, "/proxy-connections") { + handleAdminPluginProxyConnections(w, r, strings.TrimSuffix(rawPluginID, "/proxy-connections")) + return + } + pluginID, err := adminhttp.PathSegment(rawPluginID) if err != nil { adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) @@ -290,7 +300,12 @@ func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID s writePluginManagerError(w, err) return } - adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin}) + view, err := pluginView(r, plugin, true) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": view}) case http.MethodPut: if session.Role != adminRoleAdmin { adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") @@ -323,6 +338,242 @@ func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID s } } +func handleAdminPluginProxyConnections(w http.ResponseWriter, r *http.Request, rawPluginID string) { + if r.Method != http.MethodGet { + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + pluginID, err := adminhttp.PathSegment(rawPluginID) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) + return + } + connections, err := pluginsManager.ActiveProxyConnections(r.Context(), pluginID) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"proxy_connections": connections}) +} + +func handleAdminPluginConfig(w http.ResponseWriter, r *http.Request, rawSegment string) { + session, ok := requireRole(w, r, adminRoleMember) + if !ok { + return + } + if pluginsManager == nil { + adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") + return + } + pluginID, action, ok := splitPluginSubresource(w, rawSegment, "config") + if !ok { + return + } + switch { + case r.Method == http.MethodGet && action == "snapshots": + snapshots, err := pluginsManager.ListConfigSnapshots(r.Context(), pluginID) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"snapshots": snapshots}) + case r.Method == http.MethodGet && strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/diff"): + parts := strings.Split(action, "/") + if len(parts) != 3 { + adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid snapshot diff path") + return + } + snapshotID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid snapshot id") + return + } + diff, err := pluginsManager.ConfigSnapshotDiff(r.Context(), snapshotID) + if err != nil { + writePluginManagerError(w, err) + return + } + if diff.PluginID != pluginID { + adminhttp.WriteAPIError(w, http.StatusNotFound, "snapshot not found") + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"diff": diff}) + case r.Method == http.MethodPost && action == "dry-run": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req adminhttp.PluginConfigRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + configJSON, err := pluginConfigRequestJSON(req) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if req.ArtifactID == "" { + plugin, err := pluginsManager.Plugin(r.Context(), pluginID) + if err != nil { + writePluginManagerError(w, err) + return + } + req.ArtifactID = plugin.DesiredArtifactID + } + result, err := pluginsManager.DryRunConfig(r.Context(), pluginID, req.ArtifactID, configJSON) + if err != nil { + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_config_dry_run", "plugin", pluginID, false, err.Error(), map[string]any{ + "artifact_id": req.ArtifactID, + }) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_config_dry_run", "plugin", pluginID, true, "config dry-run succeeded", map[string]any{ + "artifact_id": req.ArtifactID, + "restart_required": result.RestartRequired, + "sensitive_paths": result.SensitivePaths, + "redacted_diff_json": result.RedactedDiffJSON, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"result": result}) + case r.Method == http.MethodPut && action == "": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req adminhttp.PluginConfigRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + configJSON, err := pluginConfigRequestJSON(req) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) + return + } + current, err := pluginsManager.Plugin(r.Context(), pluginID) + if err != nil { + writePluginManagerError(w, err) + return + } + if req.ArtifactID == "" { + req.ArtifactID = current.DesiredArtifactID + } + if req.DesiredState == "" { + req.DesiredState = current.DesiredState + } + if req.Priority == 0 { + req.Priority = current.Priority + } + plugin, err := pluginsManager.SetDesired(r.Context(), session.Username, pluginID, req.ArtifactID, req.DesiredState, configJSON, req.Priority) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_config_update", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_config_update", "plugin", pluginID, true, "config desired state updated", map[string]any{ + "artifact_id": plugin.DesiredArtifactID, + "desired_generation": plugin.DesiredGeneration, + "desired_state": plugin.DesiredState, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin}) + default: + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleAdminPluginSecrets(w http.ResponseWriter, r *http.Request, rawSegment string) { + session, ok := requireRole(w, r, adminRoleMember) + if !ok { + return + } + if pluginsManager == nil { + adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") + return + } + pluginID, action, ok := splitPluginSubresource(w, rawSegment, "secrets") + if !ok { + return + } + switch { + case r.Method == http.MethodGet && action == "": + secrets, err := pluginsManager.ListSecrets(r.Context(), pluginID) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"secrets": secrets}) + case r.Method == http.MethodPost && action == "": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req adminhttp.PluginSecretRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + secret, err := pluginsManager.UpsertSecret(r.Context(), session.Username, pluginID, req.ArtifactID, req.Name, req.Value, req.ReloadRequired, req.HotReload) + if err != nil { + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_secret_update", "plugin_secret", "plugin://"+pluginID+"/"+req.Name, false, "secret update failed", map[string]any{ + "error": err.Error(), + }) + adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_secret_update", "plugin_secret", "plugin://"+pluginID+"/"+secret.Name, true, "secret updated", map[string]any{ + "current_version": secret.CurrentVersion, + "previous_version": secret.PreviousVersion, + "reload_required": secret.ReloadRequired, + "hot_reload": secret.HotReload, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"secret": secret}) + default: + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleAdminPluginRollback(w http.ResponseWriter, r *http.Request, rawSegment string) { + session, ok := requireRole(w, r, adminRoleAdmin) + if !ok { + return + } + if pluginsManager == nil { + adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") + return + } + if r.Method != http.MethodPost { + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + pluginID, action, ok := splitPluginSubresource(w, rawSegment, "rollback") + if !ok { + return + } + var req adminhttp.PluginRollbackRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + var plugin pluginmanager.PluginRecord + var err error + switch action { + case "artifact": + plugin, err = pluginsManager.RollbackArtifact(r.Context(), session.Username, pluginID, req.ArtifactID) + case "config": + plugin, err = pluginsManager.RollbackConfigSnapshot(r.Context(), session.Username, req.SnapshotID, req.FullDesired) + default: + adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown rollback action") + return + } + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_rollback_"+action, "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_rollback_"+action, "plugin", pluginID, true, "rollback desired state updated", map[string]any{ + "desired_generation": plugin.DesiredGeneration, + "active_changed": false, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin}) +} + func handleAdminPluginAction(w http.ResponseWriter, r *http.Request, rawSegment string) { session, ok := requireRole(w, r, adminRoleAdmin) if !ok { @@ -512,6 +763,214 @@ func pluginConfigJSON(req adminhttp.PluginDesiredRequest) (string, error) { return string(data), nil } +func pluginConfigRequestJSON(req adminhttp.PluginConfigRequest) (string, error) { + if strings.TrimSpace(req.ConfigJSON) != "" { + if !json.Valid([]byte(req.ConfigJSON)) { + return "", errors.New("config_json must be valid JSON") + } + return req.ConfigJSON, nil + } + if req.Config == nil { + return "{}", nil + } + data, err := json.Marshal(req.Config) + if err != nil { + return "", err + } + return string(data), nil +} + +func splitPluginSubresource(w http.ResponseWriter, rawSegment, resource string) (pluginID, action string, ok bool) { + parts := strings.Split(rawSegment, "/") + if len(parts) < 2 || parts[1] != resource { + adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid plugin "+resource+" path") + return "", "", false + } + id, err := adminhttp.PathSegment(parts[0]) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error()) + return "", "", false + } + if len(parts) > 2 { + action = strings.Join(parts[2:], "/") + } + return id, action, true +} + +func pluginViews(r *http.Request, plugins []pluginmanager.PluginRecord) ([]map[string]any, error) { + views := make([]map[string]any, 0, len(plugins)) + for _, plugin := range plugins { + view, err := pluginView(r, plugin, false) + if err != nil { + return nil, err + } + views = append(views, view) + } + return views, nil +} + +func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool) (map[string]any, error) { + artifacts, err := pluginsManager.ListArtifacts(r.Context(), plugin.ID) + if err != nil { + return nil, err + } + builds, err := pluginsManager.ListBuilds(r.Context(), plugin.ID) + if err != nil { + return nil, err + } + secrets, err := pluginsManager.ListSecrets(r.Context(), plugin.ID) + if err != nil { + return nil, err + } + snapshots, err := pluginsManager.ListConfigSnapshots(r.Context(), plugin.ID) + if err != nil { + return nil, err + } + artifactByID := make(map[string]pluginmanager.ArtifactRecord, len(artifacts)) + for _, artifact := range artifacts { + artifactByID[artifact.ID] = artifact + } + desiredArtifact := artifactByID[plugin.DesiredArtifactID] + activeArtifact := artifactByID[plugin.ActiveArtifactID] + loadedArtifact := artifactByID[plugin.LoadedArtifactID] + manifest := pluginManifest(desiredArtifact) + view := map[string]any{ + "id": plugin.ID, + "name": manifest.Name, + "version": desiredArtifact.Version, + "artifact_type": desiredArtifact.ArtifactType, + "runtime_type": desiredArtifact.RuntimeType, + "desired_state": plugin.DesiredState, + "runtime_state": plugin.RuntimeState, + "desired_artifact_id": plugin.DesiredArtifactID, + "active_artifact_id": plugin.ActiveArtifactID, + "loaded_artifact_id": plugin.LoadedArtifactID, + "desired_artifact": desiredArtifact, + "active_artifact": activeArtifact, + "loaded_artifact": loadedArtifact, + "extension_points": jsonArrayString(desiredArtifact.ExtensionPointsJSON), + "priority": plugin.Priority, + "scope": pluginScope(manifest), + "rollout": pluginRollout(manifest), + "restart_required": pluginRestartRequired(plugin), + "health": pluginHealth(plugin), + "last_error": plugin.LastError, + "runtime_summary": jsonObjectString(plugin.RuntimeSummaryJSON), + "dispatch_summary": jsonArrayString(plugin.DispatchSummaryJSON), + "capabilities_summary": jsonObjectString(desiredArtifact.CapabilitiesSummaryJSON), + "minecraft": pluginMinecraftSummary(desiredArtifact), + "config_json": plugin.ConfigJSON, + "config_schema": jsonObjectString(manifestConfigSchemaString(manifest)), + "secrets": secrets, + "snapshots": snapshots, + "builds": builds, + "artifacts": artifacts, + "updated_at": plugin.UpdatedAt, + } + if detail { + view["manifest"] = manifest + view["operations_path"] = "/plugin-artifacts?plugin_id=" + plugin.ID + view["active_proxy_connections"] = activeProxyConnections(plugin) + connections, err := pluginsManager.ActiveProxyConnections(r.Context(), plugin.ID) + if err != nil { + return nil, err + } + view["proxy_connections"] = connections + } + return view, nil +} + +func pluginManifest(artifact pluginmanager.ArtifactRecord) pluginmanager.Manifest { + var manifest pluginmanager.Manifest + _ = json.Unmarshal([]byte(artifact.MetadataJSON), &manifest) + return manifest +} + +func manifestConfigSchemaString(manifest pluginmanager.Manifest) string { + if len(manifest.ConfigSchema) == 0 { + return "{}" + } + return string(manifest.ConfigSchema) +} + +func pluginScope(manifest pluginmanager.Manifest) any { + var caps map[string]any + if len(manifest.Capabilities) == 0 || json.Unmarshal(manifest.Capabilities, &caps) != nil { + return map[string]any{"type": "global"} + } + if scope, ok := caps["scope"]; ok { + return scope + } + return map[string]any{"type": "global"} +} + +func pluginRollout(manifest pluginmanager.Manifest) any { + var caps map[string]any + if len(manifest.Capabilities) == 0 || json.Unmarshal(manifest.Capabilities, &caps) != nil { + return map[string]any{"mode": "all"} + } + if rollout, ok := caps["rollout"]; ok { + return rollout + } + return map[string]any{"mode": "all"} +} + +func pluginHealth(plugin pluginmanager.PluginRecord) string { + if plugin.LastError != "" || plugin.RuntimeState == pluginmanager.RuntimeFailed { + return "error" + } + if plugin.RuntimeState == pluginmanager.RuntimeEnabled { + return "healthy" + } + if plugin.RuntimeState == pluginmanager.RuntimeDraining { + return "draining" + } + return "inactive" +} + +func pluginRestartRequired(plugin pluginmanager.PluginRecord) bool { + return plugin.LoadedArtifactID != "" && plugin.DesiredArtifactID != "" && plugin.LoadedArtifactID != plugin.DesiredArtifactID +} + +func pluginMinecraftSummary(artifact pluginmanager.ArtifactRecord) any { + var summary pluginmanager.CapabilitySummary + if json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary) != nil || summary.Minecraft == nil { + return nil + } + return summary.Minecraft +} + +func activeProxyConnections(plugin pluginmanager.PluginRecord) int64 { + var summary map[string]any + if json.Unmarshal([]byte(plugin.RuntimeSummaryJSON), &summary) != nil { + return 0 + } + switch value := summary["active_proxy_connections"].(type) { + case float64: + return int64(value) + case int64: + return value + default: + return 0 + } +} + +func jsonObjectString(raw string) any { + var value any + if raw == "" || json.Unmarshal([]byte(raw), &value) != nil { + return map[string]any{} + } + return value +} + +func jsonArrayString(raw string) any { + var value any + if raw == "" || json.Unmarshal([]byte(raw), &value) != nil { + return []any{} + } + return value +} + func writePluginManagerError(w http.ResponseWriter, err error) { switch { case errors.Is(err, pluginmanager.ErrArtifactNotFound), errors.Is(err, pluginmanager.ErrPluginNotFound): diff --git a/cmd/gateway/admin_static/app.css b/cmd/gateway/admin_static/app.css index 1fa2f40..e406231 100644 --- a/cmd/gateway/admin_static/app.css +++ b/cmd/gateway/admin_static/app.css @@ -64,8 +64,23 @@ button.danger:hover { background: #8f1d15; } +button.link-button { + min-height: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--accent-dark); + text-align: left; +} + +button.link-button:hover { + background: transparent; + text-decoration: underline; +} + input, -select { +select, +textarea { width: 100%; min-height: 38px; border: 1px solid var(--line); @@ -75,6 +90,13 @@ select { color: var(--text); } +textarea { + min-height: 220px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + resize: vertical; +} + label { display: grid; gap: 6px; @@ -276,6 +298,10 @@ tr:last-child td { border-bottom: 0; } +tr.selected td { + background: #f0f7f3; +} + .actions { width: 180px; } @@ -320,11 +346,122 @@ tr:last-child td { gap: 10px; } -.service form { +.service form, +.inline-form { display: grid; gap: 10px; } +.file-button { + display: inline-flex; + align-items: center; + min-height: 36px; + border-radius: 6px; + padding: 0 14px; + background: var(--accent); + color: #fff; + cursor: pointer; +} + +.file-button input { + display: none; +} + +.plugin-detail { + margin-top: 16px; +} + +.detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.detail-header h2 { + margin: 0; + font-size: 20px; +} + +.detail-header p { + margin: 2px 0 0; + color: var(--muted); +} + +.status-grid.dense { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.plugin-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.plugin-layout .panel { + min-width: 0; + box-shadow: none; +} + +.plugin-layout h3 { + margin: 0 0 10px; + font-size: 15px; +} + +.kv { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 8px 10px; + margin: 0; +} + +.kv dt { + color: var(--muted); +} + +.kv dd { + margin: 0; + overflow-wrap: anywhere; +} + +.mini-list { + display: grid; + gap: 8px; +} + +.mini-row { + display: grid; + grid-template-columns: minmax(90px, 0.8fr) minmax(160px, 1.5fr) minmax(100px, 1fr) auto auto; + gap: 8px; + align-items: center; + border-bottom: 1px solid var(--line); + padding-bottom: 8px; +} + +.mini-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.log-output { + max-height: 240px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfb; + padding: 10px; + white-space: pre-wrap; +} + +.inline-alert { + margin-bottom: 12px; +} + +.empty { + color: var(--muted); +} + dialog { width: min(460px, calc(100vw - 28px)); border: 1px solid var(--line); @@ -361,7 +498,17 @@ dialog h2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .status-grid.dense, + .plugin-layout { + grid-template-columns: 1fr; + } + + .mini-row { + grid-template-columns: 1fr; + } + .topbar, + .detail-header, .toolbar { align-items: stretch; flex-direction: column; diff --git a/cmd/gateway/admin_static/index.html b/cmd/gateway/admin_static/index.html index c1038d3..152ce7e 100644 --- a/cmd/gateway/admin_static/index.html +++ b/cmd/gateway/admin_static/index.html @@ -63,6 +63,7 @@