diff --git a/go.mod b/go.mod
index 7eecfcd..08fbd79 100644
--- a/go.mod
+++ b/go.mod
@@ -3,10 +3,10 @@ module gontrol
go 1.23.4
require (
+ github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/PuerkitoBio/goquery v1.10.1
github.com/go-sql-driver/mysql v1.8.1
github.com/gorilla/websocket v1.5.3
- github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-sqlite3 v1.14.28
)
diff --git a/go.sum b/go.sum
index a87bc7f..69cef94 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
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/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
-github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
+github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
diff --git a/gomatic.sql b/gomatic.sql
deleted file mode 100644
index df94bc7..0000000
--- a/gomatic.sql
+++ /dev/null
@@ -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());
diff --git a/main.go b/main.go
index e75e5be..486bb26 100644
--- a/main.go
+++ b/main.go
@@ -2,356 +2,53 @@ package main
import (
"context"
- "encoding/json"
- "embed"
- "os"
- "strings"
- "time"
- "slices"
- "database/sql"
- "fmt"
- "html/template"
"log"
- "net"
"net/http"
- "strconv"
- "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"
"os/signal"
+ "syscall"
+ "time"
- _ "github.com/go-sql-driver/mysql"
- "github.com/kelseyhightower/envconfig"
+ "gontrol/src/logger"
+ "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() {
-
- // sqlite3 has been initialized in init()
- defer logger.CloseDB()
-
- defer db.Close()
-
- var cfg Config
- readEnv(&cfg)
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- var wg sync.WaitGroup
-
- websocketServer := websocketserver.Server()
-
- webMux := http.NewServeMux()
- webMux.HandleFunc("/", getHomepage)
- webMux.HandleFunc("/agents", agentsHandler)
- webMux.HandleFunc("/agentNames", getAgentNames)
- 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,
+ tmpl, err := webapp.ParseTemplates()
+ if err != nil {
+ log.Fatalf("Parse templates: %v", err)
+ }
+
+ db := database.InitSQLiteDB("/tmp/gontrol_agents.db")
+ defer db.Close()
+
+ if err := logger.InitDB("/tmp/gontrol_logs.db"); err != nil {
+ log.Fatalf("Init log db: %v", err)
+ }
+ defer logger.CloseDB()
+
+ app := &webapp.App{Tmpl: tmpl, DB: db}
+
+ srv := &http.Server {
+ Addr: ":3333",
+ Handler: webapp.BuildRouter(app),
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
}
- wg.Add(1)
go func() {
- defer wg.Done()
- logLine := "Websocket server is running on port 5555"
+ log.Println("Web server is running on port :3333")
+ 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)
+ websocketServer := websocketserver.Server()
if err := websocketServer.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Websocket server failed: %s", err)
}
@@ -361,39 +58,11 @@ func main() {
}
}()
- wg.Add(1)
- go func() {
- defer wg.Done()
- logLine := "Web server is running on port 3333"
- log.Println(logLine)
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+ <-stop
- if err := webServer.ListenAndServe(); err != http.ErrServerClosed {
- log.Fatalf("Web server failed: %s", err)
- }
-
- 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")
+ ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
}
diff --git a/src/server/database/database.go b/src/server/database/database.go
index bc06e85..fb07b50 100644
--- a/src/server/database/database.go
+++ b/src/server/database/database.go
@@ -71,7 +71,7 @@ func CloseDB() {
if Agent_db != nil {
err := Agent_db.Close()
if err != nil {
- log.Println("Error closing database: %v", err)
+ log.Fatalf("Error closing database: %v", err)
}
}
}
diff --git a/src/server/webapp/agents_handler_html_test.go b/src/server/webapp/agents_handler_html_test.go
new file mode 100644
index 0000000..a30c43b
--- /dev/null
+++ b/src/server/webapp/agents_handler_html_test.go
@@ -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(), "
")
+ }
+}
diff --git a/src/server/webapp/e2e_test.go b/src/server/webapp/e2e_test.go
new file mode 100644
index 0000000..fa0a374
--- /dev/null
+++ b/src/server/webapp/e2e_test.go
@@ -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), "") {
+ t.Errorf("homepage HTML missing 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)
+}
diff --git a/src/server/webapp/handlers_test.go b/src/server/webapp/handlers_test.go
new file mode 100644
index 0000000..f8036bc
--- /dev/null
+++ b/src/server/webapp/handlers_test.go
@@ -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")
+ }
+}
diff --git a/src/server/webapp/router.go b/src/server/webapp/router.go
new file mode 100644
index 0000000..d4e2a41
--- /dev/null
+++ b/src/server/webapp/router.go
@@ -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",
+ )
+}
diff --git a/static/agents-graph.js b/src/server/webapp/static/agents-graph.js
similarity index 100%
rename from static/agents-graph.js
rename to src/server/webapp/static/agents-graph.js
diff --git a/static/computer-offline.svg b/src/server/webapp/static/computer-offline.svg
similarity index 100%
rename from static/computer-offline.svg
rename to src/server/webapp/static/computer-offline.svg
diff --git a/static/computer-online.svg b/src/server/webapp/static/computer-online.svg
similarity index 100%
rename from static/computer-online.svg
rename to src/server/webapp/static/computer-online.svg
diff --git a/static/download-command.js b/src/server/webapp/static/download-command.js
similarity index 100%
rename from static/download-command.js
rename to src/server/webapp/static/download-command.js
diff --git a/static/gontrol-helper.js b/src/server/webapp/static/gontrol-helper.js
similarity index 100%
rename from static/gontrol-helper.js
rename to src/server/webapp/static/gontrol-helper.js
diff --git a/static/gontrol-stylesheet.css b/src/server/webapp/static/gontrol-stylesheet.css
similarity index 100%
rename from static/gontrol-stylesheet.css
rename to src/server/webapp/static/gontrol-stylesheet.css
diff --git a/static/help-command.js b/src/server/webapp/static/help-command.js
similarity index 100%
rename from static/help-command.js
rename to src/server/webapp/static/help-command.js
diff --git a/static/keyboard-shortcuts.js b/src/server/webapp/static/keyboard-shortcuts.js
similarity index 100%
rename from static/keyboard-shortcuts.js
rename to src/server/webapp/static/keyboard-shortcuts.js
diff --git a/static/server-offline.svg b/src/server/webapp/static/server-offline.svg
similarity index 100%
rename from static/server-offline.svg
rename to src/server/webapp/static/server-offline.svg
diff --git a/static/server-online.svg b/src/server/webapp/static/server-online.svg
similarity index 100%
rename from static/server-online.svg
rename to src/server/webapp/static/server-online.svg
diff --git a/static/start-interactive.js b/src/server/webapp/static/start-interactive.js
similarity index 100%
rename from static/start-interactive.js
rename to src/server/webapp/static/start-interactive.js
diff --git a/static/stylesheet.css b/src/server/webapp/static/stylesheet.css
similarity index 100%
rename from static/stylesheet.css
rename to src/server/webapp/static/stylesheet.css
diff --git a/static/switch-themes.js b/src/server/webapp/static/switch-themes.js
similarity index 100%
rename from static/switch-themes.js
rename to src/server/webapp/static/switch-themes.js
diff --git a/static/xterm/xterm-addon-fit.js b/src/server/webapp/static/xterm/xterm-addon-fit.js
similarity index 100%
rename from static/xterm/xterm-addon-fit.js
rename to src/server/webapp/static/xterm/xterm-addon-fit.js
diff --git a/static/xterm/xterm.css b/src/server/webapp/static/xterm/xterm.css
similarity index 100%
rename from static/xterm/xterm.css
rename to src/server/webapp/static/xterm/xterm.css
diff --git a/static/xterm/xterm.js b/src/server/webapp/static/xterm/xterm.js
similarity index 100%
rename from static/xterm/xterm.js
rename to src/server/webapp/static/xterm/xterm.js
diff --git a/src/server/webapp/static_files_test.go b/src/server/webapp/static_files_test.go
new file mode 100644
index 0000000..0389f6c
--- /dev/null
+++ b/src/server/webapp/static_files_test.go
@@ -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")
+ }
+}
diff --git a/src/server/webapp/template_test.go b/src/server/webapp/template_test.go
new file mode 100644
index 0000000..7535c48
--- /dev/null
+++ b/src/server/webapp/template_test.go
@@ -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)
+ }
+ }
+}
diff --git a/templates/index.html b/src/server/webapp/templates/index.html
similarity index 100%
rename from templates/index.html
rename to src/server/webapp/templates/index.html
diff --git a/templates/partials/agent_detail.html b/src/server/webapp/templates/partials/agent_detail.html
similarity index 100%
rename from templates/partials/agent_detail.html
rename to src/server/webapp/templates/partials/agent_detail.html
diff --git a/templates/partials/agent_list.html b/src/server/webapp/templates/partials/agent_list.html
similarity index 100%
rename from templates/partials/agent_list.html
rename to src/server/webapp/templates/partials/agent_list.html
diff --git a/templates/partials/logs_partial.html b/src/server/webapp/templates/partials/logs_partial.html
similarity index 100%
rename from templates/partials/logs_partial.html
rename to src/server/webapp/templates/partials/logs_partial.html
diff --git a/src/server/webapp/webapp.go b/src/server/webapp/webapp.go
deleted file mode 100644
index e69de29..0000000
diff --git a/templates/logs.html b/templates/logs.html
deleted file mode 100644
index 03900f7..0000000
--- a/templates/logs.html
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
- Web Server Logs
-
-
-
-
- Web Server Logs
-
-
-
-
-
-
-
-
-
-
-
-