mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus) - 画布多对多连线,渲染 tcp/udp/http/https frpc 配置 - worker 进程管理:自愈、日志轮转、崩溃退避重启 - frpc 一键安装 (GitHub Releases) + 手动指定路径 - 三页 UI:状态(默认)/连接配置/设置 - 状态页实时节点/转发状态,节点可增删改启停 - 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存 - backend 单测覆盖 store/render/process/httpapi/install - plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/httpapi/dist/index.html
vendored
Normal file
13
internal/httpapi/dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
283
internal/httpapi/handlers.go
Normal file
283
internal/httpapi/handlers.go
Normal file
@ -0,0 +1,283 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// canvasData is the full drawing canvas exchanged with the frontend.
|
||||
type canvasData struct {
|
||||
Locals []store.Local `json:"locals"`
|
||||
Remotes []store.Remote `json:"remotes"`
|
||||
Links []store.Link `json:"links"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) {
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
links, _ := h.Store.ListLinks()
|
||||
writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleCanvasGet(w, r)
|
||||
case http.MethodPut:
|
||||
h.saveCanvas(w, r)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
var canvas canvasData
|
||||
if err := json.NewDecoder(r.Body).Decode(&canvas); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s := h.Store
|
||||
|
||||
// Upsert locals.
|
||||
for _, l := range canvas.Locals {
|
||||
if err := s.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete locals not present.
|
||||
if existing, err := s.ListLocals(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, l := range canvas.Locals {
|
||||
keep[l.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteLocal(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert remotes.
|
||||
for _, rem := range canvas.Remotes {
|
||||
if err := s.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing, err := s.ListRemotes(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, rem := range canvas.Remotes {
|
||||
keep[rem.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteRemote(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace links wholesale.
|
||||
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Restart affected running workers so changes take effect immediately.
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
settings, _ := h.Store.Settings()
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
var st store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpdateSettings(st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.handleSettingsGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Binary ----
|
||||
|
||||
func (h *Handler) handleBinaryStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := map[string]any{"binaryPath": ""}
|
||||
if h.BinaryPath != nil {
|
||||
resp["binaryPath"] = h.BinaryPath()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBinaryInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if h.InstallBinary == nil {
|
||||
http.Error(w, "install not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path, version, err := h.InstallBinary(req.Version)
|
||||
if err != nil {
|
||||
http.Error(w, "install failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "version": version})
|
||||
}
|
||||
|
||||
// ---- Single remote upsert/delete (used by the status page) ----
|
||||
|
||||
func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var rem store.Remote
|
||||
if err := json.NewDecoder(r.Body).Decode(&rem); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rem.Name == "" || rem.IP == "" || rem.Port <= 0 || rem.Port > 65535 {
|
||||
http.Error(w, "name/ip/port required, port in [1,65535]", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Start the worker if enabled.
|
||||
if rem.Enabled {
|
||||
_ = h.Process.Start(rem.Name)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rem)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/remotes/")
|
||||
if name == "" {
|
||||
http.Error(w, "remote name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = h.Process.Stop(name)
|
||||
if err := h.Store.DeleteRemote(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---- Profile lifecycle ----
|
||||
|
||||
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Path: /api/manager/profiles/{name}/{action?}
|
||||
rel := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.Error(w, "profile name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "":
|
||||
// GET profile status
|
||||
st, has := h.Process.Status(name)
|
||||
forwards, _ := h.Store.LinksForRemote(name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
|
||||
})
|
||||
case "start", "stop", "restart":
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var err error
|
||||
if action == "start" {
|
||||
err = h.Process.Start(name)
|
||||
} else if action == "stop" {
|
||||
err = h.Process.Stop(name)
|
||||
} else {
|
||||
err = h.Process.Restart(name)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
case "config":
|
||||
data, err := os.ReadFile(h.Process.ConfigPath(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
case "logs":
|
||||
path := h.Process.LogPath(name)
|
||||
data, err := tailFile(path, 64*1024)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(data))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
|
||||
|
||||
// tailFile returns the last maxBytes bytes of a file.
|
||||
func tailFile(path string, maxBytes int64) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
size := min(info.Size(), maxBytes)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, size)
|
||||
if _, err := f.ReadAt(buf, info.Size()-size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
213
internal/httpapi/server.go
Normal file
213
internal/httpapi/server.go
Normal file
@ -0,0 +1,213 @@
|
||||
// Package httpapi serves the web UI and REST API.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Handler bundles dependencies for the HTTP API.
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
Process *process.Manager
|
||||
WorkDir string
|
||||
BinDir string
|
||||
User string
|
||||
Password string
|
||||
|
||||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||||
// avoid an import cycle with the install package.
|
||||
InstallBinary func(version string) (path, ver string, err error)
|
||||
// BinaryPath resolves the current worker binary.
|
||||
BinaryPath func() string
|
||||
// RunInstall is a hook to trigger canary tasks after canvas save.
|
||||
SyncWorkers func()
|
||||
}
|
||||
|
||||
const (
|
||||
healthzPath = "/healthz"
|
||||
apiPrefix = "/api/manager"
|
||||
)
|
||||
|
||||
// NewServeMux builds the full HTTP handler.
|
||||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != h.User || p != h.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// API routes (basic auth).
|
||||
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
||||
mux.HandleFunc(apiPrefix+"/settings", auth(h.handleSettingsGet))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
||||
|
||||
// Profile lifecycle routes.
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
||||
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
||||
|
||||
// Static assets (also basic auth) under /.
|
||||
mux.HandleFunc("/", h.handleStatic)
|
||||
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
// handleStatic serves the embedded web build. Paths map to dist files; / and
|
||||
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
|
||||
// routing, so returning index.html for "/" is enough — a redirect here would
|
||||
// loop (the hash fragment never reaches the server).
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
http.Error(w, "assets unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
data, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
// SPA fallback: any non-file path serves index.html.
|
||||
data, err = fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name = "index.html"
|
||||
}
|
||||
if ct := contentTypeFor(name); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func contentTypeFor(name string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
case strings.HasSuffix(name, ".js"):
|
||||
return "application/javascript"
|
||||
case strings.HasSuffix(name, ".css"):
|
||||
return "text/css"
|
||||
case strings.HasSuffix(name, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(name, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(name, ".ico"):
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
settings, _ := h.Store.Settings()
|
||||
|
||||
type profileStatus struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status process.Status `json:"process"`
|
||||
HasProc bool `json:"hasProcess"`
|
||||
Forwards []store.Forward `json:"forwards"`
|
||||
}
|
||||
|
||||
profiles := make([]profileStatus, 0, len(remotes))
|
||||
binary := ""
|
||||
if h.BinaryPath != nil {
|
||||
binary = h.BinaryPath()
|
||||
}
|
||||
for _, rv := range remotes {
|
||||
st, has := h.Process.Status(rv.Name)
|
||||
fwd, _ := h.Store.LinksForRemote(rv.Name)
|
||||
profiles = append(profiles, profileStatus{
|
||||
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
|
||||
})
|
||||
}
|
||||
|
||||
// Local status: each local plus its forwarding targets and whether each
|
||||
// target's worker is healthy (running).
|
||||
type localTargetStatus struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
WorkerState string `json:"workerState"`
|
||||
}
|
||||
type localStatus struct {
|
||||
Local store.Local `json:"local"`
|
||||
Targets []localTargetStatus `json:"targets"`
|
||||
}
|
||||
|
||||
localStatuses := make([]localStatus, 0, len(locals))
|
||||
for _, l := range locals {
|
||||
targets, err := h.Store.LinksForLocal(l.Name)
|
||||
if err != nil {
|
||||
targets = nil
|
||||
}
|
||||
ts := make([]localTargetStatus, 0, len(targets))
|
||||
for _, tg := range targets {
|
||||
st, _ := h.Process.Status(tg.Remote)
|
||||
ts = append(ts, localTargetStatus{
|
||||
Remote: tg.Remote,
|
||||
RemotePort: tg.RemotePort,
|
||||
WorkerState: st.State,
|
||||
})
|
||||
}
|
||||
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"version": "0.1.0",
|
||||
"workDir": h.WorkDir,
|
||||
"settings": settings,
|
||||
"services": locals,
|
||||
"remotes": remotes,
|
||||
"binaryPath": binary,
|
||||
"profiles": profiles,
|
||||
"localStatus": localStatuses,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = fmtJSONEncode(w, v)
|
||||
}
|
||||
|
||||
func fmtJSONEncode(w http.ResponseWriter, v any) error {
|
||||
return json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
99
internal/httpapi/server_test.go
Normal file
99
internal/httpapi/server_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *httptest.Server) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := process.NewManager(process.Options{
|
||||
ConfigsDir: filepath.Join(dir, "configs"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
BinaryPath: func() string { return "" },
|
||||
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
||||
AutoRestart: func(string) bool { return false },
|
||||
RestartInterval: func() int { return 5 },
|
||||
})
|
||||
|
||||
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw"}
|
||||
mux, err := NewServeMux(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return h, ts
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
resp, err := http.Get(ts.URL + "/api/manager/status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasRoundTrip(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
client := ts.Client()
|
||||
|
||||
body := `{
|
||||
"locals": [{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
|
||||
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||
"links": [{"local":"web","remote":"srv-a","remotePort":8080}]
|
||||
}`
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||
req.SetBasicAuth("admin", "pw")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("save canvas status = %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got canvasData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Locals) != 1 || got.Locals[0].Name != "web" {
|
||||
t.Fatalf("locals = %+v", got.Locals)
|
||||
}
|
||||
if len(got.Links) != 1 || got.Links[0].RemotePort != 8080 {
|
||||
t.Fatalf("links = %+v", got.Links)
|
||||
}
|
||||
|
||||
// GET again to confirm persistence via store.
|
||||
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/canvas", nil)
|
||||
req2.SetBasicAuth("admin", "pw")
|
||||
resp2, err2 := client.Do(req2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
var got2 canvasData
|
||||
_ = json.NewDecoder(resp2.Body).Decode(&got2)
|
||||
if len(got2.Remotes) != 1 || got2.Remotes[0].Name != "srv-a" {
|
||||
t.Fatalf("remotes after reload = %+v", got2.Remotes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user