refactor(correction to folder sturcture): before we got to deep resturctures to best pactice folder

This commit is contained in:
2025-08-01 15:54:20 -05:00
parent b6968b7b67
commit c775bb3354
32 changed files with 476 additions and 601 deletions

51
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,51 @@
package db
import (
"fmt"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"lst.net/internal/models"
)
var DB *gorm.DB
type DBConfig struct {
DB *gorm.DB
DSN string
}
func InitDB() (*DBConfig, error) {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s",
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"))
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
fmt.Println("✅ Connected to database")
// ensures we have the uuid stuff setup properly
DB.Exec(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`)
err = DB.AutoMigrate(&models.Log{}, &models.Settings{}) // &ClientRecord{}, &Servers{}
if err != nil {
return nil, fmt.Errorf("failed to auto-migrate models: %v", err)
}
fmt.Println("✅ Database migration completed successfully")
return &DBConfig{
DB: DB,
DSN: dsn,
}, nil
}

View File

@@ -0,0 +1,77 @@
package db
import (
"log"
"gorm.io/gorm"
"lst.net/internal/models"
)
var seedConfigData = []models.Settings{
{Name: "serverPort", Description: "The port the server will listen on if not running in docker", Value: "4000", Enabled: true, AppService: "server"},
{Name: "server", Description: "The server we will use when connecting to the alplaprod sql", Value: "usmcd1vms006", Enabled: true, AppService: "server"},
{Name: "timezone", Value: "America/Chicago", Description: "What time zone is the server in this is used for cronjobs and some other time stuff", AppService: "server", Enabled: true},
{Name: "dbUser", Value: "alplaprod", Description: "What is the db userName", AppService: "server", Enabled: true},
{Name: "dbPass", Value: "b2JlbGl4", Description: "What is the db password", AppService: "server", Enabled: true},
{Name: "tcpPort", Value: "2222", Description: "TCP port for printers to connect send data and the zedra cameras", AppService: "server", Enabled: true},
{Name: "prolinkCheck", Value: "1", Description: "Will prolink be considered to check if matches, maninly used in plants that do not fully utilize prolink + ocp", AppService: "production", Enabled: true},
{Name: "bookin", Value: "1", Description: "do we want to book in after a label is printed", AppService: "ocp", Enabled: true},
{Name: "dbServer", Value: "usmcd1vms036", Description: "What server is the prod db on?", AppService: "server", Enabled: true},
{Name: "printDelay", Value: "90", Description: "How long in seconds between prints", AppService: "ocp", Enabled: true},
{Name: "plantToken", Value: "test3", Description: "What is the plant token", AppService: "server", Enabled: true},
{Name: "dualPrinting", Value: "0", Description: "Dose the plant have 2 machines that go to 1?", AppService: "ocp", Enabled: true},
{Name: "ocmeService", Value: "0", Description: "Is the ocme service enabled. this is gernerally only for Dayton.", AppService: "ocme", Enabled: true},
{Name: "fifoCheck", Value: "45", Description: "How far back do we want to check for fifo default 45, putting 0 will ignore.", AppService: "ocme", Enabled: true},
{Name: "dayCheck", Value: "3", Description: "how many days +/- to check for shipments in alplaprod", AppService: "ocme", Enabled: true},
{Name: "maxLotPerTruck", Value: "3", Description: "How mant lots can we have per truck?", AppService: "ocme", Enabled: true},
{Name: "monitorAddress", Value: "8", Description: "What address is monitored to be limited to the amount of lots that can be added to a truck.", AppService: "ocme", Enabled: true},
{Name: "ocmeCycleCount", Value: "1", Description: "Are we allowing ocme cycle counts?", AppService: "ocme", Enabled: true},
{Name: "devDir", Value: "", Description: "This is the dev dir and strictly only for updating the servers.", AppService: "server", Enabled: true},
{Name: "demandMGTActivated", Value: "0", Description: "Do we allow for new fake edi?", AppService: "logistics", Enabled: true},
{Name: "qualityRequest", Value: "0", Description: "quality request module?", AppService: "quality", Enabled: true},
{Name: "ocpLogsCheck", Value: "4", Description: "How long do we want to allow logs to show that have not been cleared?", AppService: "ocp", Enabled: true},
{Name: "inhouseDelivery", Value: "0", Description: "Are we doing auto inhouse delivery?", AppService: "ocp", Enabled: true},
// dyco settings
{Name: "dycoConnect", Value: "0", Description: "Are we running the dyco system?", AppService: "dycp", Enabled: true},
{Name: "dycoPrint", Value: "0", Description: "Are we using the dyco to get the labels or the rfid?", AppService: "dyco", Enabled: true},
{Name: "strapperCheck", Value: "1", Description: "Are we monitoring the strapper for faults?", AppService: "dyco", Enabled: true},
// ocp
{Name: "ocpActive", Value: `1`, Description: "Are we pritning on demand?", AppService: "ocp", Enabled: true},
{Name: "ocpCycleDelay", Value: `10`, Description: "How long between printer cycles do we want to monitor.", AppService: "ocp", Enabled: true},
{Name: "pNgAddress", Value: `139`, Description: "What is the address for p&g so we can make sure we have the correct fake edi forcast going in.", AppService: "logisitcs", Enabled: true},
{Name: "scannerID", Value: `500`, Description: "What scanner id will we be using for the app", AppService: "logistics", Enabled: true},
{Name: "scannerPort", Value: `50002`, Description: "What port instance will we be using?", AppService: "logistics", Enabled: true},
{Name: "stagingReturnLocations", Value: `30125,31523`, Description: "What are the staging location IDs we will use to select from. seperated by commas", AppService: "logistics", Enabled: true},
}
func SeedSettings(db *gorm.DB) error {
for _, cfg := range seedConfigData {
var existing models.Settings
// Try to find config by unique Name
result := db.Where("Name =?", cfg.Name).First(&existing)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// not here lets add it
if err := db.Create(&cfg).Error; err != nil {
log.Printf("Failed to seed config %s: %v", cfg.Name, err)
}
//log.Printf("Seeded new config: %s", cfg.Name)
} else {
// Some other error
return result.Error
}
} else {
// only update the fields we want to update.
existing.Description = cfg.Description
if err := db.Save(&existing).Error; err != nil {
log.Printf("Failed to update config %s: %v", cfg.Name, err)
return err
}
//log.Printf("Updated existing config: %s", cfg.Name)
}
}
return nil
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"lst.net/pkg"
)
type Log struct {
LogID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey" json:"id"`
Level string `gorm:"size:10;not null"` // "info", "error", etc.
Message string `gorm:"not null"`
Service string `gorm:"size:50"`
Metadata pkg.JSONB `gorm:"type:jsonb"` // fields (e.g., {"user_id": 123})
CreatedAt time.Time `gorm:"index"`
Checked bool `gorm:"type:boolean;default:false"`
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/google/uuid"
"lst.net/pkg"
)
type Servers struct {
ServerID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey" json:"id"`
ServerName string `gorm:"size:50;not null"`
ServerDNS string `gorm:"size:25;not null"`
PlantToken string `gorm:"size:10;not null"`
IPAddress string `gorm:"size:16;not null"`
GreatPlainsPlantCode int `gorm:"size:10;not null"`
StreetAddress string `gorm:"size:255;not null"`
CityState string `gorm:"size:50;not null"`
Zipcode int `gorm:"size:13;not null"`
ContactEmail string `gorm:"size:255"`
ContactPhone string `gorm:"size:255"`
CustomerTiAcc string `gorm:"size:255"`
LstServerPort int `gorm:"size:255; not null"`
Active bool `gorm:"type:boolean;default:true"`
LerverLoc string `gorm:"size:255:not null"`
LastUpdated time.Time `gorm:"index"`
ShippingHours pkg.JSONB `gorm:"type:jsonb;default:'[{\"early\": \"06:30\", \"late\": \"23:00\"}]'"`
TiPostTime pkg.JSONB `gorm:"type:jsonb;default:'[{\"from\": \"24\", \"to\": \"24\"}]'"`
OtherSettings pkg.JSONB `gorm:"type:jsonb;default:'[{\"specialInstructions\": \"something for ti\", \"active\": false}]'"`
IsUpgrading bool `gorm:"type:boolean;default:true"`
AlplaProdApiKey string `gorm:"size:255"`
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Settings struct {
SettingID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;not null"`
Description string `gorm:"type:text"`
Value string `gorm:"not null"`
Enabled bool `gorm:"default:true"`
AppService string `gorm:"default:system"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
"lst.net/pkg"
)
type ClientRecord struct {
ClientID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey" json:"id"`
APIKey string `gorm:"not null"`
IPAddress string `gorm:"not null"`
UserAgent string `gorm:"size:255"`
ConnectedAt time.Time `gorm:"index"`
LastHeartbeat time.Time `gorm:"column:last_heartbeat"`
Channels pkg.JSONB `gorm:"type:jsonb"`
CreatedAt time.Time
UpdatedAt time.Time
DisconnectedAt *time.Time `gorm:"column:disconnected_at"`
}

View File

@@ -0,0 +1,179 @@
package ws
import (
"encoding/json"
"log"
"strings"
"sync"
"lst.net/pkg/logger"
)
type Channel struct {
Name string
Clients map[*Client]bool
Register chan *Client
Unregister chan *Client
Broadcast chan []byte
lock sync.RWMutex
}
var (
channels = make(map[string]*Channel)
channelsMu sync.RWMutex
)
// InitializeChannels creates and returns all channels
func InitializeChannels() {
channelsMu.Lock()
defer channelsMu.Unlock()
channels["logServices"] = NewChannel("logServices")
channels["labels"] = NewChannel("labels")
// Add more channels here as needed
}
func NewChannel(name string) *Channel {
return &Channel{
Name: name,
Clients: make(map[*Client]bool),
Register: make(chan *Client),
Unregister: make(chan *Client),
Broadcast: make(chan []byte),
}
}
func GetChannel(name string) (*Channel, bool) {
channelsMu.RLock()
defer channelsMu.RUnlock()
ch, exists := channels[name]
return ch, exists
}
func GetAllChannels() map[string]*Channel {
channelsMu.RLock()
defer channelsMu.RUnlock()
chs := make(map[string]*Channel)
for k, v := range channels {
chs[k] = v
}
return chs
}
func StartAllChannels() {
channelsMu.RLock()
defer channelsMu.RUnlock()
for _, ch := range channels {
go ch.RunChannel()
}
}
func CleanupChannels() {
channelsMu.Lock()
defer channelsMu.Unlock()
for _, ch := range channels {
close(ch.Broadcast)
// Add any other cleanup needed
}
channels = make(map[string]*Channel)
}
func StartBroadcasting(broadcaster chan logger.Message, channels map[string]*Channel) {
logger := logger.New()
go func() {
for msg := range broadcaster {
switch msg.Channel {
case "logServices":
// Just forward the message - filtering happens in RunChannel()
messageBytes, err := json.Marshal(msg)
if err != nil {
logger.Error("Error marshaling message", "websocket", map[string]interface{}{
"errors": err,
})
continue
}
channels["logServices"].Broadcast <- messageBytes
case "labels":
// Future labels handling
messageBytes, err := json.Marshal(msg)
if err != nil {
logger.Error("Error marshaling message", "websocket", map[string]interface{}{
"errors": err,
})
continue
}
channels["labels"].Broadcast <- messageBytes
default:
log.Printf("Received message for unknown channel: %s", msg.Channel)
}
}
}()
}
func contains(slice []string, item string) bool {
// Empty filter slice means "match all"
if len(slice) == 0 {
return true
}
// Case-insensitive comparison
item = strings.ToLower(item)
for _, s := range slice {
if strings.ToLower(s) == item {
return true
}
}
return false
}
// Updated Channel.RunChannel() for logServices filtering
func (ch *Channel) RunChannel() {
for {
select {
case client := <-ch.Register:
ch.lock.Lock()
ch.Clients[client] = true
ch.lock.Unlock()
case client := <-ch.Unregister:
ch.lock.Lock()
delete(ch.Clients, client)
ch.lock.Unlock()
case message := <-ch.Broadcast:
var msg logger.Message
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
ch.lock.RLock()
for client := range ch.Clients {
// Special filtering for logServices
if ch.Name == "logServices" {
logLevel, _ := msg.Meta["level"].(string)
logService, _ := msg.Meta["service"].(string)
levelMatch := len(client.LogLevels) == 0 || contains(client.LogLevels, logLevel)
serviceMatch := len(client.Services) == 0 || contains(client.Services, logService)
if !levelMatch || !serviceMatch {
continue
}
}
select {
case client.Send <- message:
default:
ch.Unregister <- client
}
}
ch.lock.RUnlock()
}
}
}

View File

@@ -0,0 +1,275 @@
package ws
import (
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"lst.net/internal/db"
"lst.net/internal/models"
"lst.net/pkg"
"lst.net/pkg/logger"
)
var (
clients = make(map[*Client]bool)
clientsMu sync.RWMutex
)
type Client struct {
ClientID uuid.UUID `json:"client_id"`
Conn *websocket.Conn `json:"-"` // Excluded from JSON
APIKey string `json:"api_key"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Send chan []byte `json:"-"` // Excluded from JSON
Channels map[string]bool `json:"channels"`
LogLevels []string `json:"levels,omitempty"`
Services []string `json:"services,omitempty"`
Labels []string `json:"labels,omitempty"`
ConnectedAt time.Time `json:"connected_at"`
done chan struct{} // For graceful shutdown
isAlive atomic.Bool
lastActive time.Time // Tracks last activity
}
func (c *Client) SaveToDB() {
// Convert c.Channels (map[string]bool) to map[string]interface{} for JSONB
channels := make(map[string]interface{})
for ch := range c.Channels {
channels[ch] = true
}
clientRecord := &models.ClientRecord{
APIKey: c.APIKey,
IPAddress: c.IPAddress,
UserAgent: c.UserAgent,
Channels: pkg.JSONB(channels),
ConnectedAt: time.Now(),
LastHeartbeat: time.Now(),
}
if err := db.DB.Create(&clientRecord).Error; err != nil {
log.Println("❌ Error saving client:", err)
} else {
c.ClientID = clientRecord.ClientID
c.ConnectedAt = clientRecord.ConnectedAt
}
}
func (c *Client) MarkDisconnected() {
logger := logger.New()
clientData := fmt.Sprintf("Client %v just lefts us", c.ClientID)
logger.Info(clientData, "websocket", map[string]interface{}{})
now := time.Now()
res := db.DB.Model(&models.ClientRecord{}).
Where("client_id = ?", c.ClientID).
Updates(map[string]interface{}{
"disconnected_at": &now,
})
if res.RowsAffected == 0 {
logger.Info("⚠️ No rows updated for client_id", "websocket", map[string]interface{}{
"clientID": c.ClientID,
})
}
if res.Error != nil {
logger.Error("❌ Error updating disconnected_at", "websocket", map[string]interface{}{
"clientID": c.ClientID,
"error": res.Error,
})
}
}
func (c *Client) SafeClient() *Client {
return &Client{
ClientID: c.ClientID,
APIKey: c.APIKey,
IPAddress: c.IPAddress,
UserAgent: c.UserAgent,
Channels: c.Channels,
LogLevels: c.LogLevels,
Services: c.Services,
Labels: c.Labels,
ConnectedAt: c.ConnectedAt,
}
}
// GetAllClients returns safe representations of all clients
func GetAllClients() []*Client {
clientsMu.RLock()
defer clientsMu.RUnlock()
var clientList []*Client
for client := range clients {
clientList = append(clientList, client.SafeClient())
}
return clientList
}
// GetClientsByChannel returns clients in a specific channel
func GetClientsByChannel(channel string) []*Client {
clientsMu.RLock()
defer clientsMu.RUnlock()
var channelClients []*Client
for client := range clients {
if client.Channels[channel] {
channelClients = append(channelClients, client.SafeClient())
}
}
return channelClients
}
// heat beat stuff
const (
pingPeriod = 30 * time.Second
pongWait = 60 * time.Second
writeWait = 10 * time.Second
)
func (c *Client) StartHeartbeat() {
logger := logger.New()
log.Println("Started hearbeat")
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if !c.isAlive.Load() { // Correct way to read atomic.Bool
return
}
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Printf("Heartbeat failed for %s: %v", c.ClientID, err)
c.Close()
return
}
now := time.Now()
res := db.DB.Model(&models.ClientRecord{}).
Where("client_id = ?", c.ClientID).
Updates(map[string]interface{}{
"last_heartbeat": &now,
})
if res.RowsAffected == 0 {
logger.Info("⚠️ No rows updated for client_id", "websocket", map[string]interface{}{
"clientID": c.ClientID,
})
}
if res.Error != nil {
logger.Error("❌ Error updating disconnected_at", "websocket", map[string]interface{}{
"clientID": c.ClientID,
"error": res.Error,
})
}
case <-c.done:
return
}
}
}
func (c *Client) Close() {
if c.isAlive.CompareAndSwap(true, false) { // Atomic swap
close(c.done)
c.Conn.Close()
// Add any other cleanup here
c.MarkDisconnected()
}
}
func (c *Client) startServerPings() {
ticker := time.NewTicker(60 * time.Second) // Ping every 30s
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
c.Close() // Disconnect if ping fails
return
}
case <-c.done:
return
}
}
}
func (c *Client) markActive() {
c.lastActive = time.Now() // No mutex needed if single-writer
}
func (c *Client) IsActive() bool {
return time.Since(c.lastActive) < 45*time.Second // 1.5x ping interval
}
func (c *Client) updateHeartbeat() {
//fmt.Println("Updating heatbeat")
now := time.Now()
logger := logger.New()
//fmt.Printf("Updating heartbeat for client: %s at %v\n", c.ClientID, now)
//db.DB = db.DB.Debug()
res := db.DB.Model(&models.ClientRecord{}).
Where("client_id = ?", c.ClientID).
Updates(map[string]interface{}{
"last_heartbeat": &now, // Explicit format
})
//fmt.Printf("Executed SQL: %v\n", db.DB.Statement.SQL.String())
if res.RowsAffected == 0 {
logger.Info("⚠️ No rows updated for client_id", "websocket", map[string]interface{}{
"clientID": c.ClientID,
})
}
if res.Error != nil {
logger.Error("❌ Error updating disconnected_at", "websocket", map[string]interface{}{
"clientID": c.ClientID,
"error": res.Error,
})
}
// 2. Verify DB connection
if db.DB == nil {
logger.Error("DB connection is nil", "websocket", map[string]interface{}{})
return
}
// 3. Test raw SQL execution first
testRes := db.DB.Exec("SELECT 1")
if testRes.Error != nil {
logger.Error("DB ping failed", "websocket", map[string]interface{}{
"error": testRes.Error,
})
return
}
}
// work on this stats later
// Add to your admin endpoint
// type ConnectionStats struct {
// TotalConnections int `json:"total_connections"`
// ActiveConnections int `json:"active_connections"`
// AvgDuration string `json:"avg_duration"`
// }
// func GetConnectionStats() ConnectionStats {
// // Implement your metrics tracking
// }

View File

@@ -0,0 +1,224 @@
package ws
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
type JoinPayload struct {
Channel string `json:"channel"`
APIKey string `json:"apiKey"`
Services []string `json:"services,omitempty"`
Levels []string `json:"levels,omitempty"`
Labels []string `json:"labels,omitempty"`
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // allow all origins; customize for prod
HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
EnableCompression: true,
}
func SocketHandler(c *gin.Context, channels map[string]*Channel) {
// Upgrade HTTP to WebSocket
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Println("WebSocket upgrade failed:", err)
return
}
//defer conn.Close()
// Create new client
client := &Client{
Conn: conn,
APIKey: "exampleAPIKey",
Send: make(chan []byte, 256), // Buffered channel
Channels: make(map[string]bool),
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
done: make(chan struct{}),
}
client.isAlive.Store(true)
// Add to global clients map
clientsMu.Lock()
clients[client] = true
clientsMu.Unlock()
// Save initial connection to DB
client.SaveToDB()
// Save initial connection to DB
// if err := client.SaveToDB(); err != nil {
// log.Println("Failed to save client to DB:", err)
// conn.Close()
// return
// }
// Set handlers
conn.SetPingHandler(func(string) error {
return nil // Auto-responds with pong
})
conn.SetPongHandler(func(string) error {
now := time.Now()
client.markActive() // Track last pong time
client.lastActive = now
client.updateHeartbeat()
return nil
})
// Start server-side ping ticker
go client.startServerPings()
defer func() {
// Unregister from all channels
for channelName := range client.Channels {
if ch, exists := channels[channelName]; exists {
ch.Unregister <- client
}
}
// Remove from global clients map
clientsMu.Lock()
delete(clients, client)
clientsMu.Unlock()
// Mark disconnected in DB
client.MarkDisconnected()
// Close connection
conn.Close()
log.Printf("Client disconnected: %s", client.ClientID)
}()
// Send welcome message immediately
welcomeMsg := map[string]string{
"status": "connected",
"message": "Welcome to the WebSocket server. Send subscription request to begin.",
}
if err := conn.WriteJSON(welcomeMsg); err != nil {
log.Println("Failed to send welcome message:", err)
return
}
// Message handling goroutine
go func() {
defer func() {
// Cleanup on disconnect
for channelName := range client.Channels {
if ch, exists := channels[channelName]; exists {
ch.Unregister <- client
}
}
close(client.Send)
client.MarkDisconnected()
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Printf("Client disconnected unexpectedly: %v", err)
}
break
}
var payload struct {
Channel string `json:"channel"`
APIKey string `json:"apiKey"`
Services []string `json:"services,omitempty"`
Levels []string `json:"levels,omitempty"`
Labels []string `json:"labels,omitempty"`
}
if err := json.Unmarshal(msg, &payload); err != nil {
conn.WriteJSON(map[string]string{"error": "invalid payload format"})
continue
}
// Validate API key (implement your own validateAPIKey function)
// if payload.APIKey == "" || !validateAPIKey(payload.APIKey) {
// conn.WriteJSON(map[string]string{"error": "invalid or missing API key"})
// continue
// }
if payload.APIKey == "" {
conn.WriteMessage(websocket.TextMessage, []byte("Missing API Key"))
continue
}
client.APIKey = payload.APIKey
// Handle channel subscription
switch payload.Channel {
case "logServices":
// Unregister from other channels if needed
if client.Channels["labels"] {
channels["labels"].Unregister <- client
delete(client.Channels, "labels")
}
// Update client filters
client.Services = payload.Services
client.LogLevels = payload.Levels
// Register to channel
channels["logServices"].Register <- client
client.Channels["logServices"] = true
conn.WriteJSON(map[string]string{
"status": "subscribed",
"channel": "logServices",
})
case "labels":
// Unregister from other channels if needed
if client.Channels["logServices"] {
channels["logServices"].Unregister <- client
delete(client.Channels, "logServices")
}
// Set label filters if provided
if payload.Labels != nil {
client.Labels = payload.Labels
}
// Register to channel
channels["labels"].Register <- client
client.Channels["labels"] = true
// Update DB record
client.SaveToDB()
// if err := client.SaveToDB(); err != nil {
// log.Println("Failed to update client labels:", err)
// }
conn.WriteJSON(map[string]interface{}{
"status": "subscribed",
"channel": "labels",
"filters": client.Labels,
})
default:
conn.WriteJSON(map[string]string{
"error": "invalid channel",
"available_channels": "logServices, labels",
})
}
}
}()
// Send messages to client
for message := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
log.Println("Write error:", err)
break
}
}
}

View File

@@ -0,0 +1,80 @@
package ws
// setup the notifiyer
// -- Only needs to be run once in DB
// CREATE OR REPLACE FUNCTION notify_new_log() RETURNS trigger AS $$
// BEGIN
// PERFORM pg_notify('new_log', row_to_json(NEW)::text);
// RETURN NEW;
// END;
// $$ LANGUAGE plpgsql;
// DROP TRIGGER IF EXISTS new_log_trigger ON logs;
// CREATE TRIGGER new_log_trigger
// AFTER INSERT ON logs
// FOR EACH ROW EXECUTE FUNCTION notify_new_log();
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/lib/pq"
"lst.net/pkg/logger"
)
func LogServices(broadcaster chan logger.Message) {
log := logger.New()
log.Info("[LogServices] started - single channel for all logs", "websocket", map[string]interface{}{})
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
)
listener := pq.NewListener(dsn, 10*time.Second, time.Minute, nil)
err := listener.Listen("new_log")
if err != nil {
log.Panic("Failed to LISTEN on new_log", "logger", map[string]interface{}{
"error": err.Error(),
})
}
fmt.Println("Listening for all logs through single logServices channel...")
for {
select {
case notify := <-listener.Notify:
if notify != nil {
var logData map[string]interface{}
if err := json.Unmarshal([]byte(notify.Extra), &logData); err != nil {
log.Error("Failed to unmarshal notification payload", "logger", map[string]interface{}{
"error": err.Error(),
})
continue
}
// Always send to logServices channel
broadcaster <- logger.Message{
Channel: "logServices",
Data: logData,
Meta: map[string]interface{}{
"level": logData["level"],
"service": logData["service"],
},
}
}
case <-time.After(90 * time.Second):
go func() {
listener.Ping()
}()
}
}
}

View File

@@ -0,0 +1,55 @@
package ws
import (
"net/http"
"github.com/gin-gonic/gin"
"lst.net/pkg/logger"
)
var (
broadcaster = make(chan logger.Message)
)
func RegisterSocketRoutes(r *gin.Engine, base_url string) {
// Initialize all channels
InitializeChannels()
// Start channel processors
StartAllChannels()
// Start background services
go LogServices(broadcaster)
go StartBroadcasting(broadcaster, channels)
// WebSocket route
r.GET(base_url+"/ws", func(c *gin.Context) {
SocketHandler(c, channels)
})
r.GET(base_url+"/ws/clients", AdminAuthMiddleware(), handleGetClients)
}
func handleGetClients(c *gin.Context) {
channel := c.Query("channel")
var clientList []*Client
if channel != "" {
clientList = GetClientsByChannel(channel)
} else {
clientList = GetAllClients()
}
c.JSON(http.StatusOK, gin.H{
"count": len(clientList),
"clients": clientList,
})
}
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Implement your admin authentication logic
// Example: Check API key or JWT token
c.Next()
}
}

View File

@@ -0,0 +1,65 @@
package router
import (
"net/http"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"lst.net/internal/notifications/ws"
"lst.net/internal/system/servers"
"lst.net/internal/system/settings"
"lst.net/pkg/logger"
)
func Setup(db *gorm.DB, basePath string) *gin.Engine {
r := gin.Default()
if os.Getenv("APP_ENV") == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Enable CORS (adjust origins as needed)
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // Allow all origins (change in production)
AllowMethods: []string{"GET", "OPTIONS", "POST", "DELETE", "PATCH", "CONNECT"},
AllowHeaders: []string{"Origin", "Cache-Control", "Content-Type"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
AllowWebSockets: true,
}))
// Serve Docusaurus static files
r.StaticFS(basePath+"/docs", http.Dir("docs"))
r.StaticFS(basePath+"/app", http.Dir("frontend"))
r.GET(basePath+"/api/ping", func(c *gin.Context) {
log := logger.New()
log.Info("Checking if the server is up", "system", map[string]interface{}{
"endpoint": "/api/ping",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
})
c.JSON(200, gin.H{"message": "pong"})
})
// all routes to there respective systems.
ws.RegisterSocketRoutes(r, basePath)
settings.RegisterSettingsRoutes(r, basePath)
servers.RegisterServersRoutes(r, basePath)
r.Any(basePath+"/api", errorApiLoc)
return r
}
func errorApiLoc(c *gin.Context) {
log := logger.New()
log.Error("Api endpoint hit that dose not exist", "system", map[string]interface{}{
"endpoint": "/api",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
})
c.JSON(http.StatusBadRequest, gin.H{"message": "looks like you have encountered an api route that dose not exist"})
}

View File

@@ -0,0 +1,65 @@
package servers
import (
"reflect"
"strings"
"github.com/gin-gonic/gin"
"lst.net/internal/db"
"lst.net/internal/models"
"lst.net/pkg/logger"
)
func getServers(c *gin.Context) {
log := logger.New()
servers, err := GetServers()
log.Info("Current Settings", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
})
if err != nil {
log := logger.New()
log.Error("Current Settings", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"error": err,
})
c.JSON(500, gin.H{"message": "There was an error getting the settings", "error": err})
return
}
c.JSON(200, gin.H{"message": "Current settings", "data": servers})
}
func GetServers() ([]map[string]interface{}, error) {
var servers []models.Servers
res := db.DB.Find(&servers)
if res.Error != nil {
return nil, res.Error
}
toLowercase := func(s models.Servers) map[string]interface{} {
t := reflect.TypeOf(s)
v := reflect.ValueOf(s)
data := make(map[string]interface{})
for i := 0; i < t.NumField(); i++ {
field := strings.ToLower(t.Field(i).Name)
data[field] = v.Field(i).Interface()
}
return data
}
var lowercaseServers []map[string]interface{}
for _, server := range servers {
lowercaseServers = append(lowercaseServers, toLowercase(server))
}
return lowercaseServers, nil
}

View File

@@ -0,0 +1,20 @@
package servers
import (
"log"
"lst.net/internal/db"
"lst.net/internal/models"
)
func NewServer(serverData models.Servers) (string, error) {
err := db.DB.Create(&serverData).Error
if err != nil {
log.Println("There was an error adding the new server")
return "There was an error adding the new server", err
}
return "New server was just created", nil
}

View File

@@ -0,0 +1,11 @@
package servers
import (
"github.com/gin-gonic/gin"
)
func RegisterServersRoutes(l *gin.Engine, baseUrl string) {
s := l.Group(baseUrl + "/api/v1")
s.GET("/servers", getServers)
}

View File

@@ -0,0 +1,59 @@
package servers
// import (
// "encoding/json"
// "github.com/gin-gonic/gin"
// "lst.net/internal/db"
// "lst.net/pkg/logger"
// )
// func updateSettingById(c *gin.Context) {
// log := logger.New()
// settingID := c.Param("id")
// if settingID == "" {
// c.JSON(500, gin.H{"message": "Invalid data"})
// log.Error("Invalid data", "system", map[string]interface{}{
// "endpoint": "/api/v1/settings",
// "client_ip": c.ClientIP(),
// "user_agent": c.Request.UserAgent(),
// })
// return
// }
// var setting SettingUpdateInput
// //err := c.ShouldBindBodyWithJSON(&setting)
// decoder := json.NewDecoder(c.Request.Body) // more strict and will force us to have correct data
// decoder.DisallowUnknownFields()
// if err := decoder.Decode(&setting); err != nil {
// c.JSON(400, gin.H{"message": "Invalid request body", "error": err.Error()})
// log.Error("Invalid request body", "system", map[string]interface{}{
// "endpoint": "/api/v1/settings",
// "client_ip": c.ClientIP(),
// "user_agent": c.Request.UserAgent(),
// "error": err,
// })
// return
// }
// if err := UpdateServer(db.DB, settingID, setting); err != nil {
// c.JSON(500, gin.H{"message": "Failed to update setting", "error": err.Error()})
// log.Error("Failed to update setting", "system", map[string]interface{}{
// "endpoint": "/api/v1/settings",
// "client_ip": c.ClientIP(),
// "user_agent": c.Request.UserAgent(),
// "error": err,
// })
// return
// }
// c.JSON(200, gin.H{"message": "Setting was just updated", "data": setting})
// }
// func UpdateServer() (string, error) {
// return "Server was just updated", nil
// }

View File

@@ -0,0 +1,41 @@
package settings
import (
"reflect"
"strings"
"gorm.io/gorm"
"lst.net/internal/models"
)
func GetAllSettings(db *gorm.DB) ([]map[string]interface{}, error) {
var settings []models.Settings
result := db.Find(&settings)
if result.Error != nil {
return nil, result.Error
}
// Function to convert struct to map with lowercase keys
toLowercase := func(s models.Settings) map[string]interface{} {
t := reflect.TypeOf(s)
v := reflect.ValueOf(s)
data := make(map[string]interface{})
for i := 0; i < t.NumField(); i++ {
field := strings.ToLower(t.Field(i).Name)
data[field] = v.Field(i).Interface()
}
return data
}
// Convert each struct in settings slice to a map with lowercase keys
var lowercaseSettings []map[string]interface{}
for _, setting := range settings {
lowercaseSettings = append(lowercaseSettings, toLowercase(setting))
}
return lowercaseSettings, nil
}

View File

@@ -0,0 +1,8 @@
package settings
type SettingUpdateInput struct {
Description *string `json:"description"`
Value *string `json:"value"`
Enabled *bool `json:"enabled"`
AppService *string `json:"app_service"`
}

View File

@@ -0,0 +1,88 @@
package settings
import (
"encoding/json"
"github.com/gin-gonic/gin"
"lst.net/internal/db"
"lst.net/pkg/logger"
)
func RegisterSettingsRoutes(l *gin.Engine, baseUrl string) {
// seed the db on start up
db.SeedSettings(db.DB)
s := l.Group(baseUrl + "/api/v1")
s.GET("/settings", getSettings)
s.PATCH("/settings/:id", updateSettingById)
}
func getSettings(c *gin.Context) {
log := logger.New()
configs, err := GetAllSettings(db.DB)
log.Info("Current Settings", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
})
if err != nil {
log := logger.New()
log.Error("Current Settings", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"error": err,
})
c.JSON(500, gin.H{"message": "There was an error getting the settings", "error": err})
return
}
c.JSON(200, gin.H{"message": "Current settings", "data": configs})
}
func updateSettingById(c *gin.Context) {
log := logger.New()
settingID := c.Param("id")
if settingID == "" {
c.JSON(500, gin.H{"message": "Invalid data"})
log.Error("Invalid data", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
})
return
}
var setting SettingUpdateInput
//err := c.ShouldBindBodyWithJSON(&setting)
decoder := json.NewDecoder(c.Request.Body) // more strict and will force us to have correct data
decoder.DisallowUnknownFields()
if err := decoder.Decode(&setting); err != nil {
c.JSON(400, gin.H{"message": "Invalid request body", "error": err.Error()})
log.Error("Invalid request body", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"error": err,
})
return
}
if err := UpdateSetting(db.DB, settingID, setting); err != nil {
c.JSON(500, gin.H{"message": "Failed to update setting", "error": err.Error()})
log.Error("Failed to update setting", "system", map[string]interface{}{
"endpoint": "/api/v1/settings",
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"error": err,
})
return
}
c.JSON(200, gin.H{"message": "Setting was just updated", "data": setting})
}

View File

@@ -0,0 +1,34 @@
package settings
import (
"gorm.io/gorm"
"lst.net/internal/models"
)
func UpdateSetting(db *gorm.DB, id string, input SettingUpdateInput) error {
var cfg models.Settings
if err := db.Where("setting_id =?", id).First(&cfg).Error; err != nil {
return err
}
updates := map[string]interface{}{}
if input.Description != nil {
updates["description"] = *input.Description
}
if input.Value != nil {
updates["value"] = *input.Value
}
if input.Enabled != nil {
updates["enabled"] = *input.Enabled
}
if input.AppService != nil {
updates["app_service"] = *input.AppService
}
if len(updates) == 0 {
return nil // nothing to update
}
return db.Model(&cfg).Updates(updates).Error
}