refractoring, added test
2
go.mod
|
@ -3,10 +3,10 @@ module gontrol
|
||||||
go 1.23.4
|
go 1.23.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/PuerkitoBio/goquery v1.10.1
|
github.com/PuerkitoBio/goquery v1.10.1
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/kelseyhightower/envconfig v1.4.0
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
5
go.sum
|
@ -1,5 +1,7 @@
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
@ -9,8 +11,7 @@ github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqw
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
|
17
gomatic.sql
|
@ -1,17 +0,0 @@
|
||||||
/* create database 'gomatic'; */
|
|
||||||
|
|
||||||
drop table if exists agents;
|
|
||||||
create table agents (
|
|
||||||
id UUID default uuid() Primary Key,
|
|
||||||
agentId int unique,
|
|
||||||
agentType varchar(255),
|
|
||||||
agentName varchar(255),
|
|
||||||
IPv4Address varchar(15),
|
|
||||||
initialContact timestamp,
|
|
||||||
lastContact timestamp,
|
|
||||||
status Boolean,
|
|
||||||
addPort varchar(255),
|
|
||||||
hostName varchar(255),
|
|
||||||
);
|
|
||||||
|
|
||||||
insert into agents (IPv4Address, agentId, agentName, initialContact, lastContact) values ( '127.0.0.1', 'testAgent', NOW(), NOW());
|
|
415
main.go
|
@ -2,356 +2,53 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"embed"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"slices"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"os"
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
// "golang.org/x/net/html"
|
|
||||||
"gontrol/src/logger"
|
|
||||||
"gontrol/src/randomname"
|
|
||||||
api "gontrol/src/server/api"
|
|
||||||
"gontrol/src/server/database"
|
|
||||||
websocketserver "gontrol/src/server/websocket"
|
|
||||||
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
"gontrol/src/logger"
|
||||||
"github.com/kelseyhightower/envconfig"
|
"gontrol/src/server/database"
|
||||||
|
"gontrol/src/server/webapp"
|
||||||
|
"gontrol/src/server/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
tmpl *template.Template
|
|
||||||
db *sql.DB
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed static/*
|
|
||||||
var staticFiles embed.FS
|
|
||||||
|
|
||||||
// //go:embed templates
|
|
||||||
// var templateFiles embed.FS
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Database struct {
|
|
||||||
Username string `envconfig:"DB_USERNAME"`
|
|
||||||
Password string `envconfig:"DB_PASSWORD"`
|
|
||||||
Port int16 `envconfig:"DB_PORT"`
|
|
||||||
Name string `envconfig:"DB_NAME"`
|
|
||||||
Host string `envconfig:"DB_HOST"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readEnv(cfg *Config) {
|
|
||||||
err := envconfig.Process("", cfg)
|
|
||||||
if err != nil {
|
|
||||||
processError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processError(err error) {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
tmpl, _ = template.ParseGlob("templates/*.html")
|
|
||||||
|
|
||||||
// var err error
|
|
||||||
// tmpl, err = template.ParseFS(
|
|
||||||
// templateFiles,
|
|
||||||
// "templates/*.html",
|
|
||||||
// "templates/partials/*.html",
|
|
||||||
// )
|
|
||||||
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatalf("Failed to parse embedded templates: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Sqlite3
|
|
||||||
err := logger.InitDB("/tmp/gontrol_logs.db")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agents database
|
|
||||||
db = database.InitSQLiteDB("/tmp/gontrol_agents.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
// func renderTemplate(w http.ResponseWriter, tmplPath string, data interface{}) {
|
|
||||||
// t := tmpl.Lookup(strings.TrimPrefix(tmplPath, "templates/"))
|
|
||||||
// if t == nil {
|
|
||||||
// log.Printf("Template %s not found", tmplPath)
|
|
||||||
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if err := t.Execute(w, data); err != nil {
|
|
||||||
// log.Printf("Failed to render template %s: %v", tmplPath, err)
|
|
||||||
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
// }
|
|
||||||
|
|
||||||
func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
|
|
||||||
t, err := template.ParseFiles(tmpl)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to load template %s: %v", tmpl, err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := t.Execute(w, data); err != nil {
|
|
||||||
log.Printf("Failed to render template %s: %v", tmpl, err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/agents/"), "/")
|
|
||||||
agentId := ""
|
|
||||||
if len(parts) > 0 && parts[0] != "" {
|
|
||||||
agentId = parts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodDelete:
|
|
||||||
api.DeleteAgent(db, w, r, agentId)
|
|
||||||
listAgents(w,r)
|
|
||||||
case http.MethodGet:
|
|
||||||
if agentId == "" {
|
|
||||||
listAgents(w, r)
|
|
||||||
} else {
|
|
||||||
agent, _ := api.GetAgent(db, w, r, agentId)
|
|
||||||
renderTemplate(w, "templates/partials/agent_detail.html", agent)
|
|
||||||
// renderTemplate(w, "agent_detail.html", agent)
|
|
||||||
}
|
|
||||||
case http.MethodPost:
|
|
||||||
api.CreateAgent(db, w, r)
|
|
||||||
listAgents(w, r)
|
|
||||||
case http.MethodPut:
|
|
||||||
api.UpdateAgent(db, w, r, agentId)
|
|
||||||
listAgents(w, r)
|
|
||||||
default:
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHomepage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tmpl.ExecuteTemplate(w, "index.html", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAgents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var agents []api.Agent
|
|
||||||
agents, err := api.GetAgents(db)
|
|
||||||
currentAgents := getAgentsStatus()
|
|
||||||
|
|
||||||
for i := range agents {
|
|
||||||
if slices.Contains(currentAgents, agents[i].AgentName) {
|
|
||||||
agents[i].Status = "Connected"
|
|
||||||
} else {
|
|
||||||
agents[i].Status = "Disconnected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(r.Header.Get("Accept"), "json") {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
jsonData, err := json.Marshal(agents)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to encode agents to JSON", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Write(jsonData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to fetch agents", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTemplate(w, "templates/partials/agent_list.html", agents)
|
|
||||||
// renderTemplate(w, "agent_list.html", agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAgentsStatus() []string {
|
|
||||||
resp, err := http.Get("http://localhost:5555/agentNames")
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error fetching agent names:", err)
|
|
||||||
logger.InsertLog(logger.Error, "Error fetching agent names from websocketServer")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var agentNames []string
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&agentNames); err != nil {
|
|
||||||
log.Println("Error decoding response:", err)
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return agentNames
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAgentNames(w http.ResponseWriter, r *http.Request) {
|
|
||||||
api.GetAgentNames(db, w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAgentIds(w http.ResponseWriter, r *http.Request) {
|
|
||||||
api.GetAgentIds(db, w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func logsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Warning this bit me in the nose: var countStr is []string, but
|
|
||||||
// variable = countStr[0] is casted to int automatically
|
|
||||||
// when the string is a number. Jesus Christ, this is odd behavior!
|
|
||||||
levels := r.URL.Query()["level"]
|
|
||||||
countStr := r.URL.Query()["limit"]
|
|
||||||
var limit int = 128
|
|
||||||
if len(countStr) > 0 {
|
|
||||||
parsedCount, err := strconv.Atoi(countStr[0])
|
|
||||||
if err == nil {
|
|
||||||
limit = parsedCount
|
|
||||||
} else {
|
|
||||||
http.Error(w, "Invalid count value", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This enables not only `level` GET parameters but also selecting by paths
|
|
||||||
// For example /logs/error is now identical to /logs?level=error
|
|
||||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/logs/"), "/")
|
|
||||||
if (len(levels) == 0) && len(parts) > 0 && parts[0] != "" {
|
|
||||||
levels = []string{parts[0]}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the police... I mean logger
|
|
||||||
logs, err := logger.FetchLogs(limit, levels)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Error fetching logs", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
renderTemplate(w, "templates/partials/logs_partial.html", logs)
|
|
||||||
// renderTemplate(w, "logs_partial.html", logs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxyAgentHandler tunnels HTTP and WebSocket traffic to an agent
|
|
||||||
// selected by ?ip=…&port=… . It keeps the path *after* /proxyAgent,
|
|
||||||
// removes the two query parameters, disables HTTP/2 (mandatory for WS),
|
|
||||||
// and streams with no buffering.
|
|
||||||
func proxyAgentHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ip := r.URL.Query().Get("ip")
|
|
||||||
port := r.URL.Query().Get("port")
|
|
||||||
if ip == "" || port == "" {
|
|
||||||
http.Error(w, "ip and port query parameters are required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// We leave the scheme "http" even for WebSockets – the Upgrade
|
|
||||||
// header does the rest. (Only cosmetic to change it to "ws”.)
|
|
||||||
target, err := url.Parse("http://" + ip + ":" + port)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid ip/port: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy := &httputil.ReverseProxy{
|
|
||||||
Director: func(req *http.Request) {
|
|
||||||
// Point to the agent
|
|
||||||
req.URL.Scheme = target.Scheme
|
|
||||||
req.URL.Host = target.Host
|
|
||||||
|
|
||||||
// Trim the first "/proxyAgent" prefix
|
|
||||||
if strings.HasPrefix(req.URL.Path, "/proxyAgent") {
|
|
||||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/proxyAgent")
|
|
||||||
if req.URL.Path == "" {
|
|
||||||
req.URL.Path = "/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrub ip/port from downstream query
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Del("ip")
|
|
||||||
q.Del("port")
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
// Preserve Host (many CLI-style servers care)
|
|
||||||
req.Host = target.Host
|
|
||||||
},
|
|
||||||
|
|
||||||
// Critical tweaks for WebSockets
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
||||||
ForceAttemptHTTP2: false, // MUST be HTTP/1.1 for WS
|
|
||||||
ResponseHeaderTimeout: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
FlushInterval: -1, // stream bytes immediately
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
tmpl, err := webapp.ParseTemplates()
|
||||||
// sqlite3 has been initialized in init()
|
if err != nil {
|
||||||
defer logger.CloseDB()
|
log.Fatalf("Parse templates: %v", err)
|
||||||
|
}
|
||||||
defer db.Close()
|
|
||||||
|
db := database.InitSQLiteDB("/tmp/gontrol_agents.db")
|
||||||
var cfg Config
|
defer db.Close()
|
||||||
readEnv(&cfg)
|
|
||||||
|
if err := logger.InitDB("/tmp/gontrol_logs.db"); err != nil {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
log.Fatalf("Init log db: %v", err)
|
||||||
defer cancel()
|
}
|
||||||
|
defer logger.CloseDB()
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
app := &webapp.App{Tmpl: tmpl, DB: db}
|
||||||
websocketServer := websocketserver.Server()
|
|
||||||
|
srv := &http.Server {
|
||||||
webMux := http.NewServeMux()
|
Addr: ":3333",
|
||||||
webMux.HandleFunc("/", getHomepage)
|
Handler: webapp.BuildRouter(app),
|
||||||
webMux.HandleFunc("/agents", agentsHandler)
|
ReadTimeout: 5 * time.Second,
|
||||||
webMux.HandleFunc("/agentNames", getAgentNames)
|
WriteTimeout: 10 * time.Second,
|
||||||
webMux.HandleFunc("/agentIds", getAgentIds)
|
|
||||||
webMux.HandleFunc("/agents/{agentId}", agentsHandler)
|
|
||||||
webMux.HandleFunc("/logs", logsHandler)
|
|
||||||
webMux.HandleFunc("/logs/{level}", logsHandler)
|
|
||||||
webMux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
|
||||||
webMux.HandleFunc("/proxyAgent", proxyAgentHandler)
|
|
||||||
webMux.HandleFunc("/proxyAgent/", proxyAgentHandler)
|
|
||||||
|
|
||||||
// db := database.InitDB (cfg.Database.Username, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)
|
|
||||||
// defer db.Close()
|
|
||||||
|
|
||||||
name := randomname.GenerateRandomName()
|
|
||||||
log.Println("Server instance: ", name)
|
|
||||||
|
|
||||||
webServer := &http.Server {
|
|
||||||
Addr: ":3333",
|
|
||||||
Handler: webMux,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
log.Println("Web server is running on port :3333")
|
||||||
logLine := "Websocket server is running on port 5555"
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logLine := "Websocket server is running on port :5555"
|
||||||
log.Println(logLine)
|
log.Println(logLine)
|
||||||
|
websocketServer := websocketserver.Server()
|
||||||
if err := websocketServer.ListenAndServe(); err != http.ErrServerClosed {
|
if err := websocketServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
log.Fatalf("Websocket server failed: %s", err)
|
log.Fatalf("Websocket server failed: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -361,39 +58,11 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wg.Add(1)
|
stop := make(chan os.Signal, 1)
|
||||||
go func() {
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
defer wg.Done()
|
<-stop
|
||||||
logLine := "Web server is running on port 3333"
|
|
||||||
log.Println(logLine)
|
|
||||||
|
|
||||||
if err := webServer.ListenAndServe(); err != http.ErrServerClosed {
|
ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
|
||||||
log.Fatalf("Web server failed: %s", err)
|
defer cancel()
|
||||||
}
|
_ = srv.Shutdown(ctx)
|
||||||
|
|
||||||
err := logger.InsertLog(logger.Info, logLine)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error inserting log:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
shutdownCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
<-shutdownCh
|
|
||||||
log.Println("Shutdown signal received")
|
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
|
|
||||||
if err := websocketServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Printf("error shutting down websocket server: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := webServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Printf("Error shutting down web server: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
log.Println("All servers stopped")
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,7 @@ func CloseDB() {
|
||||||
if Agent_db != nil {
|
if Agent_db != nil {
|
||||||
err := Agent_db.Close()
|
err := Agent_db.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error closing database: %v", err)
|
log.Fatalf("Error closing database: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
// src/server/webapp/agents_handler_html_test.go
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startStubAgentNameServer spins up an HTTP server **on :5555**
|
||||||
|
// so getAgentsStatus() succeeds during tests.
|
||||||
|
func startStubAgentNameServer(t *testing.T) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Prepare a listener on the exact address getAgentsStatus() calls.
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:5555")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cannot bind :5555 (is another process using it?): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/agentNames", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`["alpha","beta"]`))
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{Handler: mux}
|
||||||
|
go srv.Serve(l)
|
||||||
|
|
||||||
|
// Return cleanup function.
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build App with sqlmock that returns an empty agents result.
|
||||||
|
func newAppForAgents(t *testing.T) *App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpl, err := ParseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTemplates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlmock with regexp matcher
|
||||||
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any SELECT from agents returns zero rows
|
||||||
|
mock.ExpectQuery("(?i)select .* from agents").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"agent_id", "agent_name", "status"}))
|
||||||
|
|
||||||
|
return &App{Tmpl: tmpl, DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsHandler_ListHTML(t *testing.T) {
|
||||||
|
cleanup := startStubAgentNameServer(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
app := newAppForAgents(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.agentsHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("Content‑Type = %q; want text/html", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "<table") {
|
||||||
|
t.Errorf("agents list HTML missing <table>")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package webapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gontrol/src/server/webapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouter_Homepage(t *testing.T) {
|
||||||
|
tmpl, err := webapp.ParseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse templates: %v",err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appDB, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer appDB.Close()
|
||||||
|
|
||||||
|
app := &webapp.App{
|
||||||
|
Tmpl: tmpl,
|
||||||
|
DB: appDB,
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := httptest.NewServer(webapp.BuildRouter(app))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
res, err := http.Get(ts.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
if !strings.Contains(string(body), "<title>") {
|
||||||
|
t.Errorf("homepage HTML missing <title")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,232 @@
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gontrol/src/logger"
|
||||||
|
"gontrol/src/server/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
Tmpl *template.Template
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderTemplate(w http.ResponseWriter, name string, data any) {
|
||||||
|
t := a.Tmpl.Lookup(name)
|
||||||
|
if t == nil {
|
||||||
|
log.Printf("Template %s not found", name)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.Execute(w, data); err != nil {
|
||||||
|
log.Printf("Failed to render %s: %v", name, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Handlers
|
||||||
|
//
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func (a *App) getHomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.renderTemplate(w, "index.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) agentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/agents/"), "/")
|
||||||
|
agentId := ""
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
agentId = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodDelete:
|
||||||
|
api.DeleteAgent(a.DB, w, r, agentId)
|
||||||
|
a.listAgents(w,r)
|
||||||
|
case http.MethodGet:
|
||||||
|
if agentId == "" {
|
||||||
|
a.listAgents(w, r)
|
||||||
|
} else {
|
||||||
|
agent, _ := api.GetAgent(a.DB, w, r, agentId)
|
||||||
|
a.renderTemplate(w, "agent_detail.html", agent)
|
||||||
|
}
|
||||||
|
case http.MethodPost:
|
||||||
|
api.CreateAgent(a.DB, w, r)
|
||||||
|
a.listAgents(w, r)
|
||||||
|
case http.MethodPut:
|
||||||
|
api.UpdateAgent(a.DB, w, r, agentId)
|
||||||
|
a.listAgents(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) listAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var agents []api.Agent
|
||||||
|
agents, err := api.GetAgents(a.DB)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to fetch agents", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAgents := a.getAgentsStatus()
|
||||||
|
|
||||||
|
for i := range agents {
|
||||||
|
|
||||||
|
if slices.Contains(currentAgents, agents[i].AgentName) {
|
||||||
|
agents[i].Status = "Connected"
|
||||||
|
} else {
|
||||||
|
agents[i].Status = "Disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
jsonData, err := json.Marshal(agents)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode agents to JSON", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(jsonData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderTemplate(w, "agent_list.html", agents)
|
||||||
|
// renderTemplate(w, "agent_list.html", agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) logsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Warning this bit me in the nose: var countStr is []string, but
|
||||||
|
// variable = countStr[0] is casted to int automatically
|
||||||
|
// when the string is a number. Jesus Christ, this is odd behavior!
|
||||||
|
levels := r.URL.Query()["level"]
|
||||||
|
countStr := r.URL.Query()["limit"]
|
||||||
|
var limit int = 128
|
||||||
|
if len(countStr) > 0 {
|
||||||
|
parsedCount, err := strconv.Atoi(countStr[0])
|
||||||
|
if err == nil {
|
||||||
|
limit = parsedCount
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Invalid count value", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This enables not only `level` GET parameters but also selecting by paths
|
||||||
|
// For example /logs/error is now identical to /logs?level=error
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/logs/"), "/")
|
||||||
|
if (len(levels) == 0) && len(parts) > 0 && parts[0] != "" {
|
||||||
|
levels = []string{parts[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the police... I mean logger
|
||||||
|
logs, err := logger.FetchLogs(limit, levels)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error fetching logs", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a.renderTemplate(w, "logs_partial.html", logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getAgentsStatus() []string {
|
||||||
|
resp, err := http.Get("http://localhost:5555/agentNames")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error fetching agent names:", err)
|
||||||
|
logger.InsertLog(logger.Error, "Error fetching agent names from websocketServer")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var agentNames []string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&agentNames); err != nil {
|
||||||
|
log.Println("Error decoding response:", err)
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getAgentNames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
api.GetAgentNames(a.DB, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getAgentIds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
api.GetAgentIds(a.DB, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyAgentHandler tunnels HTTP and WebSocket traffic to an agent
|
||||||
|
// selected by ?ip=…&port=… . It keeps the path *after* /proxyAgent,
|
||||||
|
// removes the two query parameters, disables HTTP/2 (mandatory for WS),
|
||||||
|
// and streams with no buffering.
|
||||||
|
func (a *App) proxyAgentHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := r.URL.Query().Get("ip")
|
||||||
|
port := r.URL.Query().Get("port")
|
||||||
|
if ip == "" || port == "" {
|
||||||
|
http.Error(w, "ip and port query parameters are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We leave the scheme "http" even for WebSockets – the Upgrade
|
||||||
|
// header does the rest. (Only cosmetic to change it to "ws”.)
|
||||||
|
target, err := url.Parse("http://" + ip + ":" + port)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid ip/port: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Director: func(req *http.Request) {
|
||||||
|
// Point to the agent
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
|
||||||
|
// Trim the first "/proxyAgent" prefix
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/proxyAgent") {
|
||||||
|
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/proxyAgent")
|
||||||
|
if req.URL.Path == "" {
|
||||||
|
req.URL.Path = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub ip/port from downstream query
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Del("ip")
|
||||||
|
q.Del("port")
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Preserve Host (many CLI-style servers care)
|
||||||
|
req.Host = target.Host
|
||||||
|
},
|
||||||
|
|
||||||
|
// Critical tweaks for WebSockets
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||||
|
ForceAttemptHTTP2: false, // MUST be HTTP/1.1 for WS
|
||||||
|
ResponseHeaderTimeout: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
FlushInterval: -1, // stream bytes immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gontrol/src/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestApp(t *testing.T) (*App, sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpl, err := ParseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTemplates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
return &App{Tmpl: tmpl, DB: db}, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// initFakeLogs puts one record in the in‑memory log DB used by logger.FetchLogs.
|
||||||
|
func initFakeLogs(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if err := logger.InsertLog(logger.Info, "unit‑test"); err != nil {
|
||||||
|
t.Fatalf("InsertLog: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsHandler_HTML(t *testing.T) {
|
||||||
|
tmp, err := os.CreateTemp("", "logs_*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Temp DB: %v", err)
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
if err := logger.InitDB(tmp.Name()); err != nil {
|
||||||
|
t.Fatalf("logger.InitDB: %v", err)
|
||||||
|
}
|
||||||
|
defer logger.CloseDB()
|
||||||
|
|
||||||
|
logger.InsertLog(logger.Error, "fake-log-1")
|
||||||
|
|
||||||
|
app, mock := newTestApp(t)
|
||||||
|
defer app.DB.Close()
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sqlmock expectations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logs/error?limit=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.logsHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||||
|
t.Errorf("Content-Type = %s; want text/html", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "fake-log-1") {
|
||||||
|
t.Errorf("rendered HTML missing our fake log line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that `limit=abc` is rejected with 400 and FetchLogs is **not** hit.
|
||||||
|
func TestLogsHandler_InvalidLimit(t *testing.T) {
|
||||||
|
initFakeLogs(t)
|
||||||
|
|
||||||
|
app, _ := newTestApp(t) // helper from previous file
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logs?limit=abc", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.logsHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d; want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Invalid count value") {
|
||||||
|
t.Errorf("missing error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test JSON response when client sends Accept: application/json.
|
||||||
|
func TestLogsHandler_JSON(t *testing.T) {
|
||||||
|
initFakeLogs(t)
|
||||||
|
|
||||||
|
app, _ := newTestApp(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logs?limit=1", nil)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.logsHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content‑Type = %q; want application/json", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "unit‑test") {
|
||||||
|
t.Errorf("JSON body missing expected log entry")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html templates/partials/*.html static/*
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// App is defined in handlers.go and called in main.go
|
||||||
|
func BuildRouter(app *App) *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/", app.getHomePage)
|
||||||
|
mux.HandleFunc("/agents", app.agentsHandler)
|
||||||
|
mux.HandleFunc("/agents/{agentId}", app.agentsHandler)
|
||||||
|
mux.HandleFunc("/agentNames", app.getAgentNames)
|
||||||
|
mux.HandleFunc("/agentIds", app.getAgentIds)
|
||||||
|
mux.HandleFunc("/logs", app.logsHandler)
|
||||||
|
mux.HandleFunc("/logs/{level}", app.logsHandler)
|
||||||
|
mux.HandleFunc("/proxyAgent", app.proxyAgentHandler)
|
||||||
|
mux.HandleFunc("/proxyAgent/", app.proxyAgentHandler)
|
||||||
|
|
||||||
|
staticSub, _ := fs.Sub(assets, "static")
|
||||||
|
mux.Handle("/static/",
|
||||||
|
http.StripPrefix("/static/",
|
||||||
|
http.FileServer(http.FS(staticSub)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all templates from the embedded FS
|
||||||
|
// Called in main() and passed into &App{Tmp: tmpl)
|
||||||
|
func ParseTemplates() (*template.Template, error) {
|
||||||
|
return template.ParseFS(
|
||||||
|
assets,
|
||||||
|
"templates/*.html",
|
||||||
|
"templates/partials/*.html",
|
||||||
|
)
|
||||||
|
}
|
Before Width: | Height: | Size: 655 B After Width: | Height: | Size: 655 B |
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 673 B After Width: | Height: | Size: 673 B |
|
@ -0,0 +1,54 @@
|
||||||
|
// src/server/webapp/static_files_test.go
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findFirstStaticFile walks the embedded FS and returns the first file path.
|
||||||
|
func findFirstStaticFile() (webPath string, content []byte, ok bool) {
|
||||||
|
_ = fs.WalkDir(assets, "static", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if !d.IsDir() && ok == false {
|
||||||
|
data, _ := assets.ReadFile(path) // ignore err; test will skip if nil
|
||||||
|
webPath = "/" + path // e.g. /static/css/main.css
|
||||||
|
content = data
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requires at least one file under static/. Skips if none embedded.
|
||||||
|
func TestStaticFileServer(t *testing.T) {
|
||||||
|
webPath, wantBytes, ok := findFirstStaticFile()
|
||||||
|
if !ok {
|
||||||
|
t.Skip("no embedded static files to test")
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------
|
||||||
|
// build router with sqlmock DB (not used in this test)
|
||||||
|
//-----------------------------------------------------------------
|
||||||
|
app, _ := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(BuildRouter(app))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
res, err := http.Get(ts.URL + webPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", webPath, err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotBytes, _ := io.ReadAll(res.Body)
|
||||||
|
if len(gotBytes) == 0 || string(gotBytes) != string(wantBytes) {
|
||||||
|
t.Errorf("served file differs from embedded asset")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplatesExecute(t *testing.T ) {
|
||||||
|
tmpl, err := ParseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTemplates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := tmpl.Templates()
|
||||||
|
for _, tt := range names {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.ExecuteTemplate(&buf, tt.Name(), nil); err != nil {
|
||||||
|
t.Errorf("template %s failed: %v", tt.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,80 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Web Server Logs</title>
|
|
||||||
<script src="https://unpkg.com/htmx.org@1.8.5"></script>
|
|
||||||
<style>
|
|
||||||
.log-info {
|
|
||||||
color: green;
|
|
||||||
}
|
|
||||||
.log-warning {
|
|
||||||
color: orange;
|
|
||||||
}
|
|
||||||
.log-error {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
.log-fatal {
|
|
||||||
color: blue;
|
|
||||||
}
|
|
||||||
.log-debug {
|
|
||||||
color: violet;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Web Server Logs</h1>
|
|
||||||
|
|
||||||
<div id="logs-container">
|
|
||||||
<!-- Logs will be injected here -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML">
|
|
||||||
Load Logs
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML" hx-trigger="every 2s">
|
|
||||||
Auto-Refresh Logs
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function renderLogs(logs) {
|
|
||||||
const container = document.getElementById('logs-container');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
logs.forEarch(log => {
|
|
||||||
const logElement = document.createElement('p');
|
|
||||||
logElement.innerHTML = `<strong>level=${log.level}</strong> msg=${log.message}`;
|
|
||||||
|
|
||||||
if (log.level ==== 'INFO') {
|
|
||||||
logElement.classList.add('log-info');
|
|
||||||
} else if (log.level === 'WARNING') {
|
|
||||||
logElement.classList.add('log'warning);
|
|
||||||
} else if (log.level === 'ERROR') {
|
|
||||||
logElement.classList.add('log-warning');
|
|
||||||
} else if (log.level === 'FATAL') {
|
|
||||||
logElement.classList.add('log-fatal');
|
|
||||||
} else if (log.level === 'DEBUG') {
|
|
||||||
logElement.classList.add('log-debug');
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(logElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchLogs() {
|
|
||||||
fetch('/logs')
|
|
||||||
.then(response -> response.json())
|
|
||||||
.then(data => renderLogs(data))
|
|
||||||
.catch(error -> console.error('Error fetching logs:', error));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event){
|
|
||||||
if (event.target.id === 'logs-container') {
|
|
||||||
fetchLogs();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|