mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Consolidate backend: drop vendored APwhy, converge on goose + sqlc
Phase 0 cleanup verified end-to-end:
- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
unwired copy (internal/api/server.go + static bundle + cloned
internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
routes under /api/v1/gateway/* so they stop colliding with the
native /services paths. Fix handlers against the real schema:
UUID ids via RETURNING, integer enabled flags, plan validation,
service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
schema (52 tables) minus APwhy's parallel auth/RBAC objects;
idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
replays cleanly over legacy installs. Add a drop migration for the
dead objects and delete the legacy filename-tracked migrations/
loader, directory, and Dockerfile copy. Verified via cmd/migrate:
fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
cmds, and legacy 0001_init.sql backups.
Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
37a56dc6b6
commit
9e3aa3aa17
153 files changed
+6996
-11135
No files matched your search
+400
-251
@@ -1,7 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -9,26 +12,29 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
// NodeAgent represents a container orchestration agent
|
||||
type NodeAgent struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Hostname string `json:"hostname" gorm:"not null"`
|
||||
IPAddress string `json:"ip_address" gorm:"not null"`
|
||||
Port int `json:"port" gorm:"not null"`
|
||||
Status string `json:"status" gorm:"default:'offline'"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
|
||||
Resources NodeResources `json:"resources" gorm:"serializer:json"`
|
||||
Capabilities AgentCapabilities `json:"capabilities"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// AgentCapabilities defines what the agent can do
|
||||
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
|
||||
|
||||
// ContainerInstance represents a container running on an agent
|
||||
type ContainerInstance struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Image string `json:"image" gorm:"not null"`
|
||||
ProjectID string `json:"project_id" gorm:"not null"`
|
||||
ServiceID string `json:"service_id" gorm:"not null"`
|
||||
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
|
||||
Status ContainerStatus `json:"status" gorm:"serializer:json"`
|
||||
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
|
||||
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
|
||||
Environment map[string]string `json:"environment" gorm:"serializer:json"`
|
||||
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
|
||||
Networks []string `json:"networks" gorm:"serializer:json"`
|
||||
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
|
||||
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
Status ContainerStatus `json:"status"`
|
||||
Resources ContainerResources `json:"resources"`
|
||||
Ports []PortMapping `json:"ports"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Volumes []VolumeMount `json:"volumes"`
|
||||
Networks []string `json:"networks"`
|
||||
RestartPolicy RestartPolicy `json:"restart_policy"`
|
||||
HealthCheck *HealthCheck `json:"health_check"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -155,12 +161,12 @@ type HealthCheck struct {
|
||||
|
||||
// AgentCommand represents a command sent to an agent
|
||||
type AgentCommand struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Type string `json:"type" gorm:"not null"`
|
||||
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
ContainerID *string `json:"container_id"`
|
||||
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
|
||||
Status string `json:"status" gorm:"default:'pending'"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
Result *string `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AgentHeartbeatRecord struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
NodeAgentID string `json:"node_agent_id" gorm:"index;not null"`
|
||||
Timestamp time.Time `json:"timestamp" gorm:"index;not null"`
|
||||
Status string `json:"status"`
|
||||
Resources NodeResources `json:"resources" gorm:"serializer:json"`
|
||||
ContainerCount int `json:"container_count"`
|
||||
SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AgentHeartbeatRecord) TableName() string {
|
||||
return "agent_heartbeats"
|
||||
}
|
||||
|
||||
type SystemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
@@ -206,13 +195,125 @@ type SystemLoad struct {
|
||||
|
||||
// NodeAgentHandler handles agent-related endpoints
|
||||
type NodeAgentHandler struct {
|
||||
db *gorm.DB
|
||||
q *sqlcdb.Queries
|
||||
}
|
||||
|
||||
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
|
||||
return &NodeAgentHandler{db: db}
|
||||
func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
|
||||
return &NodeAgentHandler{q: sqlcdb.New(db.DB)}
|
||||
}
|
||||
|
||||
// --- row <-> API type conversion helpers ---
|
||||
|
||||
func rawJSON(v interface{}) pqtype.NullRawMessage {
|
||||
if v == nil {
|
||||
return pqtype.NullRawMessage{}
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return pqtype.NullRawMessage{}
|
||||
}
|
||||
return pqtype.NullRawMessage{RawMessage: b, Valid: true}
|
||||
}
|
||||
|
||||
func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
|
||||
if raw.Valid && len(raw.RawMessage) > 0 {
|
||||
_ = json.Unmarshal(raw.RawMessage, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||
agent := NodeAgent{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IpAddress,
|
||||
Port: int(row.Port),
|
||||
Status: row.Status.String,
|
||||
Version: row.Version.String,
|
||||
Metadata: map[string]interface{}{},
|
||||
}
|
||||
if row.LastHeartbeat.Valid {
|
||||
agent.LastHeartbeat = row.LastHeartbeat.Time
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
agent.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
agent.UpdatedAt = row.UpdatedAt.Time
|
||||
}
|
||||
unmarshalRaw(row.Capabilities, &agent.Capabilities)
|
||||
unmarshalRaw(row.Resources, &agent.Resources)
|
||||
unmarshalRaw(row.Metadata, &agent.Metadata)
|
||||
return agent
|
||||
}
|
||||
|
||||
func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance {
|
||||
container := ContainerInstance{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Image: row.Image,
|
||||
ProjectID: row.ProjectID,
|
||||
ServiceID: row.ServiceID,
|
||||
NodeAgentID: row.NodeAgentID,
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
container.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
container.UpdatedAt = row.UpdatedAt.Time
|
||||
}
|
||||
if row.StartedAt.Valid {
|
||||
t := row.StartedAt.Time
|
||||
container.StartedAt = &t
|
||||
}
|
||||
unmarshalRaw(row.Status, &container.Status)
|
||||
unmarshalRaw(row.Resources, &container.Resources)
|
||||
unmarshalRaw(row.Ports, &container.Ports)
|
||||
unmarshalRaw(row.Environment, &container.Environment)
|
||||
unmarshalRaw(row.Volumes, &container.Volumes)
|
||||
unmarshalRaw(row.Networks, &container.Networks)
|
||||
unmarshalRaw(row.RestartPolicy, &container.RestartPolicy)
|
||||
if row.HealthCheck.Valid {
|
||||
var hc HealthCheck
|
||||
if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil {
|
||||
container.HealthCheck = &hc
|
||||
}
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
func commandFromRow(row sqlcdb.AgentCommand) AgentCommand {
|
||||
cmd := AgentCommand{
|
||||
ID: row.ID,
|
||||
Type: row.Type,
|
||||
NodeAgentID: row.NodeAgentID,
|
||||
Status: row.Status.String,
|
||||
}
|
||||
if row.ContainerID.Valid {
|
||||
cmd.ContainerID = &row.ContainerID.String
|
||||
}
|
||||
if row.Result.Valid {
|
||||
cmd.Result = &row.Result.String
|
||||
}
|
||||
if row.Error.Valid {
|
||||
cmd.Error = &row.Error.String
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
cmd.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
cmd.UpdatedAt = row.UpdatedAt.Time
|
||||
}
|
||||
if row.CompletedAt.Valid {
|
||||
t := row.CompletedAt.Time
|
||||
cmd.CompletedAt = &t
|
||||
}
|
||||
unmarshalRaw(row.Payload, &cmd.Payload)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// RegisterAgent handles agent registration
|
||||
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent already exists
|
||||
var existingAgent NodeAgent
|
||||
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
|
||||
// Update existing agent
|
||||
existingAgent.Name = req.Name
|
||||
existingAgent.Port = req.Port
|
||||
existingAgent.Capabilities = req.Capabilities
|
||||
existingAgent.Status = "connecting"
|
||||
existingAgent.LastHeartbeat = time.Now()
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if err := h.db.Save(&existingAgent).Error; err != nil {
|
||||
// Re-registering an existing host updates it in place.
|
||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||
Hostname: req.Hostname,
|
||||
IpAddress: req.IPAddress,
|
||||
}); err == nil {
|
||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||
ID: existing.ID,
|
||||
Name: req.Name,
|
||||
Hostname: existing.Hostname,
|
||||
IpAddress: existing.IpAddress,
|
||||
Port: int32(req.Port),
|
||||
Status: sql.NullString{String: "connecting", Valid: true},
|
||||
Version: existing.Version,
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: existing.Resources,
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: existing.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": existingAgent.ID,
|
||||
"agent_id": updated.ID,
|
||||
"auth_token": authToken,
|
||||
"status": "updated",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new agent
|
||||
agent := NodeAgent{
|
||||
ID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Hostname: req.Hostname,
|
||||
IPAddress: req.IPAddress,
|
||||
Port: req.Port,
|
||||
Status: "connecting",
|
||||
Capabilities: req.Capabilities,
|
||||
Resources: NodeResources{
|
||||
CPU: CPUResources{
|
||||
Cores: 4,
|
||||
Allocation: 0,
|
||||
Usage: 0,
|
||||
},
|
||||
Memory: MemoryResources{
|
||||
Total: 8 * 1024 * 1024 * 1024, // 8GB
|
||||
Allocated: 0,
|
||||
Used: 0,
|
||||
Available: 8 * 1024 * 1024 * 1024,
|
||||
},
|
||||
Storage: StorageResources{
|
||||
Total: 100 * 1024 * 1024 * 1024, // 100GB
|
||||
Allocated: 0,
|
||||
Used: 0,
|
||||
Available: 100 * 1024 * 1024 * 1024,
|
||||
},
|
||||
Network: NetworkResources{
|
||||
Interfaces: []NetworkInterface{
|
||||
{
|
||||
Name: "eth0",
|
||||
IPAddress: req.IPAddress,
|
||||
MACAddress: "00:00:00:00:00:00",
|
||||
Speed: 1000,
|
||||
Status: "up",
|
||||
},
|
||||
},
|
||||
Bandwidth: BandwidthInfo{
|
||||
Inbound: 0,
|
||||
Outbound: 0,
|
||||
agentID := uuid.New().String()
|
||||
resources := NodeResources{
|
||||
CPU: CPUResources{Cores: 4},
|
||||
Memory: MemoryResources{
|
||||
Total: 8 * 1024 * 1024 * 1024, // 8GB
|
||||
Available: 8 * 1024 * 1024 * 1024,
|
||||
},
|
||||
Storage: StorageResources{
|
||||
Total: 100 * 1024 * 1024 * 1024, // 100GB
|
||||
Available: 100 * 1024 * 1024 * 1024,
|
||||
},
|
||||
Network: NetworkResources{
|
||||
Interfaces: []NetworkInterface{
|
||||
{
|
||||
Name: "eth0",
|
||||
IPAddress: req.IPAddress,
|
||||
MACAddress: "00:00:00:00:00:00",
|
||||
Speed: 1000,
|
||||
Status: "up",
|
||||
},
|
||||
},
|
||||
},
|
||||
LastHeartbeat: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&agent).Error; err != nil {
|
||||
_, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{
|
||||
ID: agentID,
|
||||
Name: req.Name,
|
||||
Hostname: req.Hostname,
|
||||
IpAddress: req.IPAddress,
|
||||
Port: int32(req.Port),
|
||||
Status: sql.NullString{String: "connecting", Valid: true},
|
||||
Version: sql.NullString{},
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: rawJSON(resources),
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: rawJSON(map[string]interface{}{}),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"agent_id": agent.ID,
|
||||
"agent_id": agentID,
|
||||
"auth_token": authToken,
|
||||
"status": "registered",
|
||||
})
|
||||
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
// GetAgents returns all registered agents
|
||||
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
|
||||
var agents []NodeAgent
|
||||
if err := h.db.Find(&agents).Error; err != nil {
|
||||
rows, err := h.q.ListAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
|
||||
return
|
||||
}
|
||||
|
||||
agents := make([]NodeAgent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
agents = append(agents, agentFromRow(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agents": agents})
|
||||
}
|
||||
|
||||
// GetAgent returns a specific agent
|
||||
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var agent NodeAgent
|
||||
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agent": agent})
|
||||
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
|
||||
}
|
||||
|
||||
// UpdateAgent updates an agent's information
|
||||
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var agent NodeAgent
|
||||
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
row, err := h.q.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
|
||||
agent := agentFromRow(row)
|
||||
applyAgentUpdates(&agent, updates)
|
||||
|
||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||
ID: agent.ID,
|
||||
Name: agent.Name,
|
||||
Hostname: agent.Hostname,
|
||||
IpAddress: agent.IPAddress,
|
||||
Port: int32(agent.Port),
|
||||
Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""},
|
||||
Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""},
|
||||
Capabilities: rawJSON(agent.Capabilities),
|
||||
Resources: rawJSON(agent.Resources),
|
||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||
Metadata: rawJSON(agent.Metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agent": agent})
|
||||
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
|
||||
}
|
||||
|
||||
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
|
||||
func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
|
||||
remarshal := func(key string, dst interface{}) {
|
||||
if v, ok := updates[key]; ok {
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
_ = json.Unmarshal(b, dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
remarshal("name", &agent.Name)
|
||||
remarshal("hostname", &agent.Hostname)
|
||||
remarshal("ip_address", &agent.IPAddress)
|
||||
remarshal("port", &agent.Port)
|
||||
remarshal("status", &agent.Status)
|
||||
remarshal("version", &agent.Version)
|
||||
remarshal("capabilities", &agent.Capabilities)
|
||||
remarshal("resources", &agent.Resources)
|
||||
remarshal("metadata", &agent.Metadata)
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent
|
||||
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
|
||||
if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
|
||||
return
|
||||
}
|
||||
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
heartbeat.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
var agent NodeAgent
|
||||
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ctx := c.Request.Context()
|
||||
if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
@@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update agent status and resources
|
||||
agent.Status = heartbeat.Status
|
||||
agent.Resources = heartbeat.Resources
|
||||
agent.LastHeartbeat = heartbeat.Timestamp
|
||||
agent.UpdatedAt = time.Now()
|
||||
|
||||
if err := h.db.Save(&agent).Error; err != nil {
|
||||
if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
|
||||
ID: heartbeat.NodeAgentID,
|
||||
Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
|
||||
Resources: rawJSON(heartbeat.Resources),
|
||||
LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
return
|
||||
}
|
||||
|
||||
record := AgentHeartbeatRecord{
|
||||
err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
|
||||
ID: uuid.New().String(),
|
||||
NodeAgentID: heartbeat.NodeAgentID,
|
||||
Timestamp: heartbeat.Timestamp,
|
||||
Status: heartbeat.Status,
|
||||
Resources: heartbeat.Resources,
|
||||
ContainerCount: heartbeat.ContainerCount,
|
||||
SystemLoad: heartbeat.SystemLoad,
|
||||
Resources: rawJSON(heartbeat.Resources).RawMessage,
|
||||
ContainerCount: int32(heartbeat.ContainerCount),
|
||||
SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
|
||||
Uptime: heartbeat.Uptime,
|
||||
Version: heartbeat.Version,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := h.db.Create(&record).Error; err != nil {
|
||||
})
|
||||
if err != nil && !isMissingTableError(err) {
|
||||
// Keep heartbeat endpoint available even if history table is not yet migrated.
|
||||
if !isMissingTableError(err) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
var commands []AgentCommand
|
||||
if err := h.db.
|
||||
Where("node_agent_id = ? AND status = ?", agentID, "pending").
|
||||
Order("created_at ASC").
|
||||
Limit(25).
|
||||
Find(&commands).Error; err != nil {
|
||||
rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
commands := make([]AgentCommand, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
commands = append(commands, commandFromRow(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ctx := c.Request.Context()
|
||||
command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
|
||||
ID: commandID,
|
||||
NodeAgentID: agentID,
|
||||
Status: sql.NullString{String: req.Status, Valid: true},
|
||||
Result: sql.NullString{String: req.Result, Valid: req.Result != ""},
|
||||
Error: sql.NullString{String: req.Error, Valid: req.Error != ""},
|
||||
})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
command.Status = req.Status
|
||||
command.UpdatedAt = now
|
||||
command.CompletedAt = &now
|
||||
if req.Result != "" {
|
||||
command.Result = &req.Result
|
||||
}
|
||||
if req.Error != "" {
|
||||
command.Error = &req.Error
|
||||
}
|
||||
|
||||
if err := h.db.Save(&command).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
||||
return
|
||||
}
|
||||
|
||||
if command.ContainerID != nil {
|
||||
h.updateContainerStatusAfterCommand(command)
|
||||
out := commandFromRow(command)
|
||||
if out.ContainerID != nil {
|
||||
h.updateContainerStatusAfterCommand(ctx, out)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
c.JSON(http.StatusOK, gin.H{"command": out})
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
|
||||
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
container := containerFromRow(row)
|
||||
|
||||
if command.Status == "failed" {
|
||||
errorMessage := ""
|
||||
if command.Error != nil {
|
||||
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
|
||||
}
|
||||
container.Status.State = "error"
|
||||
container.Status.Error = &errorMessage
|
||||
_ = h.db.Save(&container).Error
|
||||
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: container.ID,
|
||||
Status: rawJSON(container.Status),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
|
||||
container.Status.State = "removed"
|
||||
container.Status.FinishedAt = &now
|
||||
}
|
||||
container.UpdatedAt = now
|
||||
_ = h.db.Save(&container).Error
|
||||
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: container.ID,
|
||||
Status: rawJSON(container.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentContainers returns containers running on a specific agent
|
||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
|
||||
var containers []ContainerInstance
|
||||
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
|
||||
rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
|
||||
return
|
||||
}
|
||||
|
||||
containers := make([]ContainerInstance, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
containers = append(containers, containerFromRow(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"containers": containers})
|
||||
}
|
||||
|
||||
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Verify agent exists
|
||||
var agent NodeAgent
|
||||
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if _, err := h.q.GetAgent(ctx, agentID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
containerID := uuid.New().String()
|
||||
container := ContainerInstance{
|
||||
ID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Image: req.Image,
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
NodeAgentID: agentID,
|
||||
Status: ContainerStatus{
|
||||
State: "created",
|
||||
Health: "none",
|
||||
},
|
||||
ID: containerID,
|
||||
Name: req.Name,
|
||||
Image: req.Image,
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
NodeAgentID: agentID,
|
||||
Status: ContainerStatus{State: "created", Health: "none"},
|
||||
Resources: req.Resources,
|
||||
Ports: req.Ports,
|
||||
Environment: req.Environment,
|
||||
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&container).Error; err != nil {
|
||||
_, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{
|
||||
ID: container.ID,
|
||||
Name: container.Name,
|
||||
Image: container.Image,
|
||||
ProjectID: container.ProjectID,
|
||||
ServiceID: container.ServiceID,
|
||||
NodeAgentID: container.NodeAgentID,
|
||||
Status: rawJSON(container.Status),
|
||||
Resources: rawJSON(container.Resources),
|
||||
Ports: rawJSON(container.Ports),
|
||||
Environment: rawJSON(container.Environment),
|
||||
Volumes: rawJSON(container.Volumes),
|
||||
Networks: rawJSON(container.Networks),
|
||||
RestartPolicy: rawJSON(container.RestartPolicy),
|
||||
HealthCheck: rawJSON(container.HealthCheck),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create command to start container on agent
|
||||
command := AgentCommand{
|
||||
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: "create_container",
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
"container": container,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&command).Error; err != nil {
|
||||
ContainerID: sql.NullString{String: container.ID, Valid: true},
|
||||
Payload: rawJSON(map[string]interface{}{"container": container}),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
|
||||
return
|
||||
}
|
||||
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
command := AgentCommand{
|
||||
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: req.Type,
|
||||
NodeAgentID: agentID,
|
||||
Payload: req.Payload,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&command).Error; err != nil {
|
||||
Payload: rawJSON(req.Payload),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"command": command})
|
||||
c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)})
|
||||
}
|
||||
|
||||
// GetAgentCommands returns commands for an agent
|
||||
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
|
||||
var commands []AgentCommand
|
||||
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
|
||||
rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
commands := make([]AgentCommand, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
commands = append(commands, commandFromRow(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
// GetCommandStatus returns the status of a specific command
|
||||
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
commandID := c.Param("commandId")
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
|
||||
ID: c.Param("commandId"),
|
||||
NodeAgentID: c.Param("id"),
|
||||
})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
|
||||
}
|
||||
|
||||
// ContainerAction handles container lifecycle actions
|
||||
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Verify container exists
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
|
||||
ID: containerID,
|
||||
NodeAgentID: agentID,
|
||||
})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
|
||||
return
|
||||
}
|
||||
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create command for the action
|
||||
command := AgentCommand{
|
||||
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: fmt.Sprintf("%s_container", action),
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
|
||||
Payload: rawJSON(map[string]interface{}{
|
||||
"container_id": containerID,
|
||||
"container_name": container.Name,
|
||||
"docker_name": container.Name,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&command).Error; err != nil {
|
||||
"container_name": containerRow.Name,
|
||||
"docker_name": containerRow.Name,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
||||
return
|
||||
}
|
||||
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var agent NodeAgent
|
||||
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ctx := c.Request.Context()
|
||||
agentRow, err := h.q.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
|
||||
return
|
||||
}
|
||||
agent := agentFromRow(agentRow)
|
||||
|
||||
from := time.Now().Add(-duration)
|
||||
var records []AgentHeartbeatRecord
|
||||
queryErr := h.db.
|
||||
Where("node_agent_id = ? AND timestamp >= ?", agentID, from).
|
||||
Order("timestamp ASC").
|
||||
Find(&records).Error
|
||||
records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
|
||||
NodeAgentID: agentID,
|
||||
Timestamp: from,
|
||||
})
|
||||
if queryErr != nil && !isMissingTableError(queryErr) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
|
||||
return
|
||||
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
|
||||
|
||||
metrics := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount))
|
||||
var resources NodeResources
|
||||
var load SystemLoad
|
||||
_ = json.Unmarshal(record.Resources, &resources)
|
||||
_ = json.Unmarshal(record.SystemLoad, &load)
|
||||
metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount)))
|
||||
}
|
||||
|
||||
if len(metrics) == 0 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,21 +14,14 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Constants for validation and limits
|
||||
// Default quota limits applied when the request omits them.
|
||||
const (
|
||||
MaxServiceNameLength = 100
|
||||
MaxRoutePrefixLength = 200
|
||||
MaxUpstreamURLLength = 500
|
||||
MaxAPIKeyNameLength = 100
|
||||
MinAPIKeyLength = 20
|
||||
MaxAPIKeyLength = 100
|
||||
DefaultRPMLimit = 60
|
||||
DefaultMonthlyQuota = 1000
|
||||
MaxRPMLimit = 10000
|
||||
MaxMonthlyQuota = 10000000
|
||||
DefaultRPMLimit = 60
|
||||
DefaultMonthlyQuota = 1000
|
||||
)
|
||||
|
||||
// Validator instance for request validation
|
||||
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
|
||||
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
||||
}
|
||||
|
||||
// generateServiceID generates a cryptographically secure service ID
|
||||
func generateServiceID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return "svc_" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateAPIKey generates a cryptographically secure API key
|
||||
func generateAPIKey() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||
}
|
||||
|
||||
// handleAPwhyServiceValidate probes the upstream health path and records the
|
||||
// result on the service row. Ported from the standalone APwhy server.
|
||||
func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
serviceID := c.Param("id")
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var upstreamURL, healthPath string
|
||||
var authHeader, authValue sql.NullString
|
||||
var timeoutMs sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
||||
FROM api_services WHERE id = $1
|
||||
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
timeout := 8000
|
||||
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||
timeout = int(timeoutMs.Int64)
|
||||
}
|
||||
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if authHeader.Valid && authHeader.String != "" {
|
||||
req.Header.Set(authHeader.String, authValue.String)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
|
||||
res, probeErr := client.Do(req)
|
||||
|
||||
status := http.StatusBadGateway
|
||||
message := ""
|
||||
ok := false
|
||||
if probeErr != nil {
|
||||
message = probeErr.Error()
|
||||
} else {
|
||||
defer res.Body.Close()
|
||||
status = res.StatusCode
|
||||
message = fmt.Sprintf("HTTP %d", res.StatusCode)
|
||||
ok = res.StatusCode >= 200 && res.StatusCode < 300
|
||||
}
|
||||
|
||||
validationStatus := "failed"
|
||||
if ok {
|
||||
validationStatus = "healthy"
|
||||
}
|
||||
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
UPDATE api_services
|
||||
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, validationStatus, message, serviceID)
|
||||
|
||||
if !ok {
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
||||
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
||||
`, serviceID, message, status)
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||
"validation": map[string]interface{}{
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyServicesList returns a list of API services
|
||||
func handleAPwhyServicesList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
@@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
// Query services from database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
||||
FROM api_services
|
||||
FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []map[string]interface{}
|
||||
services := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||
var enabled bool
|
||||
var enabled int
|
||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue // Skip malformed rows
|
||||
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
"slug": slug,
|
||||
"upstreamUrl": upstreamURL,
|
||||
"routePrefix": routePrefix,
|
||||
"enabled": enabled,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
})
|
||||
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
// Generate slug and ID
|
||||
id, err := generateServiceID()
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
||||
"Failed to generate service ID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
||||
|
||||
// Insert service into database
|
||||
query := `
|
||||
INSERT INTO api_services (
|
||||
id, name, slug, upstream_url, route_prefix, health_path,
|
||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
|
||||
`
|
||||
|
||||
var rpmLimit, monthlyQuota int
|
||||
if req.RPMLimit != nil {
|
||||
rpmLimit = *req.RPMLimit
|
||||
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
monthlyQuota = DefaultMonthlyQuota
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
|
||||
req.RoutePrefix, rpmLimit, monthlyQuota)
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO api_services (
|
||||
name, slug, upstream_url, route_prefix, health_path,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
|
||||
"A service with this slug or route prefix already exists", err.Error())
|
||||
return
|
||||
}
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||
"Failed to create service", err.Error())
|
||||
return
|
||||
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
*input.Enabled, serviceID)
|
||||
enabled, serviceID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []map[string]interface{}
|
||||
keys := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||
var enabled bool
|
||||
var enabled int
|
||||
var rpmLimit, monthlyQuota sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
|
||||
"name": name,
|
||||
"keyPrefix": keyPrefix,
|
||||
"plan": plan,
|
||||
"enabled": enabled,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
}
|
||||
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID and prefix
|
||||
id, err := generateServiceID()
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
||||
"Failed to generate key ID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
keyPrefix := apiKey[:8]
|
||||
|
||||
// Set default limits based on plan
|
||||
var rpmLimit, monthlyQuota int
|
||||
switch input.Plan {
|
||||
case "free":
|
||||
rpmLimit, monthlyQuota = 60, 1000
|
||||
case "pro":
|
||||
rpmLimit, monthlyQuota = 600, 50000
|
||||
case "business":
|
||||
rpmLimit, monthlyQuota = 3000, 300000
|
||||
default:
|
||||
rpmLimit, monthlyQuota = 60, 1000
|
||||
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"Invalid plan", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Insert key into database
|
||||
_, err = db.ExecContext(context.Background(), `
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err = db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO api_keys (
|
||||
id, name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
|
||||
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
|
||||
name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
"data": gin.H{
|
||||
"id": id,
|
||||
"name": input.Name,
|
||||
"plan": input.Plan,
|
||||
"plan": plan,
|
||||
"key": apiKey, // Only return the actual key once
|
||||
"keyPrefix": keyPrefix,
|
||||
"enabled": true,
|
||||
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
*input.Enabled, keyID)
|
||||
enabled, keyID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// firstPathParam returns the first non-empty route param from the provided names.
|
||||
func firstPathParam(c *gin.Context, names ...string) string {
|
||||
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string, trimmed.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"containr/internal/scaling"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Initialize scaling handler
|
||||
scalingHandler := NewScalingHandler(autoScaler)
|
||||
|
||||
// Initialize GORM for agent system
|
||||
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("Failed to initialize GORM: " + err.Error())
|
||||
}
|
||||
|
||||
// Initialize agent handler
|
||||
agentHandler := NewNodeAgentHandler(gormDB)
|
||||
// Initialize agent handler (sqlc-backed)
|
||||
agentHandler := NewNodeAgentHandler(db)
|
||||
|
||||
// Initialize database handler
|
||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||
@@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
c.Set("auto_scaler", autoScaler)
|
||||
c.Set("ha_manager", haManager)
|
||||
c.Set("scaling_handler", scalingHandler)
|
||||
c.Set("gorm_db", gormDB)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Audit Logs routes
|
||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
}
|
||||
|
||||
// APwhy Gateway routes
|
||||
apwhy := router.Group("/api/v1")
|
||||
{
|
||||
// Health check (no auth required)
|
||||
apwhy.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"status": "ok",
|
||||
"name": "Containr + APwhy",
|
||||
"database": "postgresql",
|
||||
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
// API Gateway routes (merged APwhy) - namespaced to avoid
|
||||
// colliding with Containr's own /services paths.
|
||||
gateway := protected.Group("/gateway")
|
||||
{
|
||||
gateway.GET("/services", handleAPwhyServicesList)
|
||||
gateway.POST("/services", handleAPwhyServicesCreate)
|
||||
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
|
||||
|
||||
// Protected APwhy routes (authentication required)
|
||||
protectedAPwhy := router.Group("/api/v1")
|
||||
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
|
||||
{
|
||||
// Service management
|
||||
protectedAPwhy.GET("/services", handleAPwhyServicesList)
|
||||
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
|
||||
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||
gateway.GET("/keys", handleAPwhyKeysList)
|
||||
gateway.POST("/keys", handleAPwhyKeysCreate)
|
||||
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
||||
|
||||
// API Keys
|
||||
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
|
||||
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
|
||||
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
||||
|
||||
// Analytics
|
||||
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
||||
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
||||
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
||||
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
||||
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
|
||||
@@ -1 +0,0 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>APwhy</title>
|
||||
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Type: sql.NullString{String: serviceType, Valid: true},
|
||||
Status: sql.NullString{String: "stopped", Valid: true},
|
||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
|
||||
Reference in new issue
Block a user