fix a bunch of things

This commit is contained in:
d34dscene
2025-01-23 16:17:29 +01:00
parent 64b99d2a50
commit 9272d8c1a4
46 changed files with 1165 additions and 1642 deletions

View File

@@ -1,4 +1,4 @@
package grpc
package agent
import (
"context"
@@ -54,7 +54,7 @@ func (s *AgentServer) GetContainer(
}
if err := s.db.UpdateAgent(context.Background(), db.UpdateAgentParams{
ID: req.Msg.GetId(),
Hostname: req.Msg.GetHostname(),
Hostname: &req.Msg.Hostname,
PublicIp: &req.Msg.PublicIp,
PrivateIps: privateIpsJSON,
Containers: containersJSON,

View File

@@ -0,0 +1,54 @@
package agent
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type AgentClaims struct {
ServerURL string `json:"serverUrl,omitempty"`
ProfileID int64 `json:"profileId,omitempty"`
jwt.RegisteredClaims
}
// EncodeJWT generates a JWT for agents
func EncodeJWT(
serverurl string,
profileid int64,
secret string,
) (string, error) {
if serverurl == "" || profileid == 0 {
return "", errors.New("serverUrl and profileID cannot be empty")
}
expirationTime := time.Now().Add(24 * time.Hour)
claims := &AgentClaims{
ServerURL: serverurl,
ProfileID: profileid,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// DecodeJWT decodes the agent token and returns claims if valid
func DecodeJWT(tokenString, secret string) (*AgentClaims, error) {
claims := &AgentClaims{}
token, err := jwt.ParseWithClaims(
tokenString,
claims,
func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
},
)
if err != nil || !token.Valid {
return nil, err
}
return claims, nil
}

View File

@@ -3,8 +3,12 @@ package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/MizuchiLabs/mantrae/internal/api/agent"
"github.com/MizuchiLabs/mantrae/internal/config"
"github.com/MizuchiLabs/mantrae/internal/db"
"github.com/google/uuid"
)
func ListAgents(q *db.Queries) http.HandlerFunc {
@@ -31,14 +35,30 @@ func GetAgent(q *db.Queries) http.HandlerFunc {
}
}
func CreateAgent(q *db.Queries) http.HandlerFunc {
func CreateAgent(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var agent db.CreateAgentParams
if err := json.NewDecoder(r.Body).Decode(&agent); err != nil {
profileID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := q.CreateAgent(r.Context(), agent); err != nil {
serverUrl, err := a.SM.Get(r.Context(), "server_url")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
token, err := agent.EncodeJWT(serverUrl.Value.(string), profileID, a.Config.Secret)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := a.DB.CreateAgent(r.Context(), db.CreateAgentParams{
ID: uuid.New().String(),
ProfileID: profileID,
Token: token,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -76,17 +96,33 @@ func DeleteAgent(q *db.Queries) http.HandlerFunc {
}
}
// func RotateAgentToken(q *db.Queries) http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// agent, err := q.GetAgent(r.Context(), r.PathValue("id"))
// if err != nil {
// http.Error(w, err.Error(), http.StatusInternalServerError)
// return
// }
// if err := q.RotateAgentToken(r.Context(), agent.ID); err != nil {
// http.Error(w, err.Error(), http.StatusInternalServerError)
// return
// }
// w.WriteHeader(http.StatusNoContent)
// }
// }
func RotateAgentToken(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dbAgent, err := a.DB.GetAgent(r.Context(), r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serverUrl, err := a.SM.Get(r.Context(), "server_url")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
token, err := agent.EncodeJWT(serverUrl.Value.(string), dbAgent.ProfileID, a.Config.Secret)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := a.DB.UpdateAgentToken(r.Context(), db.UpdateAgentTokenParams{
ID: dbAgent.ID,
Token: token,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -1,8 +1,6 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"github.com/MizuchiLabs/mantrae/internal/util"
@@ -26,20 +24,5 @@ func GetEvents(w http.ResponseWriter, r *http.Request) {
util.ClientsMutex.Unlock()
}()
for {
select {
case message := <-util.Broadcast:
// Serialize the EventMessage to JSON
data, err := json.Marshal(message)
if err != nil {
fmt.Printf("Error marshalling message: %v\n", err)
continue
}
// Send the data to the client
fmt.Fprintf(w, "data: %s\n\n", data)
w.(http.Flusher).Flush()
case <-r.Context().Done():
return
}
}
<-r.Context().Done()
}

View File

@@ -135,7 +135,7 @@ func DeleteMiddleware(q *db.Queries) http.HandlerFunc {
mwProto := r.PathValue("protocol")
if mwName == "" || mwProto == "" {
http.Error(w, "Missing middleware name or type", http.StatusBadRequest)
http.Error(w, "Missing middleware name or protocol", http.StatusBadRequest)
return
}

View File

@@ -132,10 +132,10 @@ func DeleteRouter(q *db.Queries) http.HandlerFunc {
}
routerName := r.PathValue("name")
routerType := r.PathValue("type")
routerProto := r.PathValue("protocol")
if routerName == "" || routerType == "" {
http.Error(w, "Missing router name or type", http.StatusBadRequest)
if routerName == "" || routerProto == "" {
http.Error(w, "Missing router name or protocol", http.StatusBadRequest)
return
}
@@ -162,7 +162,7 @@ func DeleteRouter(q *db.Queries) http.HandlerFunc {
}
// Remove router and service based on type
switch routerType {
switch routerProto {
case "http":
delete(existingConfig.Config.Routers, routerName)
delete(existingConfig.Config.Services, routerName)

View File

@@ -39,7 +39,7 @@ func UpsertSetting(sm *config.SettingsManager) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := sm.Set(r.Context(), setting.Key, setting.Value); err != nil {
if err := sm.Set(r.Context(), setting.Key, setting.Value, setting.Description); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -64,6 +64,22 @@ func UpdateUser(q *db.Queries) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dbUser, err := q.GetUser(r.Context(), user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if user.Password == "" {
user.Password = dbUser.Password
} else {
hashedPassword, err := util.HashPassword(user.Password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user.Password = hashedPassword
}
if err := q.UpdateUser(r.Context(), user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@@ -50,13 +50,13 @@ func (s *Server) routes() {
register("DELETE", "/profile/{id}", jwtChain, handler.DeleteProfile(DB))
// Routers/Services
register("POST", "/router/{id}", logChain, handler.UpsertRouter(DB))
register("DELETE", "/router/{id}/{name}/{type}", jwtChain, handler.DeleteRouter(DB))
register("POST", "/router/{id}", jwtChain, handler.UpsertRouter(DB))
register("DELETE", "/router/{id}/{name}/{protocol}", jwtChain, handler.DeleteRouter(DB))
// Middlewares
register("POST", "/middleware/{id}", logChain, handler.UpsertMiddleware(DB))
register("DELETE", "/middleware/{id}/{name}/{type}", jwtChain, handler.DeleteMiddleware(DB))
register("GET", "/middleware/plugins", logChain, handler.GetMiddlewarePlugins)
register("POST", "/middleware/{id}", jwtChain, handler.UpsertMiddleware(DB))
register("DELETE", "/middleware/{id}/{name}/{protocol}", jwtChain, handler.DeleteMiddleware(DB))
register("GET", "/middleware/plugins", jwtChain, handler.GetMiddlewarePlugins)
// Users
register("GET", "/user", jwtChain, handler.ListUsers(DB))
@@ -75,15 +75,15 @@ func (s *Server) routes() {
// Settings
register("GET", "/settings", jwtChain, handler.ListSettings(s.app.SM))
register("GET", "/settings/{key}", jwtChain, handler.GetSetting(s.app.SM))
register("POST", "/settings", logChain, handler.UpsertSetting(s.app.SM))
register("POST", "/settings", jwtChain, handler.UpsertSetting(s.app.SM))
// Agent
register("GET", "/agent", jwtChain, handler.ListAgents(DB))
register("GET", "/agent/{id}", jwtChain, handler.GetAgent(DB))
register("POST", "/agent", jwtChain, handler.CreateAgent(DB))
register("POST", "/agent/{id}", jwtChain, handler.CreateAgent(s.app))
register("PUT", "/agent", jwtChain, handler.UpdateAgent(DB))
register("DELETE", "/agent/{id}", jwtChain, handler.DeleteAgent(DB))
// register("POST", "/agent/token/{id}", jwtChain, handler.RotateAgentToken(DB))
register("POST", "/agent/token/{id}", jwtChain, handler.RotateAgentToken(s.app))
// Backup
register("GET", "/backups", jwtChain, handler.ListBackups(s.app.BM))

View File

@@ -14,9 +14,10 @@ import (
"connectrpc.com/grpchealth"
"connectrpc.com/grpcreflect"
"github.com/MizuchiLabs/mantrae/agent/proto/gen/agent/v1/agentv1connect"
"github.com/MizuchiLabs/mantrae/internal/api/grpc"
"github.com/MizuchiLabs/mantrae/internal/api/agent"
"github.com/MizuchiLabs/mantrae/internal/api/middlewares"
"github.com/MizuchiLabs/mantrae/internal/config"
"github.com/MizuchiLabs/mantrae/internal/util"
"github.com/MizuchiLabs/mantrae/web"
)
@@ -33,6 +34,9 @@ func NewServer(app *config.App) *Server {
}
func (s *Server) Start(ctx context.Context) error {
// Start the event processor before registering services
util.StartEventProcessor(ctx)
s.registerServices()
defer s.app.DB.Close()
host := s.app.Config.Server.Host
@@ -65,7 +69,7 @@ func (s *Server) Start(ctx context.Context) error {
select {
case <-ctx.Done():
slog.Info("Shutting down server gracefully...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
@@ -87,16 +91,18 @@ func (s *Server) registerServices() {
// middleware.Authentication(),
// middleware.Logging(),
// ),
connect.WithRecover(func(ctx context.Context, spec connect.Spec, header http.Header, panic any) error {
// Log the panic with context
slog.Error("panic recovered in RPC call",
"method", spec.Procedure,
"panic", panic,
"trace", string(debug.Stack()),
)
header.Set("X-Error-Type", "panic")
return connect.NewError(connect.CodeInternal, fmt.Errorf("internal server error"))
}),
connect.WithRecover(
func(ctx context.Context, spec connect.Spec, header http.Header, panic any) error {
// Log the panic with context
slog.Error("panic recovered in RPC call",
"method", spec.Procedure,
"panic", panic,
"trace", string(debug.Stack()),
)
header.Set("X-Error-Type", "panic")
return connect.NewError(connect.CodeInternal, fmt.Errorf("internal server error"))
},
),
}
// Static files
@@ -109,14 +115,12 @@ func (s *Server) registerServices() {
// Routes
s.routes()
// Reflection service
reflector := grpcreflect.NewStaticReflector(
"agent.v1.AgentService",
)
// Health check service
checker := grpchealth.NewStaticChecker(
"agent.v1.AgentService",
)
serviceNames := []string{
agentv1connect.AgentServiceName,
}
reflector := grpcreflect.NewStaticReflector(serviceNames...)
checker := grpchealth.NewStaticChecker(serviceNames...)
s.mux.Handle(grpchealth.NewHandler(checker))
s.mux.Handle(grpcreflect.NewHandlerV1(reflector))
@@ -134,5 +138,5 @@ func (s *Server) registerServices() {
// })
// Service implementations
s.mux.Handle(agentv1connect.NewAgentServiceHandler(&grpc.AgentServer{}, opts...))
s.mux.Handle(agentv1connect.NewAgentServiceHandler(&agent.AgentServer{}, opts...))
}

View File

@@ -10,19 +10,24 @@ import (
"github.com/MizuchiLabs/mantrae/internal/db"
)
type SettingWithDescription struct {
Value interface{} `json:"value"`
Description *string `json:"description,omitempty"`
}
// Settings defines all application settings
type Settings struct {
ServerURL string `setting:"server_url" default:"http://localhost:3000"`
BackupEnabled bool `setting:"backup_enabled" default:"true"`
BackupInterval time.Duration `setting:"backup_interval" default:"24h"`
BackupKeep int `setting:"backup_keep" default:"3"`
EmailHost string `setting:"email_host" default:"localhost"`
EmailPort int `setting:"email_port" default:"587"`
EmailUser string `setting:"email_user" default:""`
EmailPass string `setting:"email_pass" default:""`
EmailFrom string `setting:"email_from" default:"mantrae@localhost"`
AgentCleanupEnabled bool `setting:"agent_cleanup_enabled" default:"true"`
AgentCleanupInterval time.Duration `setting:"agent_cleanup_interval" default:"24h"`
ServerURL string `setting:"server_url" default:"http://localhost:3000" description:"Base URL for the server"`
BackupEnabled bool `setting:"backup_enabled" default:"true" description:"Enable automatic backups"`
BackupInterval time.Duration `setting:"backup_interval" default:"24h" description:"Interval between backups"`
BackupKeep int `setting:"backup_keep" default:"3" description:"Number of backups to retain"`
EmailHost string `setting:"email_host" default:"localhost" description:"SMTP server hostname"`
EmailPort int `setting:"email_port" default:"587" description:"SMTP server port"`
EmailUser string `setting:"email_user" default:"" description:"SMTP username"`
EmailPassword string `setting:"email_password" default:"" description:"SMTP password"`
EmailFrom string `setting:"email_from" default:"mantrae@localhost" description:"From email address"`
AgentCleanupEnabled bool `setting:"agent_cleanup_enabled" default:"true" description:"Enable automatic agent cleanup"`
AgentCleanupInterval time.Duration `setting:"agent_cleanup_interval" default:"24h" description:"Interval for agent cleanup"`
}
type SettingsManager struct {
@@ -178,32 +183,24 @@ func (sm *SettingsManager) Initialize(ctx context.Context) error {
return nil
}
func (sm *SettingsManager) GetAll(ctx context.Context) (*Settings, error) {
settings := &Settings{} // Start with empty settings
v := reflect.ValueOf(settings).Elem()
// Modified GetAll to return settings with descriptions
func (sm *SettingsManager) GetAll(ctx context.Context) (map[string]SettingWithDescription, error) {
settings := make(map[string]SettingWithDescription)
v := reflect.ValueOf(sm.defaults).Elem()
t := v.Type()
// Create map of field info
fieldMap := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
if key := t.Field(i).Tag.Get("setting"); key != "" {
fieldMap[key] = i
}
}
// Get all settings from database
dbSettings, err := sm.q.ListSettings(ctx)
if err != nil {
return nil, err
}
// Create map of database settings for easier lookup
dbMap := make(map[string]string)
// Create map of database settings
dbMap := make(map[string]db.Setting)
for _, setting := range dbSettings {
dbMap[setting.Key] = setting.Value
dbMap[setting.Key] = setting
}
// Iterate through fields and set values
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
key := field.Tag.Get("setting")
@@ -211,47 +208,78 @@ func (sm *SettingsManager) GetAll(ctx context.Context) (*Settings, error) {
continue
}
fieldValue := reflect.New(field.Type).Elem()
description := field.Tag.Get("description")
// If value exists in database, use it
if value, exists := dbMap[key]; exists {
if err := parseValue(v.Field(i), value); err != nil {
if dbSetting, exists := dbMap[key]; exists {
if err := parseValue(fieldValue, dbSetting.Value); err != nil {
return nil, fmt.Errorf("error parsing setting %s: %w", key, err)
}
if dbSetting.Description != nil {
description = *dbSetting.Description
}
} else {
// If no value in database, use default
// Use default value
defaultVal := field.Tag.Get("default")
if defaultVal != "" {
if err := parseValue(v.Field(i), defaultVal); err != nil {
if err := parseValue(fieldValue, defaultVal); err != nil {
return nil, fmt.Errorf("error parsing default value for %s: %w", key, err)
}
}
}
desc := &description
if description == "" {
desc = nil
}
// Convert duration fields to formatted strings
var value interface{}
if field.Type == reflect.TypeOf(time.Duration(0)) {
value = fieldValue.Interface().(time.Duration).String()
} else {
value = fieldValue.Interface()
}
settings[key] = SettingWithDescription{
Value: value,
Description: desc,
}
}
return settings, nil
}
func (sm *SettingsManager) Get(ctx context.Context, key string) (interface{}, error) {
// Modified Get to return setting with description
func (sm *SettingsManager) Get(ctx context.Context, key string) (*SettingWithDescription, error) {
setting, err := sm.q.GetSetting(ctx, key)
if err != nil {
return nil, err
}
// Find the corresponding field and its type in Settings struct
v := reflect.ValueOf(sm.defaults).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Tag.Get("setting") == key {
// Create a new value of the correct type
fieldValue := reflect.New(t.Field(i).Type).Elem()
// Parse the string value to the correct type
if err := parseValue(fieldValue, setting.Value); err != nil {
return nil, fmt.Errorf("error parsing setting %s: %w", key, err)
}
// Return the interface{} value
return fieldValue.Interface(), nil
// Convert duration fields to formatted strings
var value interface{}
if t.Field(i).Type == reflect.TypeOf(time.Duration(0)) {
value = fieldValue.Interface().(time.Duration).String()
} else {
value = fieldValue.Interface()
}
return &SettingWithDescription{
Value: value,
Description: setting.Description,
}, nil
}
}
@@ -259,7 +287,11 @@ func (sm *SettingsManager) Get(ctx context.Context, key string) (interface{}, er
}
// Set updates a setting with proper type conversion from string input
func (sm *SettingsManager) Set(ctx context.Context, key string, strValue string) error {
func (sm *SettingsManager) Set(
ctx context.Context,
key, strValue string,
description *string,
) error {
// Find the corresponding field to validate and convert the type
v := reflect.ValueOf(sm.defaults).Elem()
t := v.Type()
@@ -273,11 +305,14 @@ func (sm *SettingsManager) Set(ctx context.Context, key string, strValue string)
if err := parseValue(fieldValue, strValue); err != nil {
return fmt.Errorf("invalid value for setting %s: %w", key, err)
}
return sm.q.UpsertSetting(ctx, db.UpsertSettingParams{
params := db.UpsertSettingParams{
Key: key,
Value: strValue,
})
}
if description != nil {
params.Description = description
}
return sm.q.UpsertSetting(ctx, params)
}
}

View File

@@ -129,7 +129,6 @@ func (a *App) setDefaultAdminUser() error {
// Update existing admin if credentials changed or password provided
if user.Username != a.Config.Admin.Username ||
user.Email != &a.Config.Admin.Email ||
a.Config.Admin.Password != "" {
if err := a.DB.UpdateUser(ctx, db.UpdateUserParams{

View File

@@ -11,49 +11,26 @@ import (
const createAgent = `-- name: CreateAgent :exec
INSERT INTO
agents (
id,
profile_id,
hostname,
public_ip,
private_ips,
containers,
active_ip,
token
)
agents (id, profile_id, token)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?)
(?, ?, ?)
`
type CreateAgentParams struct {
ID string `json:"id"`
ProfileID int64 `json:"profileId"`
Hostname string `json:"hostname"`
PublicIp *string `json:"publicIp"`
PrivateIps interface{} `json:"privateIps"`
Containers interface{} `json:"containers"`
ActiveIp *string `json:"activeIp"`
Token string `json:"token"`
ID string `json:"id"`
ProfileID int64 `json:"profileId"`
Token string `json:"token"`
}
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) error {
_, err := q.exec(ctx, q.createAgentStmt, createAgent,
arg.ID,
arg.ProfileID,
arg.Hostname,
arg.PublicIp,
arg.PrivateIps,
arg.Containers,
arg.ActiveIp,
arg.Token,
)
_, err := q.exec(ctx, q.createAgentStmt, createAgent, arg.ID, arg.ProfileID, arg.Token)
return err
}
const deleteAgent = `-- name: DeleteAgent :exec
DELETE FROM agents
WHERE
id = ?
id = ?
`
func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
@@ -63,11 +40,11 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
const getAgent = `-- name: GetAgent :one
SELECT
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
FROM
agents
agents
WHERE
id = ?
id = ?
`
func (q *Queries) GetAgent(ctx context.Context, id string) (Agent, error) {
@@ -90,11 +67,11 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (Agent, error) {
const listAgents = `-- name: ListAgents :many
SELECT
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
FROM
agents
agents
ORDER BY
hostname
hostname
`
func (q *Queries) ListAgents(ctx context.Context) ([]Agent, error) {
@@ -133,11 +110,11 @@ func (q *Queries) ListAgents(ctx context.Context) ([]Agent, error) {
const listAgentsByProfile = `-- name: ListAgentsByProfile :many
SELECT
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
id, profile_id, hostname, public_ip, private_ips, containers, active_ip, token, created_at, updated_at
FROM
agents
agents
WHERE
profile_id = ?
profile_id = ?
`
func (q *Queries) ListAgentsByProfile(ctx context.Context, profileID int64) ([]Agent, error) {
@@ -177,18 +154,18 @@ func (q *Queries) ListAgentsByProfile(ctx context.Context, profileID int64) ([]A
const updateAgent = `-- name: UpdateAgent :exec
UPDATE agents
SET
hostname = ?,
public_ip = ?,
private_ips = ?,
containers = ?,
active_ip = ?,
updated_at = CURRENT_TIMESTAMP
hostname = ?,
public_ip = ?,
private_ips = ?,
containers = ?,
active_ip = ?,
updated_at = CURRENT_TIMESTAMP
WHERE
id = ?
id = ?
`
type UpdateAgentParams struct {
Hostname string `json:"hostname"`
Hostname *string `json:"hostname"`
PublicIp *string `json:"publicIp"`
PrivateIps interface{} `json:"privateIps"`
Containers interface{} `json:"containers"`
@@ -207,3 +184,21 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) error
)
return err
}
const updateAgentToken = `-- name: UpdateAgentToken :exec
UPDATE agents
SET
token = ?
WHERE
id = ?
`
type UpdateAgentTokenParams struct {
Token string `json:"token"`
ID string `json:"id"`
}
func (q *Queries) UpdateAgentToken(ctx context.Context, arg UpdateAgentTokenParams) error {
_, err := q.exec(ctx, q.updateAgentTokenStmt, updateAgentToken, arg.Token, arg.ID)
return err
}

View File

@@ -120,6 +120,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.updateAgentStmt, err = db.PrepareContext(ctx, updateAgent); err != nil {
return nil, fmt.Errorf("error preparing query UpdateAgent: %w", err)
}
if q.updateAgentTokenStmt, err = db.PrepareContext(ctx, updateAgentToken); err != nil {
return nil, fmt.Errorf("error preparing query UpdateAgentToken: %w", err)
}
if q.updateDNSProviderStmt, err = db.PrepareContext(ctx, updateDNSProvider); err != nil {
return nil, fmt.Errorf("error preparing query UpdateDNSProvider: %w", err)
}
@@ -303,6 +306,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing updateAgentStmt: %w", cerr)
}
}
if q.updateAgentTokenStmt != nil {
if cerr := q.updateAgentTokenStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing updateAgentTokenStmt: %w", cerr)
}
}
if q.updateDNSProviderStmt != nil {
if cerr := q.updateDNSProviderStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing updateDNSProviderStmt: %w", cerr)
@@ -404,6 +412,7 @@ type Queries struct {
listSettingsStmt *sql.Stmt
listUsersStmt *sql.Stmt
updateAgentStmt *sql.Stmt
updateAgentTokenStmt *sql.Stmt
updateDNSProviderStmt *sql.Stmt
updateProfileStmt *sql.Stmt
updateTraefikConfigStmt *sql.Stmt
@@ -448,6 +457,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listSettingsStmt: q.listSettingsStmt,
listUsersStmt: q.listUsersStmt,
updateAgentStmt: q.updateAgentStmt,
updateAgentTokenStmt: q.updateAgentTokenStmt,
updateDNSProviderStmt: q.updateDNSProviderStmt,
updateProfileStmt: q.updateProfileStmt,
updateTraefikConfigStmt: q.updateTraefikConfigStmt,

View File

@@ -57,13 +57,14 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL,
hostname TEXT NOT NULL,
hostname TEXT,
public_ip TEXT,
private_ips JSON,
containers JSON,

View File

@@ -13,7 +13,7 @@ import (
type Agent struct {
ID string `json:"id"`
ProfileID int64 `json:"profileId"`
Hostname string `json:"hostname"`
Hostname *string `json:"hostname"`
PublicIp *string `json:"publicIp"`
PrivateIps interface{} `json:"privateIps"`
Containers interface{} `json:"containers"`
@@ -51,9 +51,10 @@ type RouterDnsProvider struct {
}
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt *time.Time `json:"updatedAt"`
Key string `json:"key"`
Value string `json:"value"`
Description *string `json:"description"`
UpdatedAt *time.Time `json:"updatedAt"`
}
type Traefik struct {

View File

@@ -41,6 +41,7 @@ type Querier interface {
ListSettings(ctx context.Context) ([]Setting, error)
ListUsers(ctx context.Context) ([]User, error)
UpdateAgent(ctx context.Context, arg UpdateAgentParams) error
UpdateAgentToken(ctx context.Context, arg UpdateAgentTokenParams) error
UpdateDNSProvider(ctx context.Context, arg UpdateDNSProviderParams) error
UpdateProfile(ctx context.Context, arg UpdateProfileParams) error
UpdateTraefikConfig(ctx context.Context, arg UpdateTraefikConfigParams) error

View File

@@ -1,55 +1,53 @@
-- name: CreateAgent :exec
INSERT INTO
agents (
id,
profile_id,
hostname,
public_ip,
private_ips,
containers,
active_ip,
token
)
agents (id, profile_id, token)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?);
(?, ?, ?);
-- name: GetAgent :one
SELECT
*
*
FROM
agents
agents
WHERE
id = ?;
id = ?;
-- name: ListAgents :many
SELECT
*
*
FROM
agents
agents
ORDER BY
hostname;
hostname;
-- name: ListAgentsByProfile :many
SELECT
*
*
FROM
agents
agents
WHERE
profile_id = ?;
profile_id = ?;
-- name: UpdateAgent :exec
UPDATE agents
SET
hostname = ?,
public_ip = ?,
private_ips = ?,
containers = ?,
active_ip = ?,
updated_at = CURRENT_TIMESTAMP
hostname = ?,
public_ip = ?,
private_ips = ?,
containers = ?,
active_ip = ?,
updated_at = CURRENT_TIMESTAMP
WHERE
id = ?;
id = ?;
-- name: UpdateAgentToken :exec
UPDATE agents
SET
token = ?
WHERE
id = ?;
-- name: DeleteAgent :exec
DELETE FROM agents
WHERE
id = ?;
id = ?;

View File

@@ -1,30 +1,31 @@
-- name: UpsertSetting :exec
INSERT INTO
settings (key, value)
settings (key, value, description)
VALUES
(?, ?) ON CONFLICT (key) DO
(?, ?, ?) ON CONFLICT (key) DO
UPDATE
SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP;
value = excluded.value,
description = excluded.description,
updated_at = CURRENT_TIMESTAMP;
-- name: GetSetting :one
SELECT
*
*
FROM
settings
settings
WHERE
key = ?
key = ?
LIMIT
1;
1;
-- name: ListSettings :many
SELECT
*
*
FROM
settings;
settings;
-- name: DeleteSetting :exec
DELETE FROM settings
WHERE
key = ?;
key = ?;

View File

@@ -12,7 +12,7 @@ import (
const deleteSetting = `-- name: DeleteSetting :exec
DELETE FROM settings
WHERE
key = ?
key = ?
`
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
@@ -22,27 +22,32 @@ func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
const getSetting = `-- name: GetSetting :one
SELECT
"key", value, updated_at
"key", value, description, updated_at
FROM
settings
settings
WHERE
key = ?
key = ?
LIMIT
1
1
`
func (q *Queries) GetSetting(ctx context.Context, key string) (Setting, error) {
row := q.queryRow(ctx, q.getSettingStmt, getSetting, key)
var i Setting
err := row.Scan(&i.Key, &i.Value, &i.UpdatedAt)
err := row.Scan(
&i.Key,
&i.Value,
&i.Description,
&i.UpdatedAt,
)
return i, err
}
const listSettings = `-- name: ListSettings :many
SELECT
"key", value, updated_at
"key", value, description, updated_at
FROM
settings
settings
`
func (q *Queries) ListSettings(ctx context.Context) ([]Setting, error) {
@@ -54,7 +59,12 @@ func (q *Queries) ListSettings(ctx context.Context) ([]Setting, error) {
var items []Setting
for rows.Next() {
var i Setting
if err := rows.Scan(&i.Key, &i.Value, &i.UpdatedAt); err != nil {
if err := rows.Scan(
&i.Key,
&i.Value,
&i.Description,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -70,21 +80,23 @@ func (q *Queries) ListSettings(ctx context.Context) ([]Setting, error) {
const upsertSetting = `-- name: UpsertSetting :exec
INSERT INTO
settings (key, value)
settings (key, value, description)
VALUES
(?, ?) ON CONFLICT (key) DO
(?, ?, ?) ON CONFLICT (key) DO
UPDATE
SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
value = excluded.value,
description = excluded.description,
updated_at = CURRENT_TIMESTAMP
`
type UpsertSettingParams struct {
Key string `json:"key"`
Value string `json:"value"`
Key string `json:"key"`
Value string `json:"value"`
Description *string `json:"description"`
}
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) error {
_, err := q.exec(ctx, q.upsertSettingStmt, upsertSetting, arg.Key, arg.Value)
_, err := q.exec(ctx, q.upsertSettingStmt, upsertSetting, arg.Key, arg.Value, arg.Description)
return err
}

View File

@@ -1,6 +1,10 @@
package util
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
)
@@ -18,11 +22,61 @@ const (
EventTypeDelete = "delete"
)
// A channel to broadcast updates
var Broadcast = make(chan EventMessage)
// A list of clients connected to SSE
var (
Broadcast = make(chan EventMessage, 100)
done = make(chan struct{})
Clients = make(map[http.ResponseWriter]bool)
ClientsMutex = &sync.Mutex{}
)
func StartEventProcessor(ctx context.Context) {
go func() {
for {
select {
case msg := <-Broadcast:
ClientsMutex.Lock()
for client := range Clients {
// Non-blocking send to each client
go func(w http.ResponseWriter, message EventMessage) {
if err := sendEventToClient(w, message); err != nil {
slog.Error("Failed to send event", "error", err)
// Optionally remove failed clients
ClientsMutex.Lock()
delete(Clients, w)
ClientsMutex.Unlock()
}
}(client, msg)
}
ClientsMutex.Unlock()
// If no clients, log the dropped event
if len(Clients) == 0 {
slog.Debug("Event dropped - no clients connected",
"type", msg.Type,
"message", msg.Message)
}
case <-ctx.Done():
close(done)
return
}
}
}()
}
func sendEventToClient(w http.ResponseWriter, msg EventMessage) error {
// Implementation of sending event to a single client
data, err := json.Marshal(msg)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", data)
if err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}

View File

@@ -18,7 +18,7 @@
"@sveltejs/kit": "^2.16.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.7",
"@types/node": "^22.10.9",
"autoprefixer": "^10.4.20",
"bits-ui": "1.0.0-next.78",
"clsx": "^2.1.1",
@@ -46,14 +46,14 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.21.0",
"vite": "^5.4.14",
"yaml": "^2.7.0",
"zod": "^3.24.1"
},
"type": "module",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/table-core": "^8.20.5",
"yaml": "^2.7.0"
"@tanstack/table-core": "^8.20.5"
},
"packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a"
}

82
web/pnpm-lock.yaml generated
View File

@@ -17,9 +17,6 @@ importers:
'@tanstack/table-core':
specifier: ^8.20.5
version: 8.20.5
yaml:
specifier: ^2.7.0
version: 2.7.0
devDependencies:
'@fontsource/fira-mono':
specifier: ^5.1.1
@@ -29,22 +26,22 @@ importers:
version: 2.2.1
'@sveltejs/adapter-auto':
specifier: ^3.3.1
version: 3.3.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))
version: 3.3.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.8(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))
version: 3.0.8(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))
'@sveltejs/kit':
specifier: ^2.16.1
version: 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
version: 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
'@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4
version: 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
version: 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
'@types/eslint':
specifier: ^9.6.1
version: 9.6.1
'@types/node':
specifier: ^22.10.7
version: 22.10.7
specifier: ^22.10.9
version: 22.10.9
autoprefixer:
specifier: ^10.4.20
version: 10.4.20(postcss@8.5.1)
@@ -65,7 +62,7 @@ importers:
version: 2.46.1(eslint@9.18.0(jiti@1.21.7))(svelte@5.19.2)
formsnap:
specifier: ^2.0.0
version: 2.0.0(svelte@5.19.2)(sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3))
version: 2.0.0(svelte@5.19.2)(sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3))
globals:
specifier: ^15.14.0
version: 15.14.0
@@ -104,7 +101,7 @@ importers:
version: 0.3.28(svelte@5.19.2)
sveltekit-superforms:
specifier: ^2.23.1
version: 2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)
version: 2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
@@ -125,7 +122,10 @@ importers:
version: 8.21.0(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3)
vite:
specifier: ^5.4.14
version: 5.4.14(@types/node@22.10.7)
version: 5.4.14(@types/node@22.10.9)
yaml:
specifier: ^2.7.0
version: 2.7.0
zod:
specifier: ^3.24.1
version: 3.24.1
@@ -735,8 +735,8 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@22.10.7':
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
'@types/node@22.10.9':
resolution: {integrity: sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==}
'@types/validator@13.12.2':
resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==}
@@ -1003,8 +1003,8 @@ packages:
effect@3.12.7:
resolution: {integrity: sha512-BsDTgSjLbL12g0+vGn5xkOgOVhRSaR3VeHmjcUb0gLvpXACJ9OgmlfeH+/FaAZwM5+omIF3I/j1gC5KJrbK1Aw==}
electron-to-chromium@1.5.84:
resolution: {integrity: sha512-I+DQ8xgafao9Ha6y0qjHHvpZ9OfyA1qKlkHkjywxzniORU2awxyz7f/iVJcULmrF2yrM3nHQf+iDjJtbbexd/g==}
electron-to-chromium@1.5.86:
resolution: {integrity: sha512-/D7GAAaCRBQFBBcop6SfAAGH37djtpWkOuYhyAajw0l5vsfeSsUQYxaFPwr1c/mC/flARCDdKFo5gpFqNI+18w==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2359,18 +2359,18 @@ snapshots:
'@sinclair/typebox@0.34.14':
optional: true
'@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))':
'@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))':
dependencies:
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
import-meta-resolve: 4.1.0
'@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))':
'@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))':
dependencies:
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
'@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))':
'@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@@ -2383,27 +2383,27 @@ snapshots:
set-cookie-parser: 2.7.1
sirv: 3.0.0
svelte: 5.19.2
vite: 5.4.14(@types/node@22.10.7)
vite: 5.4.14(@types/node@22.10.9)
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))':
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
debug: 4.4.0
svelte: 5.19.2
vite: 5.4.14(@types/node@22.10.7)
vite: 5.4.14(@types/node@22.10.9)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))':
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
debug: 4.4.0
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 5.19.2
vite: 5.4.14(@types/node@22.10.7)
vitefu: 1.0.5(vite@5.4.14(@types/node@22.10.7))
vite: 5.4.14(@types/node@22.10.9)
vitefu: 1.0.5(vite@5.4.14(@types/node@22.10.9))
transitivePeerDependencies:
- supports-color
@@ -2428,7 +2428,7 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/node@22.10.7':
'@types/node@22.10.9':
dependencies:
undici-types: 6.20.0
@@ -2629,7 +2629,7 @@ snapshots:
browserslist@4.24.4:
dependencies:
caniuse-lite: 1.0.30001695
electron-to-chromium: 1.5.84
electron-to-chromium: 1.5.86
node-releases: 2.0.19
update-browserslist-db: 1.1.2(browserslist@4.24.4)
@@ -2719,7 +2719,7 @@ snapshots:
fast-check: 3.23.2
optional: true
electron-to-chromium@1.5.84: {}
electron-to-chromium@1.5.86: {}
emoji-regex@8.0.0: {}
@@ -2954,11 +2954,11 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
formsnap@2.0.0(svelte@5.19.2)(sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)):
formsnap@2.0.0(svelte@5.19.2)(sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)):
dependencies:
svelte: 5.19.2
svelte-toolbelt: 0.5.0(svelte@5.19.2)
sveltekit-superforms: 2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)
sveltekit-superforms: 2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3)
fraction.js@4.3.7: {}
@@ -3481,9 +3481,9 @@ snapshots:
magic-string: 0.30.17
zimmerframe: 1.1.2
sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3):
sveltekit-superforms@2.23.1(@sveltejs/kit@2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(@types/json-schema@7.0.15)(svelte@5.19.2)(typescript@5.7.3):
dependencies:
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.7))
'@sveltejs/kit': 2.16.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9)))(svelte@5.19.2)(vite@5.4.14(@types/node@22.10.9))
devalue: 5.1.1
memoize-weak: 1.0.2
svelte: 5.19.2
@@ -3626,18 +3626,18 @@ snapshots:
validator@13.12.0:
optional: true
vite@5.4.14(@types/node@22.10.7):
vite@5.4.14(@types/node@22.10.9):
dependencies:
esbuild: 0.21.5
postcss: 8.5.1
rollup: 4.31.0
optionalDependencies:
'@types/node': 22.10.7
'@types/node': 22.10.9
fsevents: 2.3.3
vitefu@1.0.5(vite@5.4.14(@types/node@22.10.7)):
vitefu@1.0.5(vite@5.4.14(@types/node@22.10.9)):
optionalDependencies:
vite: 5.4.14(@types/node@22.10.7)
vite: 5.4.14(@types/node@22.10.9)
which@2.0.2:
dependencies:

View File

@@ -4,8 +4,9 @@ import {
type DNSProvider,
type Plugin,
type Profile,
type Setting,
type Settings,
type TraefikConfig,
type UpsertSettingsParams,
type User
} from './types';
import type { EntryPoints } from './types/entrypoints';
@@ -43,7 +44,7 @@ export const middlewares: Writable<Middleware[]> = writable([]);
export const users: Writable<User[]> = writable([]);
export const dnsProviders: Writable<DNSProvider[]> = writable([]);
export const agents: Writable<Agent[]> = writable([]);
export const settings: Writable<Setting[]> = writable([]);
export const settings: Writable<Settings> = writable({} as Settings);
export const plugins: Writable<Plugin[]> = writable([]);
// App state
@@ -62,19 +63,28 @@ interface APIOptions {
headers?: Record<string, string>;
}
async function send(endpoint: string, options: APIOptions = {}) {
async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof window.fetch) {
const token = localStorage.getItem(TOKEN_SK);
const headers = {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers
// Custom fetch function that adds the Authorization header
const customFetch: typeof window.fetch = async (url, options) => {
const headers = new Headers(options?.headers); // Get existing headers
if (token) {
headers.set('Authorization', 'Bearer ' + token); // Add the Authorization header
}
const customOptions = {
'Content-Type': 'application/json',
...options,
headers
};
return fetch ? fetch(url, customOptions) : window.fetch(url, customOptions); // Use custom fetch or default
};
try {
loading.set(true);
const response = await fetch(`${BASE_URL}${endpoint}`, {
const response = await customFetch(`${BASE_URL}${endpoint}`, {
method: options.method || 'GET',
body: options.body ? JSON.stringify(options.body) : undefined,
headers
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!response.ok) {
@@ -85,6 +95,7 @@ async function send(endpoint: string, options: APIOptions = {}) {
}
} catch (err: unknown) {
error.set(err instanceof Error ? err.message : String(err));
loading.set(false);
throw err;
} finally {
loading.set(false);
@@ -106,13 +117,17 @@ export const api = {
return data;
},
async verify() {
async verify(fetch: typeof window.fetch = window.fetch) {
const token = localStorage.getItem(TOKEN_SK);
try {
const data = await send('/verify', {
method: 'POST',
body: token
});
const data = await send(
'/verify',
{
method: 'POST',
body: token
},
fetch
);
return data;
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
@@ -185,6 +200,11 @@ export const api = {
}
},
async getDynamicConfig(profileName: string) {
return await send(`/${profileName}`);
},
// Routers -------------------------------------------------------------------
async upsertRouter(id: number, data: UpsertRouterParams) {
await send(`/router/${id}`, {
method: 'POST',
@@ -200,6 +220,7 @@ export const api = {
await api.getTraefikConfig(id, TraefikSource.LOCAL);
},
// Middlewares ---------------------------------------------------------------
async upsertMiddleware(id: number, data: UpsertMiddlewareParams) {
await send(`/middleware/${id}`, {
method: 'POST',
@@ -268,7 +289,7 @@ export const api = {
},
async updateUser(user: Omit<User, 'created_at' | 'updated_at'>) {
await send(`/user/${user.id}`, {
await send(`/user`, {
method: 'PUT',
body: user
});
@@ -288,6 +309,30 @@ export const api = {
agents.set(data);
},
async getAgent(id: string) {
return await send(`/agent/${id}`);
},
async createAgent(profileID: number) {
await send(`/agent/${profileID}`, { method: 'POST' });
await api.listAgents();
},
async updateAgent(agent: Omit<Agent, 'created_at' | 'updated_at'>) {
await send(`/agent/${agent.id}`, {
method: 'PUT',
body: agent
});
await api.listAgents();
},
async deleteAgent(id: string) {
await send(`/agent/${id}`, {
method: 'DELETE'
});
await api.listAgents();
},
// Settings ------------------------------------------------------------------
async listSettings() {
const data = await send('/settings');
@@ -298,7 +343,7 @@ export const api = {
return await send(`/settings/${id}`);
},
async upsertSetting(setting: Setting) {
async upsertSetting(setting: UpsertSettingsParams) {
await send(`/settings`, {
method: 'POST',
body: setting
@@ -407,481 +452,3 @@ async function fetchTraefikConfig(id: number, source: TraefikSource) {
// });
// }
// }
// export async function logout() {
// localStorage.removeItem(TOKEN_SK);
// loggedIn.set(false);
// }
// // Profiles -------------------------------------------------------------------
// export async function getProfiles() {
// const response = await handleRequest('/profile', 'GET');
// if (response) {
// const data = await response.json();
// if (data) {
// profiles.set(data);
// // Get saved profile
// const profileID = parseInt(localStorage.getItem(PROFILE_SK) ?? '');
// if (profileID) {
// getProfile(profileID);
// return;
// }
// if (data === undefined) return;
// if (!get(profile) && data.length > 0) {
// getProfile(data[0].id);
// }
// }
// }
// }
// export async function getProfile(id: number) {
// const response = await handleRequest(`/profile/${id}`, 'GET');
// if (response) {
// const data = await response.json();
// profile.set(data);
// localStorage.setItem(PROFILE_SK, data.id.toString());
// } else {
// localStorage.removeItem(PROFILE_SK);
// return;
// }
// await getEntrypoints();
// }
// export async function upsertProfile(p: Profile): Promise<void> {
// const response = await handleRequest(`/profile`, 'PUT', p);
// if (response) {
// const data = await response.json();
// if (data && get(profiles)) {
// profiles.update((items) => {
// const index = items.findIndex((item) => item.id === p.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// }
// toast.success(`Profile ${data.name} updated`);
// if (get(profile) && get(profile).id === data.id) {
// profile.set(data);
// localStorage.setItem(PROFILE_SK, data.id.toString());
// }
// }
// }
// export async function deleteProfile(p: Profile): Promise<void> {
// const response = await handleRequest(`/profile/${p.id}`, 'DELETE', p);
// if (response) {
// profiles.update((items) => items.filter((i) => i.id !== p.id));
// toast.success(`Profile deleted`);
// if (get(profile).id === p.id) {
// profile.set({} as Profile);
// localStorage.removeItem(PROFILE_SK);
// }
// }
// }
// // Routers ----------------------------------------------------------------------
// export async function getRouters() {
// const profileID = get(profile)?.id;
// if (!profileID) return;
// const response = await handleRequest(`/router/${profileID}`, 'GET');
// if (response) {
// const data = await response.json();
// if (data) routers.set(data);
// }
// }
// export async function upsertRouter(r: Router): Promise<void> {
// const response = await handleRequest(`/router`, 'POST', r);
// if (response) {
// const data = await response.json();
// if (data && get(routers)) {
// routers.update((items) => {
// const index = items.findIndex((item) => item.id === r.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// toast.success(`Router ${r.name} updated`);
// }
// }
// }
// export async function deleteRouter(r: Router): Promise<void> {
// const response = await handleRequest(`/router/${r.id}`, 'DELETE');
// if (response) {
// routers.update((items) => items.filter((i) => i.id !== r.id));
// toast.success(`Router ${r.name} deleted`);
// }
// }
// // Services ----------------------------------------------------------------------
// export async function getServices() {
// const profileID = get(profile)?.id;
// if (!profileID) return;
// const response = await handleRequest(`/service/${profileID}`, 'GET');
// if (response) {
// const data = await response.json();
// if (data) services.set(data);
// }
// }
// export async function upsertService(s: Service): Promise<void> {
// const response = await handleRequest(`/service`, 'POST', s);
// if (response) {
// const data = await response.json();
// if (data && get(services)) {
// services.update((items) => {
// const index = items.findIndex((item) => item.id === s.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// //toast.success(`Service ${data.name} created`);
// }
// }
// }
// export async function deleteService(s: Service): Promise<void> {
// const response = await handleRequest(`/service/${s.id}`, 'DELETE');
// if (response) {
// services.update((items) => items.filter((i) => i.id !== s.id));
// }
// }
// // Middleware -----------------------------------------------------------------
// export async function getMiddlewares() {
// const profileID = get(profile)?.id;
// if (!profileID) return;
// const response = await handleRequest(`/middleware/${profileID}`, 'GET');
// if (response) {
// const data = await response.json();
// if (data) middlewares.set(data);
// }
// }
// export async function upsertMiddleware(m: Middleware): Promise<void> {
// const response = await handleRequest(`/middleware`, 'POST', m);
// if (response) {
// const data = await response.json();
// if (data && get(middlewares)) {
// middlewares.update((items) => {
// const index = items.findIndex((item) => item.id === m.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// toast.success(`Middleware ${data.name} updated`);
// }
// }
// }
// export async function deleteMiddleware(m: Middleware): Promise<void> {
// const response = await handleRequest(`/middleware/${m.id}`, 'DELETE');
// if (response) {
// middlewares.update((items) => items.filter((i) => i.id !== m.id));
// toast.success(`Middleware ${m.name} deleted`);
// }
// }
// // Users ----------------------------------------------------------------------
// export async function getUsers() {
// const response = await handleRequest('/user', 'GET');
// if (response) {
// const data = await response.json();
// if (data) users.set(data);
// }
// }
// export async function upsertUser(u: User): Promise<void> {
// const response = await handleRequest(`/user`, 'POST', u);
// if (response) {
// const data = await response.json();
// if (data && get(users)) {
// users.update((items) => {
// const index = items.findIndex((item) => item.id === u.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// toast.success(`User ${data.username} updated`);
// }
// }
// }
// export async function deleteUser(id: number): Promise<void> {
// const response = await handleRequest(`/user/${id}`, 'DELETE');
// if (response) {
// users.update((items) => items.filter((i) => i.id !== id));
// toast.success(`User deleted`);
// }
// }
// // Provider -------------------------------------------------------------------
// export async function getProviders() {
// const response = await handleRequest('/provider', 'GET');
// if (response) {
// const data = await response.json();
// if (data) provider.set(data);
// }
// }
// export async function upsertProvider(p: DNSProvider): Promise<void> {
// const response = await handleRequest(`/provider`, 'POST', p);
// if (response) {
// const data = await response.json();
// if (data && get(provider)) {
// provider.update((items) => {
// const index = items.findIndex((item) => item.id === p.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// toast.success(`Provider ${data.name} updated`);
// }
// }
// }
// export async function deleteProvider(id: number): Promise<void> {
// const response = await handleRequest(`/provider/${id}`, 'DELETE');
// if (response) {
// provider.update((items) => items.filter((i) => i.id !== id));
// toast.success(`Provider deleted`);
// }
// }
// // Entrypoints ----------------------------------------------------------------
// export async function getEntrypoints() {
// const profileID = get(profile)?.id;
// if (!profileID) return;
// const response = await handleRequest(`/entrypoint/${profileID}`, 'GET');
// if (response) {
// const data = await response.json();
// entrypoints.set(data);
// }
// }
// export async function deleteRouterDNS(r: Router): Promise<void> {
// if (!r || !r.dnsProvider) return;
// const response = await handleRequest(`/dns`, 'POST', r);
// if (response) {
// toast.success(`DNS record of router ${r.name} deleted`);
// }
// }
// // Settings -------------------------------------------------------------------
// export async function getSettings(): Promise<void> {
// const response = await handleRequest('/settings', 'GET');
// if (response) {
// const data = await response.json();
// settings.set(data);
// }
// }
// export async function getSetting(key: string) {
// const response = await handleRequest(`/settings/${key}`, 'GET');
// if (response) {
// const data = await response.json();
// return data;
// }
// return {} as Setting;
// }
// export async function updateSetting(s: Setting): Promise<void> {
// const response = await handleRequest(`/settings`, 'PUT', s);
// if (response) {
// const data = await response.json();
// settings.update((items) => items.map((i) => (i.key === s.key ? data : i)));
// toast.success(`Setting ${s.key} updated`);
// }
// }
// // Agents ---------------------------------------------------------------------
// export async function getAgents(id: number) {
// const response = await handleRequest(`/agent/${id}`, 'GET');
// if (response) {
// const data = await response.json();
// if (data) agents.set(data);
// }
// }
// export async function upsertAgent(agent: Agent) {
// const response = await handleRequest(`/agent`, 'PUT', agent);
// if (response) {
// const data = await response.json();
// if (data && get(agents)) {
// agents.update((items) => {
// const index = items.findIndex((item) => item.id === agent.id);
// if (index !== -1) {
// items[index] = data;
// return [...items];
// } else {
// return [...items, data];
// }
// });
// }
// }
// }
// export async function deleteAgent(id: string) {
// const response = await handleRequest(`/agent/${id}`, 'DELETE');
// if (response) {
// agents.update((items) => items.filter((i) => i.id !== id));
// toast.success(`Agent deleted`);
// }
// }
// // Backup ---------------------------------------------------------------------
// export async function downloadBackup() {
// const response = await handleRequest('/backup', 'GET');
// if (response) {
// const data = await response.json();
// const jsonString = JSON.stringify(data, null, 2);
// const blob = new Blob([jsonString], { type: 'application/json' });
// const url = URL.createObjectURL(blob);
// const link = document.createElement('a');
// link.href = url;
// link.download = `backup-${new Date().toISOString().split('T')[0]}.json`;
// document.body.appendChild(link);
// link.click();
// URL.revokeObjectURL(url);
// document.body.removeChild(link);
// }
// }
// export async function uploadBackup(file: File) {
// const formData = new FormData();
// formData.append('file', file);
// const token = localStorage.getItem(TOKEN_SK);
// await fetch(`${BASE_URL}/restore`, {
// method: 'POST',
// body: formData,
// headers: { Authorization: `Bearer ${token}` }
// });
// toast.success('Backup restored!');
// await getProfiles();
// await getUsers();
// await getProviders();
// await getEntrypoints();
// await getSettings();
// }
// // Plugins --------------------------------------------------------------------
// export async function getPlugins() {
// const response = await handleRequest('/middleware/plugins', 'GET');
// if (response) {
// const data = await response.json();
// plugins.set(data);
// }
// }
// // Extras ---------------------------------------------------------------------
// export async function getVersion() {
// const response = await handleRequest('/version', 'GET');
// if (response) {
// const data = await response.text();
// version.set(data);
// }
// }
// export async function getPublicIP() {
// const response = await handleRequest(`/ip/${get(profile).id}`, 'GET');
// if (response) {
// const data = await response.json();
// return data.ip;
// }
// return '';
// }
// export async function getTraefikOverview() {
// const profileID = get(profile)?.id;
// if (!profileID) return;
// const response = await handleRequest(`/traefik/${profileID}`, 'GET');
// if (response) {
// const data = await response.json();
// traefikError.set('');
// return data;
// } else {
// traefikError.set('No connection to Traefik');
// }
// return '';
// }
// export async function getTraefikConfig() {
// const profileName = get(profile)?.name;
// if (!profileName) return;
// const response = await handleRequest(`/${profileName}?yaml=true`, 'GET');
// if (response) {
// const data = await response.text();
// if (!data.includes('{}')) {
// dynamic.set(data);
// }
// } else {
// dynamic.set('');
// }
// }
// // Toggle functions -----------------------------------------------------------
// export async function toggleEntrypoint(
// router: Router,
// item: Selected<unknown>[] | undefined,
// update: boolean
// ) {
// if (item === undefined) return;
// router.entryPoints = item.map((i) => i.value) as string[];
// if (update) {
// upsertRouter(router);
// }
// }
// export async function toggleMiddleware(
// router: Router,
// item: Selected<unknown>[] | undefined,
// update: boolean
// ) {
// if (item === undefined) return;
// router.middlewares = item.map((i) => i.value) as string[];
// if (update) {
// upsertRouter(router);
// }
// }
// export async function toggleDNSProvider(
// router: Router,
// item: Selected<unknown> | undefined,
// update: boolean
// ) {
// const providerID = (item?.value as number) ?? 0;
// if (providerID === 0 && router.dnsProvider !== 0) {
// deleteRouterDNS(router);
// }
// router.dnsProvider = providerID;
// if (update) {
// upsertRouter(router);
// }
// }

View File

@@ -1,243 +0,0 @@
<script lang="ts">
import { getPublicIP } from '$lib/api';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { DNSProvider } from '$lib/types/base';
import autoAnimate from '@formkit/auto-animate';
import type { Selected } from 'bits-ui';
import { Copy, Eye, EyeOff } from 'lucide-svelte';
import { z } from 'zod';
import { Toggle } from '../ui/toggle';
import HoverInfo from '../utils/hoverInfo.svelte';
interface Props {
provider: DNSProvider;
}
let { provider = $bindable() }: Props = $props();
const providerTypes: Selected<string>[] = [
{ label: 'Cloudflare', value: 'cloudflare' },
{ label: 'PowerDNS', value: 'powerdns' },
{ label: 'Technitium', value: 'technitium' }
];
let showAPIKey = $state(false);
let providerType: Selected<string> | undefined = providerTypes.find(
(t) => t.value === provider.type
);
const setProviderType = async (type: Selected<string> | undefined) => {
if (type === undefined) return;
provider.type = type.value.toLowerCase();
if (provider.type === 'technitium') {
provider.zoneType = 'primary';
}
};
const schema = z.object({
name: z.string().trim().min(1, 'Name is required').max(255),
type: z.string().trim().min(1, 'Type is required').max(255),
externalIp: z.string().trim().ip().nullish(),
apiKey: z.string().trim().min(1, 'API Key is required').max(255),
apiUrl: z.string().trim().optional()
});
let errors: Record<any, string[] | undefined> = $state({});
export const validate = () => {
try {
schema.parse({ ...provider });
errors = {};
return true;
} catch (err: any) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
return false;
}
};
</script>
<Card.Root class="mt-4">
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">
<span>DNS Provider</span>
</Card.Title>
</Card.Header>
<Card.Content class="space-y-2">
<div class="mb-4 flex items-center justify-end gap-2">
<Tooltip.Root>
<Tooltip.Trigger>
<Label for="is_active" class="text-right">Default</Label>
<Switch name="is_active" bind:checked={provider.isActive} required />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-sm">
<p>Sets this provider as the default, any new router created will use this provider</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{#if provider.type === 'cloudflare'}
<div class="flex items-center justify-end gap-2">
<Label for="proxied" class="text-right">Proxied</Label>
<Switch name="proxied" bind:checked={provider.proxied} required />
</div>
{/if}
{#if provider.type === 'technitium'}
<div class="flex items-center justify-end gap-1 font-mono text-sm" use:autoAnimate>
<Toggle
size="sm"
pressed={provider.zoneType === 'primary'}
onPressedChange={() => (provider.zoneType = 'primary')}
class="font-bold data-[state=on]:bg-green-300 dark:data-[state=on]:text-black"
>
Primary
</Toggle>
<Toggle
size="sm"
pressed={provider.zoneType === 'forwarder'}
onPressedChange={() => (provider.zoneType = 'forwarder')}
class="font-bold data-[state=on]:bg-blue-300 dark:data-[state=on]:text-black"
>
Forwarder
</Toggle>
</div>
{/if}
<div class="grid grid-cols-4 items-center gap-2 space-y-2">
<Label for="current" class="text-right">Type</Label>
<Select.Root onSelectedChange={setProviderType} selected={providerType}>
<Select.Trigger class="col-span-3">
<Select.Value placeholder="Select a type" />
</Select.Trigger>
<Select.Content class="no-scrollbar max-h-[300px] overflow-y-auto">
{#each providerTypes as type}
<Select.Item value={type.value} label={type.label}>
{type.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="name" class="text-right">Name</Label>
<Input
name="name"
type="text"
bind:value={provider.name}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Name of the provider"
required
/>
{#if errors.name}
<div class="col-span-4 text-right text-sm text-red-500">{errors.name}</div>
{/if}
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="external-ip" class="col-span-1 flex items-center justify-end gap-0.5">
IP Address
<HoverInfo text="Use the public IP of your Traefik instance. (No schema or port)" />
</Label>
<div class="col-span-3 flex flex-row items-center justify-end gap-1">
<Input
name="external-ip"
type="text"
placeholder="Public IP address of Traefik"
bind:value={provider.externalIp}
on:input={validate}
class="pr-10"
required
/>
<Tooltip.Root openDelay={500}>
<Tooltip.Trigger class="absolute">
<Button
variant="ghost"
size="icon"
on:click={async () => {
provider.externalIp = await getPublicIP();
validate();
}}
class="hover:bg-transparent hover:text-red-400"
>
<Copy size="1rem" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="center" class="max-w-sm">
Use the external IP of your Traefik instance
</Tooltip.Content>
</Tooltip.Root>
</div>
{#if errors.externalIp}
<div class="col-span-4 text-right text-sm text-red-500">{errors.externalIp}</div>
{/if}
</div>
{#if provider.type === 'powerdns' || provider.type === 'technitium'}
<div class="grid grid-cols-4 items-center gap-2">
<Label for="url" class="col-span-1 flex items-center justify-end gap-0.5">
Endpoint
<HoverInfo text="The API endpoint of the provider" />
</Label>
<Input
name="url"
type="text"
placeholder={provider.type === 'powerdns'
? 'http://127.0.0.1:8081'
: 'http://127.0.0.1:5380'}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
bind:value={provider.apiUrl}
required
/>
{#if errors.apiUrl}
<div class="col-span-4 text-right text-sm text-red-500">{errors.apiUrl}</div>
{/if}
</div>
{/if}
<div class="grid grid-cols-4 items-center gap-2">
<Label for="key" class="text-right">API Key</Label>
<div class="col-span-3 flex flex-row items-center justify-end gap-1">
{#if showAPIKey}
<Input
name="key"
type="text"
bind:value={provider.apiKey}
on:input={validate}
placeholder="API Key of the provider"
class="pr-10"
required
/>
{:else}
<Input
name="key"
type="password"
bind:value={provider.apiKey}
on:input={validate}
placeholder="API Key of the provider"
class="pr-10"
required
/>
{/if}
<Button
variant="ghost"
size="icon"
class="absolute hover:bg-transparent hover:text-red-400"
on:click={() => (showAPIKey = !showAPIKey)}
>
{#if showAPIKey}
<Eye size="1rem" />
{:else}
<EyeOff size="1rem" />
{/if}
</Button>
</div>
{#if errors.apiKey}
<div class="col-span-4 text-right text-sm text-red-500">{errors.apiKey}</div>
{/if}
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,105 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import HoverInfo from '../utils/hoverInfo.svelte';
import type { Profile } from '$lib/types/base';
import { Eye, EyeOff } from 'lucide-svelte';
interface Props {
profile: Profile;
}
let { profile = $bindable() }: Props = $props();
let showPassword = $state(false);
</script>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">Name</Label>
<Input
name="name"
type="text"
bind:value={profile.name}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Your profile name"
required
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="url" class="text-right">URL</Label>
<Input
name="url"
type="text"
class="col-span-3"
bind:value={profile.url}
placeholder="URL of your traefik instance"
required
/>
</div>
<div class="flex flex-row items-center justify-end gap-2">
<Label for="tls" class="flex items-center gap-0.5">
Verify Certificate
<HoverInfo
text="If your Traefik instance uses a self-signed certificate, you can enable/disable certificate verification here."
/>
</Label>
<Checkbox name="tls" bind:checked={profile.tls} required />
</div>
<span class="mt-2 flex flex-row items-center gap-1 border-b border-gray-200 pb-2">
<span class="font-bold">Basic Authentication</span>
<HoverInfo
text="If your Traefik instance requires basic authentication, you can enter your username and password here."
/>
</span>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="username" class="text-right">Username</Label>
<Input
name="username"
type="text"
class="col-span-3"
bind:value={profile.username}
placeholder="Basic auth username"
required
/>
</div>
<div class="relative grid grid-cols-4 items-center gap-4">
<Label for="password" class="text-right">Password</Label>
<div class="col-span-3 flex flex-row items-center justify-end gap-1">
{#if showPassword}
<Input
name="password"
type="text"
class="col-span-3 pr-10"
bind:value={profile.password}
placeholder="Basic auth password"
required
/>
{:else}
<Input
name="password"
type="password"
class="col-span-3 pr-10"
bind:value={profile.password}
placeholder="Basic auth password"
required
/>
{/if}
<Button
variant="ghost"
size="icon"
class="absolute hover:bg-transparent hover:text-red-400"
on:click={() => (showPassword = !showPassword)}
>
{#if showPassword}
<Eye size="1rem" />
{:else}
<EyeOff size="1rem" />
{/if}
</Button>
</div>
</div>
</div>

View File

@@ -1,43 +1,57 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { upsertAgent } from '$lib/api';
import type { Agent } from '$lib/types/base';
import type { Agent } from '$lib/types';
import { toast } from 'svelte-sonner';
import { api, loading } from '$lib/api';
interface Props {
agent: Agent;
}
let { agent = $bindable() }: Props = $props();
let newIP = $state('');
const setActiveIP = async (ip: string) => {
agent.activeIp = ip;
await upsertAgent(agent);
interface Props {
agent: Agent | undefined;
open?: boolean;
}
toast.success('IP address updated!');
let { agent = $bindable({} as Agent), open = $bindable(false) }: Props = $props();
const handleSubmit = async () => {
if (!newIP) return;
if (agent.id) {
agent.activeIp = newIP;
await api.updateAgent(agent);
toast.success(`Agent ${agent.hostname} updated successfully`);
} else {
await api.createAgent(agent);
toast.success(`Agent ${agent.hostname} created successfully`);
}
open = false;
};
const handleDelete = async () => {
try {
await api.deleteAgent(agent.id);
toast.success(`Agent ${agent.hostname} deleted successfully`);
open = false;
} catch (err: unknown) {
const e = err as Error;
toast.error('Failed to delete agent', {
description: e.message
});
}
};
</script>
<Dialog.Root>
<Dialog.Trigger class="w-full">
<Button variant="ghost" class="w-full bg-orange-400 text-black">Details</Button>
</Dialog.Trigger>
<Dialog.Content class="no-scrollbar max-h-[80vh] max-w-2xl overflow-y-auto">
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">Agent details</Card.Title>
<Card.Description>
You can update which ip address should be used for the routers reported by the agent, or
set a custom ip.
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-2 text-sm">
{#if agent.id}
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{agent.id ? 'Update' : 'Add'} Agent</Dialog.Title>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="grid grid-cols-4 items-center gap-2">
<span class="col-span-1 font-mono">Hostname</span>
<div class="col-span-3 space-x-2">
@@ -50,7 +64,7 @@
{#if agent.activeIp === agent.publicIp || !agent.activeIp}
<Badge variant="default">{agent.publicIp}</Badge>
{:else}
<button onclick={() => setActiveIP(agent.publicIp)}>
<button onclick={handleSubmit}>
<Badge variant="secondary">{agent.publicIp}</Badge>
</button>
{/if}
@@ -59,15 +73,15 @@
<div class="grid grid-cols-4 items-center gap-2">
<span class="col-span-1 font-mono">Private IPs</span>
<div class="col-span-3 flex flex-wrap gap-2">
{#each agent.privateIps ?? [] as ip}
{#if agent.activeIp === ip}
<Badge variant="default">{ip}</Badge>
{:else}
<button onclick={() => setActiveIP(ip)}>
<Badge variant="secondary">{ip}</Badge>
</button>
{/if}
{/each}
<!-- {#each agent.privateIps ?? [] as ip} -->
<!-- {#if agent.activeIp === ip} -->
<!-- <Badge variant="default">{ip}</Badge> -->
<!-- {:else} -->
<!-- <button onclick={() => (agent.activeIp = ip)}> -->
<!-- <Badge variant="secondary">{ip}</Badge> -->
<!-- </button> -->
<!-- {/if} -->
<!-- {/each} -->
</div>
</div>
<div class="grid grid-cols-4 items-center gap-2">
@@ -81,7 +95,7 @@
<div class="grid grid-cols-4 items-center gap-2">
<span class="col-span-1 font-mono">Last Seen</span>
<div class="col-span-3 flex flex-wrap gap-2">
<Badge variant="secondary">{new Date(agent.lastSeen).toLocaleString()}</Badge>
<Badge variant="secondary">{new Date(agent.updatedAt).toLocaleString()}</Badge>
</div>
</div>
<div class="mt-4 grid grid-cols-4 items-center gap-2">
@@ -96,14 +110,13 @@
/>
</div>
</div>
</Card.Content>
</Card.Root>
<Dialog.Close class="w-full">
{#if newIP}
<Button class="w-full" on:click={() => setActiveIP(newIP)}>Save</Button>
{:else}
<Button class="w-full">Close</Button>
{/if}
</Dialog.Close>
</Dialog.Content>
</Dialog.Root>
<Dialog.Footer>
<Button type="button" variant="destructive" onclick={handleDelete} disabled={$loading}>
Delete
</Button>
<Button type="submit" disabled={$loading}>Update</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
{/if}

View File

@@ -5,27 +5,48 @@
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { entrypoints, overview, version } from '$lib/api';
import { api, entrypoints, overview, profile, version } from '$lib/api';
import Highlight, { LineNumbers } from 'svelte-highlight';
import yaml from 'svelte-highlight/languages/yaml';
import github from 'svelte-highlight/styles/github';
import githubDark from 'svelte-highlight/styles/github-dark';
import { Copy, CopyCheck } from 'lucide-svelte';
import TraefikProxy from '$lib/images/traefikproxy.svg';
import { json, yaml } from 'svelte-highlight/languages';
import YAML from 'yaml';
let code = $state('');
let displayCode = $state('');
let isYaml = $state(false);
let copyText = $state('Copy');
// const copy = () => {
// navigator.clipboard.writeText($dynamic);
// copyText = 'Copied!';
// setTimeout(() => {
// copyText = 'Copy';
// }, 2000);
// };
</script>
<svelte:head>
{@html github}
</svelte:head>
const copy = () => {
navigator.clipboard.writeText(displayCode);
copyText = 'Copied!';
setTimeout(() => {
copyText = 'Copy';
}, 2000);
};
const toggleFormat = () => {
try {
if (isYaml) {
displayCode = code;
} else {
displayCode = YAML.stringify(JSON.parse(code));
}
isYaml = !isYaml;
} catch (error) {
console.error('Failed to convert:', error);
}
};
profile.subscribe(async (value) => {
if (value.id) {
const config = await api.getDynamicConfig(value.name);
code = JSON.stringify(config, null, 2);
displayCode = code;
}
});
</script>
<Dialog.Root>
<Dialog.Trigger>
@@ -176,38 +197,39 @@
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">
Dynamic Config
<!-- {#if $dynamic} -->
<!-- <button -->
<!-- onclick={copy} -->
<!-- class="flex flex-row items-center gap-2 rounded p-2 text-sm font-medium hover:bg-gray-100" -->
<!-- > -->
<!-- {copyText} -->
<!-- {#if copyText === 'Copied!'} -->
<!-- <CopyCheck size="1rem" /> -->
<!-- {:else} -->
<!-- <Copy size="1rem" /> -->
<!-- {/if} -->
<!-- </button> -->
<!-- {/if} -->
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={toggleFormat}>
{isYaml ? 'Show JSON' : 'Show YAML'}
</Button>
{#if displayCode}
<button
onclick={copy}
class="flex flex-row items-center gap-2 rounded p-2 text-sm font-medium hover:bg-gray-100"
>
{copyText}
{#if copyText === 'Copied!'}
<CopyCheck size="1rem" />
{:else}
<Copy size="1rem" />
{/if}
</button>
{/if}
</div>
</Card.Title>
<Card.Description>
This is the current dynamic configuration your Traefik instance is using.
</Card.Description>
</Card.Header>
<Card.Content class="text-sm">
<!-- {#if $dynamic} -->
<!-- <div class="flex items-center justify-center"> -->
<!-- <Highlight code={$dynamic} language={yaml}> -->
<!-- {#snippet children({ highlighted })} -->
<!-- <LineNumbers {highlighted} hideBorder wrapLines /> -->
<!-- {/snippet} -->
<!-- </Highlight> -->
<!-- </div> -->
<!-- {:else} -->
<!-- <p class="flex items-center justify-center"> -->
<!-- No dynamic configuration, add some routers. -->
<!-- </p> -->
<!-- {/if} -->
{#if displayCode}
<Highlight language={isYaml ? yaml : json} code={displayCode} let:highlighted>
<LineNumbers {highlighted} />
</Highlight>
{:else}
<p class="flex items-center justify-center">
No dynamic configuration, add some routers.
</p>
{/if}
</Card.Content>
</Card.Root>
</Tabs.Content>

View File

@@ -3,7 +3,7 @@
import MiddlewareForm from '../forms/middleware.svelte';
import type { Middleware, UpsertMiddlewareParams } from '$lib/types/middlewares';
import { Button } from '$lib/components/ui/button/index.js';
import { api, profile } from '$lib/api';
import { api, profile, loading } from '$lib/api';
import { toast } from 'svelte-sonner';
interface Props {
@@ -46,7 +46,7 @@
break;
}
await api.upsertMiddleware($profile.id, params.middleware);
await api.upsertMiddleware($profile.id, params);
open = false;
toast.success(`Middleware ${mode === 'create' ? 'created' : 'updated'} successfully`);
} catch (err: unknown) {
@@ -56,11 +56,39 @@
});
}
};
const handleDelete = async () => {
if (!middleware.name) return;
try {
let params: Middleware = {
name: middleware.name,
protocol: middleware.protocol
};
await api.deleteMiddleware($profile.id, params);
toast.success('Middleware deleted successfully');
open = false;
} catch (err: unknown) {
const e = err as Error;
toast.error('Failed to delete middleware', {
description: e.message
});
}
};
</script>
<Dialog.Root bind:open>
<Dialog.Content class="no-scrollbar max-h-[80vh] max-w-2xl overflow-y-auto">
<MiddlewareForm bind:middleware {mode} {disabled} />
<Button type="submit" class="w-full" onclick={() => update()}>Save</Button>
<Dialog.Footer>
{#if middleware.name}
<Button type="button" variant="destructive" onclick={handleDelete} disabled={$loading}
>Delete</Button
>
{/if}
<Button type="submit" onclick={() => update()} disabled={$loading}
>{middleware.name ? 'Update' : 'Save'}</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,32 +0,0 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button/index.js';
import { configError, traefikError } from '$lib/api';
import { TriangleAlert } from 'lucide-svelte';
</script>
{#if $configError}
<Popover.Root>
<Popover.Trigger>
<Button variant="ghost" class="text-yellow-500" size="icon">
<TriangleAlert size="24" />
</Button>
</Popover.Trigger>
<Popover.Content class="text-sm">
{$configError}
</Popover.Content>
</Popover.Root>
{/if}
{#if $traefikError}
<Popover.Root>
<Popover.Trigger>
<Button variant="ghost" class="text-yellow-500" size="icon">
<TriangleAlert size="24" />
</Button>
</Popover.Trigger>
<Popover.Content class="text-sm">
{$traefikError}
</Popover.Content>
</Popover.Root>
{/if}

View File

@@ -1,29 +1,9 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index';
import { api, profiles, profile, source } from '$lib/api';
import { api, profiles } from '$lib/api';
import Profile from './profile.svelte';
import InfoModal from '../modals/info.svelte';
// import Warning from '../modals/warning.svelte';
import { ArrowLeft, LogOut } from 'lucide-svelte';
import { SOURCE_TAB_SK } from '$lib/store';
import { TraefikSource } from '$lib/types';
import { onMount } from 'svelte';
// Update localStorage and fetch config when tab changes
async function handleTabChange(value: TraefikSource) {
localStorage.setItem(SOURCE_TAB_SK, value);
source.set(value);
if (!$profile?.id) return;
await api.getTraefikConfig($profile.id, $source);
}
onMount(async () => {
let savedSource = localStorage.getItem(SOURCE_TAB_SK) as TraefikSource;
if (savedSource) {
source.set(savedSource);
}
});
</script>
<nav class="flex h-16 items-center justify-between border-b bg-primary-foreground pl-4">
@@ -37,16 +17,8 @@
{:else}
<InfoModal />
{/if}
<!-- <Warning /> -->
</div>
<Tabs.Root value={$source} onValueChange={(value) => handleTabChange(value as TraefikSource)}>
<Tabs.List class="grid w-[400px] grid-cols-2">
<Tabs.Trigger value={TraefikSource.LOCAL}>Local</Tabs.Trigger>
<Tabs.Trigger value={TraefikSource.API}>API</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div class="mr-2 flex flex-row items-center gap-2">
<Button variant="ghost" onclick={api.logout} size="icon">
<LogOut size="1rem" />

View File

@@ -1,4 +1,4 @@
<script lang="ts" generics="TData, TValue, TModalProps">
<script lang="ts" generics="TData, TValue">
import {
type ColumnDef,
type PaginationState,
@@ -20,9 +20,13 @@
import * as Table from '$lib/components/ui/table/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { TraefikSource } from '$lib/types';
import { SOURCE_TAB_SK } from '$lib/store';
import { api, profile, source } from '$lib/api';
import {
ArrowDown,
ArrowUp,
@@ -42,10 +46,11 @@
label: string;
onClick: () => void;
};
showSourceTabs?: boolean;
onRowSelection?: (selectedRows: TData[]) => void;
};
let { data, columns, createButton }: DataTableProps<TData, TValue> = $props();
let { data, columns, createButton, showSourceTabs }: DataTableProps<TData, TValue> = $props();
// Pagination
const pageSizeOptions = [10, 20, 30, 40, 50];
@@ -161,6 +166,14 @@
}
}
});
// Update localStorage and fetch config when tab changes
async function handleTabChange(value: TraefikSource) {
localStorage.setItem(SOURCE_TAB_SK, value);
source.set(value);
if (!$profile?.id) return;
await api.getTraefikConfig($profile.id, $source);
}
</script>
<div>
@@ -180,6 +193,16 @@
/>
</div>
<!-- Tabs -->
{#if showSourceTabs}
<Tabs.Root value={$source} onValueChange={(value) => handleTabChange(value as TraefikSource)}>
<Tabs.List class="grid w-[400px] grid-cols-2">
<Tabs.Trigger value={TraefikSource.LOCAL}>Local</Tabs.Trigger>
<Tabs.Trigger value={TraefikSource.API}>API</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{/if}
<!-- Column Visibility -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Button, type ButtonProps } from '$lib/components/ui/button/index.js';
import type { SvelteComponent } from 'svelte';
import type { IconProps } from 'lucide-svelte';
@@ -9,37 +8,36 @@
label: string;
icon?: typeof SvelteComponent<IconProps>;
onClick: () => void;
variant?: 'default' | 'destructive';
variant?: ButtonProps['variant'];
classProps?: ButtonProps['class'];
};
let { actions }: { actions: Action[] } = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon">
<span class="sr-only">Open menu</span>
<Ellipsis class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Group>
{#each actions as action}
<DropdownMenu.Item
onclick={action.onClick}
class={action.variant === 'destructive' ? 'text-destructive' : ''}
>
<div class="flex flex-row items-center justify-between gap-4">
<div class="flex flex-row items-center gap-2">
{#each actions as action}
<Tooltip.Provider>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger>
<Button
variant={action.variant ?? 'ghost'}
onclick={action.onClick}
class={action.classProps}
size="icon"
>
{#if action.icon}
{@const Icon = action.icon}
<Icon size={16} />
{:else}
{action.label}
{/if}
<span>{action.label}</span>
</div>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="center" class="max-w-sm">
{action.label}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/each}
</div>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import type { SvelteComponent } from 'svelte';
import type { IconProps } from 'lucide-svelte';
type Action = {
label: string;
icon?: typeof SvelteComponent<IconProps>;
onClick: () => void;
variant?: 'default' | 'destructive';
};
let { actions }: { actions: Action[] } = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon">
<span class="sr-only">Open menu</span>
<Ellipsis class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Group>
{#each actions as action}
<DropdownMenu.Item
onclick={action.onClick}
class={action.variant === 'destructive' ? 'text-destructive' : ''}
>
<div class="flex flex-row items-center justify-between gap-4">
{#if action.icon}
{@const Icon = action.icon}
<Icon size={16} />
{/if}
<span>{action.label}</span>
</div>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import HoverInfo from '$lib/components/utils/hoverInfo.svelte';
import HoverInfo from '$lib/components/utils/HoverInfo.svelte';
import { cn } from '$lib/utils';
import autoAnimate from '@formkit/auto-animate';
import { Minus, Plus } from 'lucide-svelte';

View File

@@ -9,7 +9,7 @@
let { text }: Props = $props();
</script>
<Tooltip.Root openDelay={500}>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger>
<CircleHelp size="1rem" />
</Tooltip.Trigger>

View File

@@ -77,10 +77,19 @@ export interface Agent {
updatedAt: string;
}
export interface Settings {
key: Record<string, Setting>;
}
export interface Setting {
value: string | number | boolean;
description: string;
}
export interface UpsertSettingsParams {
key: string;
value: string;
updatedAt: string;
description: string;
}
export interface Plugin {

View File

@@ -11,6 +11,7 @@
import { onMount } from 'svelte';
import { api, profiles, profile, user } from '$lib/api';
import { PROFILE_SK } from '$lib/store';
interface Props {
children?: import('svelte').Snippet;
}

View File

@@ -10,7 +10,7 @@ export const trailingSlash = 'always';
const PUBLIC_ROUTES = ['/login/', '/reset/'];
export const load: LayoutLoad = async ({ url }) => {
export const load: LayoutLoad = async ({ url, fetch }) => {
const isPublicRoute = PUBLIC_ROUTES.includes(url.pathname);
const token = localStorage.getItem(TOKEN_SK);
@@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ url }) => {
// Case 2: Has token, verify it
if (token) {
try {
const verified = await api.verify();
const verified = await api.verify(fetch);
// Token is valid
if (verified) {

View File

@@ -1,107 +1,158 @@
<script lang="ts">
import { agents, deleteAgent, getAgents, profile, upsertAgent } from '$lib/api';
import DataTable from '$lib/components/tables/DataTable.svelte';
import TableActions from '$lib/components/tables/TableActions.svelte';
import type { ColumnDef } from '@tanstack/table-core';
import { Bot, KeyRound, Pencil, Trash } from 'lucide-svelte';
import { type Agent } from '$lib/types';
import AgentModal from '$lib/components/modals/agent.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card/index.js';
import * as Tooltip from '$lib/components/ui/tooltip';
import { newAgent, type Agent } from '$lib/types/base';
import { Bot, BotOff, Plus } from 'lucide-svelte';
import { api, agents, profile } from '$lib/api';
import { renderComponent } from '$lib/components/ui/data-table';
import { toast } from 'svelte-sonner';
import { DateFormat } from '$lib/store';
function checkLastSeen(agent: Agent) {
const lastSeenDate = new Date(agent.lastSeen);
const now = new Date();
const diffInMinutes = (Number(now) - Number(lastSeenDate)) / (1000 * 60); // Explicitly convert to number
return diffInMinutes < 1;
interface ModalState {
isOpen: boolean;
agent?: Agent;
}
function addAgent() {
let agent = newAgent();
if (!$profile.id) return;
agent.profileId = $profile.id;
agent.lastSeen = new Date('2000-01-01').toISOString();
upsertAgent(agent);
const initialModalState: ModalState = { isOpen: false };
let modalState = $state(initialModalState);
toast.success('Agent added!', {
description: 'Copy the agent token to setup your new agent.',
duration: 3000
});
}
let copyText = $state('Copy Token');
const copyToken = (agent: Agent) => {
navigator.clipboard.writeText(agent.token);
copyText = 'Copied!';
setTimeout(() => {
copyText = 'Copy Token';
}, 2000);
const deleteAgent = async (agent: Agent) => {
try {
await api.deleteAgent(agent.id);
toast.success('Agent deleted');
} catch (err: unknown) {
const e = err as Error;
toast.error(e.message);
}
};
profile.subscribe(async (value) => {
if (!value?.id) return;
await getAgents(value.id);
const copyToken = async (agent: Agent) => {
try {
await navigator.clipboard.writeText(agent.token);
toast.success('Token copied to clipboard');
} catch (err: unknown) {
const e = err as Error;
toast.error(e.message);
}
};
const columns: ColumnDef<Agent>[] = [
{
header: 'Hostname',
accessorKey: 'hostname',
enableSorting: true,
cell: ({ row }) => {
const name = row.getValue('hostname') as string;
if (!name) {
return 'Connect your agent!';
} else {
return name;
}
}
},
{
header: 'Endpoint',
accessorKey: 'activeIp',
enableSorting: true
},
// {
// header: 'Containers',
// accessorKey: 'containers',
// enableSorting: true,
// cell: ({ row }) => {
// const admin = row.getValue('isAdmin') as boolean;
// if (admin) {
// return renderComponent(ColumnBadge, {
// label: 'Yes',
// variant: 'default'
// });
// } else {
// return renderComponent(ColumnBadge, {
// label: 'No',
// variant: 'secondary'
// });
// }
// }
// },
{
header: 'Last Seen',
accessorKey: 'updatedAt',
enableSorting: true,
cell: ({ row }) => {
const date = row.getValue('updatedAt') as string;
return DateFormat.format(new Date(date));
}
},
{
header: 'Created At',
accessorKey: 'createdAt',
enableSorting: true,
cell: ({ row }) => {
const date = row.getValue('createdAt') as string;
return DateFormat.format(new Date(date));
}
},
{
id: 'actions',
cell: ({ row }) => {
return renderComponent(TableActions, {
actions: [
{
label: 'Edit Agent',
icon: Pencil,
onClick: () => {
modalState = {
isOpen: true,
agent: row.original
};
}
},
{
label: 'Copy Token',
icon: KeyRound,
classProps: 'text-green-500',
onClick: () => copyToken(row.original)
},
{
label: 'Delete Agent',
icon: Trash,
classProps: 'text-destructive',
onClick: () => {
deleteAgent(row.original);
}
}
]
});
}
}
];
profile.subscribe((value) => {
if (value.id) {
api.listAgents();
}
});
</script>
<div class="mt-4 flex flex-col gap-4 px-4 md:flex-row">
<Button class="flex items-center gap-2 bg-red-400 text-black" on:click={addAgent}>
<span>Add Agent</span>
<Plus size="1rem" />
</Button>
<svelte:head>
<title>Agents</title>
</svelte:head>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-start gap-2">
<Bot />
<h1 class="text-2xl font-bold">Agent Management</h1>
</div>
<DataTable
{columns}
data={$agents || []}
createButton={{
label: 'Add Agent',
onClick: () => api.createAgent($profile.id)
}}
/>
</div>
<div class="flex flex-col gap-4 px-4 md:flex-row">
{#if $agents && $agents.length > 0}
{#each $agents as a}
<Card.Root class="w-full md:w-[350px]">
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">
<span>{a.hostname}</span>
{#if checkLastSeen(a)}
<Tooltip.Root>
<Tooltip.Trigger>
<Bot size="1.5rem" class="z-10 animate-pulse text-green-500" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agent connected</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<BotOff size="1.5rem" class="text-red-500" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agent disconnected</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Card.Title>
</Card.Header>
{#if a.publicIp}
<Card.Content class="space-y-2">
IP: {a.publicIp}
</Card.Content>
{/if}
<Card.Footer class="flex flex-col items-center gap-4">
<div class="flex w-full flex-row gap-2">
<AgentModal agent={a} />
<Button
variant="ghost"
class="w-full bg-red-400 text-black"
on:click={() => deleteAgent(a.id)}>Delete</Button
>
</div>
<Button variant="secondary" size="sm" class="w-full" on:click={() => copyToken(a)}
>{copyText}</Button
>
</Card.Footer>
</Card.Root>
{/each}
{:else}
<div class="flex h-[calc(75vh)] w-full flex-col items-center justify-center gap-2">
<BotOff size="8rem" />
<span class="text-xl font-semibold">No agents connected</span>
</div>
{/if}
</div>
<AgentModal bind:open={modalState.isOpen} agent={modalState.agent} />

View File

@@ -4,9 +4,9 @@
import TableActions from '$lib/components/tables/TableActions.svelte';
import DNSModal from '$lib/components/modals/dns.svelte';
import type { ColumnDef } from '@tanstack/table-core';
import { Edit, Globe, Trash } from 'lucide-svelte';
import { TraefikSource, type DNSProvider } from '$lib/types';
import { api, dnsProviders, source } from '$lib/api';
import { Globe, Pencil, Trash } from 'lucide-svelte';
import { type DNSProvider } from '$lib/types';
import { api, dnsProviders } from '$lib/api';
import { renderComponent } from '$lib/components/ui/data-table';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
@@ -61,7 +61,7 @@
}
},
{
header: 'Created At',
header: 'Created',
accessorKey: 'createdAt',
enableSorting: true,
cell: ({ row }) => {
@@ -72,45 +72,28 @@
{
id: 'actions',
cell: ({ row }) => {
if ($source === TraefikSource.LOCAL) {
return renderComponent(TableActions, {
actions: [
{
label: 'Edit DNSProvider',
icon: Edit,
onClick: () => {
modalState = {
isOpen: true,
dnsProvider: row.original
};
}
},
{
label: 'Delete DNSProvider',
icon: Trash,
variant: 'destructive',
onClick: () => {
deleteUser(row.original);
}
return renderComponent(TableActions, {
actions: [
{
label: 'Edit DNSProvider',
icon: Pencil,
onClick: () => {
modalState = {
isOpen: true,
dnsProvider: row.original
};
}
]
});
} else {
return renderComponent(TableActions, {
actions: [
{
label: 'Edit DNSProvider',
icon: Edit,
onClick: () => {
modalState = {
isOpen: true,
dnsProvider: row.original
};
}
},
{
label: 'Delete DNSProvider',
icon: Trash,
classProps: 'text-destructive',
onClick: () => {
deleteUser(row.original);
}
]
});
}
}
]
});
}
}
];

View File

@@ -34,31 +34,33 @@
<Card.Description>Login to your account</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid w-full items-center gap-4" onkeydown={handleKeydown} aria-hidden>
<div class="flex flex-col gap-2">
<Label for="username">Username</Label>
<Input id="username" bind:value={username} />
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="grid w-full items-center gap-4" onkeydown={handleKeydown} aria-hidden>
<div class="flex flex-col gap-2">
<Label for="username">Username</Label>
<Input id="username" bind:value={username} />
</div>
<div class="flex flex-col gap-2">
<Label for="password">Password</Label>
<PasswordInput bind:password />
<div class="mt-1 flex flex-row items-center justify-between">
<div class="items-top flex items-center justify-end gap-2">
<Checkbox id="remember" bind:checked={remember} />
<div class="grid gap-2 leading-none">
<Label for="terms1" class="text-sm">Remember me</Label>
<div class="flex flex-col gap-2">
<Label for="password">Password</Label>
<PasswordInput bind:password />
<div class="mt-1 flex flex-row items-center justify-between">
<div class="items-top flex items-center justify-end gap-2">
<Checkbox id="remember" bind:checked={remember} />
<div class="grid gap-2 leading-none">
<Label for="terms1" class="text-sm">Remember me</Label>
</div>
</div>
<button class="text-xs text-muted-foreground" onclick={handleReset}>
Forgot password?
</button>
</div>
<button class="text-xs text-muted-foreground" onclick={handleReset}>
Forgot password?
</button>
</div>
<div class="mt-4 flex flex-col">
<Button type="submit">Login</Button>
</div>
</div>
<div class="mt-4 flex flex-col">
<Button type="submit" onclick={handleSubmit}>Login</Button>
</div>
</div>
</form>
</Card.Content>
</Card.Root>

View File

@@ -6,11 +6,13 @@
import TableActions from '$lib/components/tables/TableActions.svelte';
import type { ColumnDef } from '@tanstack/table-core';
import type { Middleware } from '$lib/types/middlewares';
import { Edit, Layers, Trash } from 'lucide-svelte';
import { Layers, Pencil, Trash } from 'lucide-svelte';
import { TraefikSource } from '$lib/types';
import { api, profile, middlewares, source } from '$lib/api';
import { renderComponent } from '$lib/components/ui/data-table';
import { toast } from 'svelte-sonner';
import { SOURCE_TAB_SK } from '$lib/store';
import { onMount } from 'svelte';
interface ModalState {
isOpen: boolean;
@@ -109,7 +111,7 @@
actions: [
{
label: 'Edit Middleware',
icon: Edit,
icon: Pencil,
onClick: () => {
openEditModal(row.original);
}
@@ -117,7 +119,7 @@
{
label: 'Delete Middleware',
icon: Trash,
variant: 'destructive',
classProps: 'text-destructive',
onClick: () => {
deleteMiddleware(row.original);
}
@@ -129,7 +131,7 @@
actions: [
{
label: 'Edit Middleware',
icon: Edit,
icon: Pencil,
onClick: () => {
openEditModal(row.original);
}
@@ -146,8 +148,12 @@
api.getTraefikConfig(value.id, $source);
}
});
$effect(() => {
console.log($middlewares);
onMount(async () => {
let savedSource = localStorage.getItem(SOURCE_TAB_SK) as TraefikSource;
if (savedSource) {
source.set(savedSource);
}
});
</script>
@@ -165,6 +171,7 @@
<DataTable
{columns}
data={$middlewares || []}
showSourceTabs={true}
createButton={{
label: 'Add Middleware',
onClick: openCreateModal
@@ -182,6 +189,7 @@
<DataTable
{columns}
data={$middlewares || []}
showSourceTabs={true}
createButton={{
label: 'Add Middleware',
onClick: openCreateModal

View File

@@ -6,11 +6,12 @@
import TableActions from '$lib/components/tables/TableActions.svelte';
import type { ColumnDef } from '@tanstack/table-core';
import type { Router, Service, TLS } from '$lib/types/router';
import { Edit, Route, Trash } from 'lucide-svelte';
import { Pencil, Route, Trash } from 'lucide-svelte';
import { TraefikSource } from '$lib/types';
import { api, profile, routers, services, source } from '$lib/api';
import { renderComponent } from '$lib/components/ui/data-table';
import { toast } from 'svelte-sonner';
import { SOURCE_TAB_SK } from '$lib/store';
interface ModalState {
isOpen: boolean;
@@ -19,11 +20,7 @@
service?: Service;
}
const initialModalState: ModalState = {
isOpen: false,
mode: 'create'
};
const initialModalState: ModalState = { isOpen: false, mode: 'create' };
let modalState = $state(initialModalState);
function openCreateModal() {
@@ -128,8 +125,12 @@
enableSorting: true,
cell: ({ row }) => {
const resolver = row.getValue('resolver') as TLS;
if (!resolver) {
return renderComponent(ColumnBadge, { label: 'None', variant: 'secondary' });
if (!resolver.certResolver) {
return renderComponent(ColumnBadge, {
label: 'None',
variant: 'secondary',
class: 'bg-slate-300 dark:bg-slate-700'
});
}
return renderComponent(ColumnBadge, {
label: resolver.certResolver as string,
@@ -165,7 +166,7 @@
actions: [
{
label: 'Edit Router',
icon: Edit,
icon: Pencil,
onClick: () => {
openEditModal(row.original.router, row.original.service);
}
@@ -173,7 +174,7 @@
{
label: 'Delete Router',
icon: Trash,
variant: 'destructive',
classProps: 'text-destructive',
onClick: () => {
deleteRouter(row.original.router);
}
@@ -185,7 +186,7 @@
actions: [
{
label: 'Edit Router',
icon: Edit,
icon: Pencil,
onClick: () => {
openEditModal(row.original.router, row.original.service);
}
@@ -199,7 +200,11 @@
profile.subscribe((value) => {
if (value.id) {
api.getTraefikConfig(value.id, $source);
let savedSource = localStorage.getItem(SOURCE_TAB_SK) as TraefikSource;
if (savedSource) {
source.set(savedSource);
api.getTraefikConfig(value.id, savedSource);
}
}
});
@@ -232,6 +237,7 @@
<DataTable
{columns}
data={mergedData || []}
showSourceTabs={true}
createButton={{
label: 'Add Router',
onClick: openCreateModal
@@ -248,6 +254,7 @@
<DataTable
{columns}
data={mergedData || []}
showSourceTabs={true}
createButton={{
label: 'Add Router',
onClick: openCreateModal

View File

@@ -1,26 +1,18 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { Download, Eye, EyeOff, Save, Upload } from 'lucide-svelte';
import { SaveIcon, Settings } from 'lucide-svelte';
import { settings, api } from '$lib/api';
import type { Setting } from '$lib/types';
import { onMount } from 'svelte';
import type { Setting } from '$lib/types';
import { toast } from 'svelte-sonner';
// State management
// let fileInput = $state<HTMLInputElement>();
let showEmailPassword = $state(false);
let settingsMap = $state<Record<string, string>>({});
let changedSettings = $state<Record<string, string>>({});
// Computed values
// let agentCleanupEnabled = $derived(settingsMap['agent-cleanup-enabled'] === 'true');
// let backupEnabled = $derived(settingsMap['backup-enabled'] === 'true');
let hasChanges = $derived(Object.keys(changedSettings).length > 0);
// async function handleFileUpload(event: Event) {
// const file = (event.target as HTMLInputElement).files?.[0];
@@ -30,37 +22,78 @@
// }
// }
async function updateSetting(key: string) {
await api.upsertSetting({ key, value: settingsMap[key] } as Setting);
// const { [key]: _, ...rest } = changedSettings;
// changedSettings = rest;
let hasChanges = $state(false);
let changedValues = $state<Record<string, Setting['value']>>({});
function parseDuration(str: string): string {
// Just clean up and validate the duration string
const cleanStr = str.trim();
try {
// Validate the duration string format
const patterns = /^(\d+h)?(\d+m)?(\d+s)?$/;
if (!patterns.test(cleanStr)) {
throw new Error('Invalid duration format');
}
return cleanStr;
} catch (err) {
const error = err as Error;
toast.error('Invalid duration format. Use format like "24h0m0s"', {
description: error.message
});
return str;
}
}
function markAsChanged(key: string) {
const originalValue = $settings.find((s: Setting) => s.key === key)?.value;
if (settingsMap[key] !== originalValue) {
changedSettings = { ...changedSettings, [key]: settingsMap[key] };
// } else {
// const { [key]: _, ...rest } = changedSettings;
// changedSettings = rest;
// Helper to convert camelCase/snake_case to Title Case
const formatSettingName = (key: string) => {
return key
.split(/[_\s]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// Determine the input type based on the setting value
const getInputType = (value: Setting) => {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (value?.toString().includes('://')) return 'url';
if (value?.toString().includes('@')) return 'email';
return 'text';
};
function handleChange(key: string, value: Setting['value']) {
changedValues[key] = value;
hasChanges = true;
}
async function saveSetting(key: string, value: Setting['value']) {
try {
await api.upsertSetting({
key,
value: value.toString(),
description: $settings[key].description
});
delete changedValues[key];
hasChanges = Object.keys(changedValues).length > 0;
toast.success('Setting updated successfully');
} catch (error) {
toast.error('Failed to save setting', { description: (error as Error).message });
}
}
async function saveAllChanges() {
await Promise.all(Object.keys(changedSettings).map((key) => updateSetting(key)));
changedSettings = {};
for (const [key, value] of Object.entries(changedValues)) {
await saveSetting(key, value);
}
hasChanges = false;
}
function handleKeydown(e: KeyboardEvent, key: string) {
if (e.key === 'Enter') updateSetting(key);
function handleKeydown(e: KeyboardEvent, key: string, value: Setting['value']) {
if (e.key === 'Enter') saveSetting(key, value);
}
onMount(async () => {
await api.listSettings();
// settingsMap = $settings.reduce(
// (acc, setting) => ({ ...acc, [setting.key]: setting.value }),
// {}
// );
});
</script>
@@ -68,113 +101,71 @@
<title>Settings</title>
</svelte:head>
<div class="container mx-auto max-w-4xl p-6">
<div class="container">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title class="text-2xl font-bold">Settings</Card.Title>
{#if hasChanges}
<Tooltip.Root>
<Tooltip.Trigger>
<Button variant="outline" size="icon" onclick={saveAllChanges}>
<Save class="h-4 w-4 animate-pulse text-green-500" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Save all changes</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
<Card.Title class="mb-3">
<div class="flex items-center gap-2">
<Settings class="size-8" />
<h1 class="text-3xl font-bold">Settings</h1>
</div>
</Card.Title>
<Separator />
</Card.Header>
<Card.Content class="flex flex-col gap-6">
{#each Object.entries($settings) as [key, setting]}
<div class="flex flex-col justify-start gap-4 sm:flex-row sm:justify-between">
<Label>
{formatSettingName(key)}
{#if setting.description}
<p class="text-sm text-muted-foreground">{setting.description}</p>
{/if}
</Label>
<Card.Content class="space-y-6">
{#each $settings as setting}
{setting.key}
<div class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-2">
<Label for={setting.key} class="font-medium">
{setting.key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')}
</Label>
<!-- {#if setting.description} -->
<!-- <HoverInfo text={setting.description} /> -->
<!-- {/if} -->
</div>
{#if setting.key.includes('enabled')}
<Switch
id={setting.key}
checked={settingsMap[setting.key] === 'true'}
onCheckedChange={(value) => {
settingsMap[setting.key] = value.toString();
updateSetting(setting.key);
}}
class="col-span-3 justify-self-end"
/>
{:else if setting.key === 'email-password'}
<div class="relative col-span-3">
<Input
id={setting.key}
type={showEmailPassword ? 'text' : 'password'}
value={settingsMap[setting.key]}
oninput={() => markAsChanged(setting.key)}
onkeydown={(e) => handleKeydown(e, setting.key)}
class="pr-10"
<div class="flex w-full items-center justify-end gap-4 sm:w-auto md:w-[380px]">
{#if getInputType(setting.value) === 'boolean'}
<Switch
id={key}
checked={setting.value}
onCheckedChange={(checked) => saveSetting(key, checked)}
/>
<Button
variant="ghost"
size="icon"
class="absolute right-2 top-1/2 -translate-y-1/2"
onclick={() => (showEmailPassword = !showEmailPassword)}
>
{#if showEmailPassword}
<Eye class="h-4 w-4" />
{:else}
<EyeOff class="h-4 w-4" />
{/if}
</Button>
</div>
{:else}
<Input
id={setting.key}
type="text"
value={settingsMap[setting.key]}
oninput={() => markAsChanged(setting.key)}
onkeydown={(e) => handleKeydown(e, setting.key)}
class="col-span-3"
/>
{/if}
{:else if key.includes('interval')}
<Input
type="text"
id={key}
value={setting.value}
onchange={(e) => handleChange(key, parseDuration(e.currentTarget.value))}
onkeydown={(e) => handleKeydown(e, key, parseDuration(e.currentTarget.value))}
/>
{:else if key.includes('port')}
<Input
type="number"
id={key}
value={setting.value}
min="1"
max="65535"
onchange={(e) => handleChange(key, parseInt(e.currentTarget.value))}
onkeydown={(e) => handleKeydown(e, key, parseInt(e.currentTarget.value))}
/>
{:else}
<Input
type="text"
value={setting.value}
onchange={(e) => handleChange(key, e.currentTarget.value)}
onkeydown={(e) => handleKeydown(e, key, e.currentTarget.value)}
/>
{/if}
</div>
</div>
<Separator />
{/each}
<!-- <div class="flex gap-4 pt-4"> -->
<!-- <input -->
<!-- type="file" -->
<!-- accept=".json" -->
<!-- class="hidden" -->
<!-- onchange={handleFileUpload} -->
<!-- bind:this={fileInput} -->
<!-- /> -->
<!-- <Button -->
<!-- variant="outline" -->
<!-- class="flex-1" -->
<!-- onclick={() => fileInput.click()} -->
<!-- > -->
<!-- <Upload class="mr-2 h-4 w-4" /> -->
<!-- Upload Backup -->
<!-- </Button> -->
<!-- <Button -->
<!-- variant="default" -->
<!-- class="flex-1" -->
<!-- onclick={() => downloadBackup()} -->
<!-- > -->
<!-- <Download class="mr-2 h-4 w-4" /> -->
<!-- Download Backup -->
<!-- </Button> -->
<!-- </div> -->
<div class="flex justify-end">
<Button variant={hasChanges ? 'default' : 'outline'} onclick={saveAllChanges} size="icon">
<SaveIcon />
</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -3,10 +3,10 @@
import DataTable from '$lib/components/tables/DataTable.svelte';
import TableActions from '$lib/components/tables/TableActions.svelte';
import type { ColumnDef } from '@tanstack/table-core';
import { Edit, Trash, Users } from 'lucide-svelte';
import { TraefikSource, type User } from '$lib/types';
import { Pencil, Trash, Users } from 'lucide-svelte';
import { type User } from '$lib/types';
import UserModal from '$lib/components/modals/user.svelte';
import { api, users, source } from '$lib/api';
import { api, users } from '$lib/api';
import { renderComponent } from '$lib/components/ui/data-table';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
@@ -70,7 +70,7 @@
}
},
{
header: 'Created At',
header: 'Created',
accessorKey: 'createdAt',
enableSorting: true,
cell: ({ row }) => {
@@ -81,45 +81,28 @@
{
id: 'actions',
cell: ({ row }) => {
if ($source === TraefikSource.LOCAL) {
return renderComponent(TableActions, {
actions: [
{
label: 'Edit User',
icon: Edit,
onClick: () => {
modalState = {
isOpen: true,
user: row.original
};
}
},
{
label: 'Delete User',
icon: Trash,
variant: 'destructive',
onClick: () => {
deleteUser(row.original);
}
return renderComponent(TableActions, {
actions: [
{
label: 'Edit User',
icon: Pencil,
onClick: () => {
modalState = {
isOpen: true,
user: row.original
};
}
]
});
} else {
return renderComponent(TableActions, {
actions: [
{
label: 'Edit User',
icon: Edit,
onClick: () => {
modalState = {
isOpen: true,
user: row.original
};
}
},
{
label: 'Delete User',
icon: Trash,
classProps: 'text-destructive',
onClick: () => {
deleteUser(row.original);
}
]
});
}
}
]
});
}
}
];