mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
377 lines
10 KiB
Go
377 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wavetermdev/waveterm/tsunami/app"
|
|
"github.com/wavetermdev/waveterm/tsunami/vdom"
|
|
)
|
|
|
|
var AppMeta = app.AppMeta{
|
|
Title: "Tsunami Config Manager",
|
|
ShortDesc: "Configuration editor for remote servers with JSON validation",
|
|
}
|
|
|
|
// Global atoms for config
|
|
var (
|
|
serverURLAtom = app.ConfigAtom("serverURL", "", &app.AtomMeta{
|
|
Desc: "Server URL for config API (can be full URL, hostname:port, or just port)",
|
|
Pattern: `^(https?://.*|[a-zA-Z0-9.-]+:\d+|\d+|[a-zA-Z0-9.-]+)$`,
|
|
})
|
|
)
|
|
|
|
type URLInputProps struct {
|
|
Value string `json:"value"`
|
|
OnChange func(string) `json:"onChange"`
|
|
OnSubmit func() `json:"onSubmit"`
|
|
IsLoading bool `json:"isLoading"`
|
|
}
|
|
|
|
type JSONEditorProps struct {
|
|
Value string `json:"value"`
|
|
OnChange func(string) `json:"onChange"`
|
|
OnSubmit func() `json:"onSubmit"`
|
|
IsLoading bool `json:"isLoading"`
|
|
Placeholder string `json:"placeholder"`
|
|
}
|
|
|
|
type ErrorDisplayProps struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type SuccessDisplayProps struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// parseURL takes flexible URL input and returns a normalized base URL
|
|
func parseURL(input string) (string, error) {
|
|
if input == "" {
|
|
return "", fmt.Errorf("URL cannot be empty")
|
|
}
|
|
|
|
input = strings.TrimSpace(input)
|
|
|
|
// Handle just port number (e.g., "52848")
|
|
if portRegex := regexp.MustCompile(`^\d+$`); portRegex.MatchString(input) {
|
|
return fmt.Sprintf("http://localhost:%s", input), nil
|
|
}
|
|
|
|
// Add http:// if no protocol specified
|
|
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
|
|
input = "http://" + input
|
|
}
|
|
|
|
// Parse the URL to validate and extract components
|
|
parsedURL, err := url.Parse(input)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid URL format: %v", err)
|
|
}
|
|
|
|
if parsedURL.Host == "" {
|
|
return "", fmt.Errorf("no host specified in URL")
|
|
}
|
|
|
|
// Return base URL (scheme + host)
|
|
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
|
|
return baseURL, nil
|
|
}
|
|
|
|
// fetchConfig fetches JSON from the /api/config endpoint
|
|
func fetchConfig(baseURL string) (string, error) {
|
|
configURL := baseURL + "/api/config"
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(configURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to connect to %s: %v", configURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("server returned status %d from %s", resp.StatusCode, configURL)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read response: %v", err)
|
|
}
|
|
|
|
// Validate that it's valid JSON
|
|
var jsonObj interface{}
|
|
if err := json.Unmarshal(body, &jsonObj); err != nil {
|
|
return "", fmt.Errorf("response is not valid JSON: %v", err)
|
|
}
|
|
|
|
// Pretty print the JSON
|
|
prettyJSON, err := json.MarshalIndent(jsonObj, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to format JSON: %v", err)
|
|
}
|
|
|
|
return string(prettyJSON), nil
|
|
}
|
|
|
|
// postConfig sends JSON to the /api/config endpoint
|
|
func postConfig(baseURL, jsonContent string) error {
|
|
configURL := baseURL + "/api/config"
|
|
|
|
// Validate JSON before sending
|
|
var jsonObj interface{}
|
|
if err := json.Unmarshal([]byte(jsonContent), &jsonObj); err != nil {
|
|
return fmt.Errorf("invalid JSON: %v", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Post(configURL, "application/json", strings.NewReader(jsonContent))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send request to %s: %v", configURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var URLInput = app.DefineComponent("URLInput",
|
|
func(props URLInputProps) any {
|
|
keyHandler := &vdom.VDomFunc{
|
|
Type: "func",
|
|
Fn: func(event vdom.VDomEvent) {
|
|
if !props.IsLoading {
|
|
props.OnSubmit()
|
|
}
|
|
},
|
|
Keys: []string{"Enter"},
|
|
PreventDefault: true,
|
|
}
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "flex gap-2 mb-4",
|
|
},
|
|
vdom.H("input", map[string]any{
|
|
"className": "flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
|
"type": "text",
|
|
"placeholder": "Enter URL (e.g., localhost:52848, http://localhost:52848/api/config, or just 52848)",
|
|
"value": props.Value,
|
|
"disabled": props.IsLoading,
|
|
"onChange": func(e vdom.VDomEvent) {
|
|
props.OnChange(e.TargetValue)
|
|
},
|
|
"onKeyDown": keyHandler,
|
|
}),
|
|
vdom.H("button", map[string]any{
|
|
"className": vdom.Classes(
|
|
"px-4 py-2 rounded font-medium cursor-pointer transition-colors",
|
|
vdom.IfElse(props.IsLoading,
|
|
"bg-slate-600 text-slate-400 cursor-not-allowed",
|
|
"bg-blue-600 text-white hover:bg-blue-700",
|
|
),
|
|
),
|
|
"onClick": vdom.If(!props.IsLoading, props.OnSubmit),
|
|
"disabled": props.IsLoading,
|
|
}, vdom.IfElse(props.IsLoading, "Loading...", "Fetch")),
|
|
)
|
|
},
|
|
)
|
|
|
|
var JSONEditor = app.DefineComponent("JSONEditor",
|
|
func(props JSONEditorProps) any {
|
|
if props.Value == "" && props.Placeholder == "" {
|
|
return vdom.H("div", map[string]any{
|
|
"className": "text-slate-400 text-center py-8",
|
|
}, "Enter a URL above and click Fetch to load configuration")
|
|
}
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "flex flex-col",
|
|
},
|
|
vdom.H("textarea", map[string]any{
|
|
"className": "w-full h-96 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500",
|
|
"value": props.Value,
|
|
"placeholder": props.Placeholder,
|
|
"disabled": props.IsLoading,
|
|
"onChange": func(e vdom.VDomEvent) {
|
|
props.OnChange(e.TargetValue)
|
|
},
|
|
}),
|
|
vdom.If(props.Value != "",
|
|
vdom.H("button", map[string]any{
|
|
"className": vdom.Classes(
|
|
"mt-2 w-full py-2 rounded font-medium cursor-pointer transition-colors",
|
|
vdom.IfElse(props.IsLoading,
|
|
"bg-slate-600 text-slate-400 cursor-not-allowed",
|
|
"bg-green-600 text-white hover:bg-green-700",
|
|
),
|
|
),
|
|
"onClick": vdom.If(!props.IsLoading, props.OnSubmit),
|
|
"disabled": props.IsLoading,
|
|
}, vdom.IfElse(props.IsLoading, "Submitting...", "Submit Changes")),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
|
|
var ErrorDisplay = app.DefineComponent("ErrorDisplay",
|
|
func(props ErrorDisplayProps) any {
|
|
if props.Message == "" {
|
|
return nil
|
|
}
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded mb-4",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "font-medium",
|
|
}, "Error"),
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-sm mt-1",
|
|
}, props.Message),
|
|
)
|
|
},
|
|
)
|
|
|
|
var SuccessDisplay = app.DefineComponent("SuccessDisplay",
|
|
func(props SuccessDisplayProps) any {
|
|
if props.Message == "" {
|
|
return nil
|
|
}
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "bg-green-900 border border-green-700 text-green-100 px-4 py-3 rounded mb-4",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "font-medium",
|
|
}, "Success"),
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-sm mt-1",
|
|
}, props.Message),
|
|
)
|
|
},
|
|
)
|
|
|
|
var App = app.DefineComponent("App",
|
|
func(_ struct{}) any {
|
|
|
|
// Get atom value once at the top
|
|
urlInput := serverURLAtom.Get()
|
|
jsonContent := app.UseLocal("")
|
|
errorMessage := app.UseLocal("")
|
|
successMessage := app.UseLocal("")
|
|
isLoading := app.UseLocal(false)
|
|
lastFetch := app.UseLocal("")
|
|
currentBaseURL := app.UseLocal("")
|
|
|
|
clearMessages := func() {
|
|
errorMessage.Set("")
|
|
successMessage.Set("")
|
|
}
|
|
|
|
fetchConfigData := func() {
|
|
clearMessages()
|
|
|
|
baseURL, err := parseURL(serverURLAtom.Get())
|
|
if err != nil {
|
|
errorMessage.Set(err.Error())
|
|
return
|
|
}
|
|
|
|
isLoading.Set(true)
|
|
currentBaseURL.Set(baseURL)
|
|
|
|
go func() {
|
|
defer func() {
|
|
isLoading.Set(false)
|
|
}()
|
|
|
|
content, err := fetchConfig(baseURL)
|
|
if err != nil {
|
|
errorMessage.Set(err.Error())
|
|
return
|
|
}
|
|
|
|
jsonContent.Set(content)
|
|
lastFetch.Set(time.Now().Format("2006-01-02 15:04:05"))
|
|
successMessage.Set(fmt.Sprintf("Successfully fetched config from %s", baseURL))
|
|
}()
|
|
}
|
|
|
|
submitConfigData := func() {
|
|
if currentBaseURL.Get() == "" {
|
|
errorMessage.Set("No base URL available. Please fetch config first.")
|
|
return
|
|
}
|
|
|
|
clearMessages()
|
|
isLoading.Set(true)
|
|
|
|
go func() {
|
|
defer func() {
|
|
isLoading.Set(false)
|
|
}()
|
|
|
|
err := postConfig(currentBaseURL.Get(), jsonContent.Get())
|
|
if err != nil {
|
|
errorMessage.Set(fmt.Sprintf("Failed to submit config: %v", err))
|
|
return
|
|
}
|
|
|
|
successMessage.Set(fmt.Sprintf("Successfully submitted config to %s", currentBaseURL.Get()))
|
|
}()
|
|
}
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "max-w-4xl mx-auto p-6 bg-slate-800 text-slate-100 min-h-screen",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "mb-6",
|
|
},
|
|
vdom.H("h1", map[string]any{
|
|
"className": "text-3xl font-bold mb-2",
|
|
}, "Tsunami Config Manager"),
|
|
vdom.H("p", map[string]any{
|
|
"className": "text-slate-400",
|
|
}, "Fetch and edit configuration from remote servers"),
|
|
),
|
|
|
|
URLInput(URLInputProps{
|
|
Value: urlInput,
|
|
OnChange: serverURLAtom.Set,
|
|
OnSubmit: fetchConfigData,
|
|
IsLoading: isLoading.Get(),
|
|
}),
|
|
|
|
ErrorDisplay(ErrorDisplayProps{
|
|
Message: errorMessage.Get(),
|
|
}),
|
|
|
|
SuccessDisplay(SuccessDisplayProps{
|
|
Message: successMessage.Get(),
|
|
}),
|
|
|
|
vdom.If(lastFetch.Get() != "",
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-sm text-slate-400 mb-4",
|
|
}, fmt.Sprintf("Last fetched: %s from %s", lastFetch.Get(), currentBaseURL.Get())),
|
|
),
|
|
|
|
JSONEditor(JSONEditorProps{
|
|
Value: jsonContent.Get(),
|
|
OnChange: jsonContent.Set,
|
|
OnSubmit: submitConfigData,
|
|
IsLoading: isLoading.Get(),
|
|
Placeholder: "JSON configuration will appear here after fetching...",
|
|
}),
|
|
)
|
|
},
|
|
)
|