mirror of
https://github.com/MizuchiLabs/mantrae.git
synced 2026-01-06 06:19:57 -06:00
fix a bunch of things
This commit is contained in:
@@ -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,
|
||||
54
internal/api/agent/token.go
Normal file
54
internal/api/agent/token.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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...))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ?;
|
||||
|
||||
@@ -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 = ?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
82
web/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
web/src/lib/components/tables/TableDropdown.svelte
Normal file
45
web/src/lib/components/tables/TableDropdown.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Root openDelay={500}>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger>
|
||||
<CircleHelp size="1rem" />
|
||||
</Tooltip.Trigger>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user