refractoring, added test
2
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
|
||||
)
|
||||
|
||||
|
|
5
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=
|
||||
|
|
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 (
|
||||
"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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|