mirror of
https://github.com/MizuchiLabs/mantrae.git
synced 2026-05-08 00:49:49 -05:00
fix backups
This commit is contained in:
@@ -20,7 +20,11 @@ func DownloadBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
bm.CreateBackup(r.Context())
|
||||
filename, err = bm.GetLatestBackup()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create backup: %v", err), http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to create backup: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -38,6 +42,21 @@ func DownloadBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func CreateBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := bm.CreateBackup(r.Context())
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to create backup: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreBackup restores a backup from a provided file.
|
||||
func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -66,7 +85,11 @@ func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
// Create destination file
|
||||
dst, err := os.Create(backupPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create backup file: %v", err), http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to create backup file: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
@@ -81,7 +104,11 @@ func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
|
||||
if err := bm.RestoreBackup(r.Context(), backupPath); err != nil {
|
||||
// Clean up the uploaded file if restore fails.
|
||||
os.Remove(backupPath)
|
||||
http.Error(w, fmt.Sprintf("Failed to restore backup: %v", err), http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to restore backup: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,13 +125,21 @@ func ListBackups(bm *config.BackupManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
backups, err := bm.ListBackups()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to list backups: %v", err), http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to list backups: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(backups); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to encode response: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ func (s *Server) routes() {
|
||||
mw.BasicAuth,
|
||||
)
|
||||
|
||||
// adminChain := middlewares.Chain(
|
||||
// mw.Logger,
|
||||
// mw.JWT,
|
||||
// mw.AdminOnly,
|
||||
// )
|
||||
|
||||
// Helper for middleware registration
|
||||
register := func(method, path string, chain middlewares.Middleware, handler http.HandlerFunc) {
|
||||
s.mux.Handle(method+" /api"+path, chain(handler))
|
||||
@@ -93,9 +99,10 @@ func (s *Server) routes() {
|
||||
register("POST", "/agent/token/{id}", jwtChain, handler.RotateAgentToken(s.app))
|
||||
|
||||
// Backup
|
||||
register("GET", "/backups", jwtChain, handler.ListBackups(s.app.BM))
|
||||
register("GET", "/backup/list", jwtChain, handler.ListBackups(s.app.BM))
|
||||
register("GET", "/backup", jwtChain, handler.DownloadBackup(s.app.BM))
|
||||
register("POST", "/backup", jwtChain, handler.RestoreBackup(s.app.BM))
|
||||
register("POST", "/backup/restore", jwtChain, handler.RestoreBackup(s.app.BM))
|
||||
register("POST", "/backup", jwtChain, handler.CreateBackup(s.app.BM))
|
||||
|
||||
// IP
|
||||
// register("GET", "/ip/{id}", jwtChain, GetPublicIP)
|
||||
|
||||
+230
-96
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,10 +19,11 @@ import (
|
||||
|
||||
type BackupManager struct {
|
||||
Config BackupConfig
|
||||
db *db.Queries
|
||||
DBType string
|
||||
DBPath string
|
||||
cronJob *cron.Cron
|
||||
db *sql.DB
|
||||
q *db.Queries
|
||||
}
|
||||
|
||||
type BackupMetadata struct {
|
||||
@@ -32,7 +34,7 @@ type BackupMetadata struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func NewBackupManager(config Config, db *db.Queries) (*BackupManager, error) {
|
||||
func NewBackupManager(ctx context.Context, config Config) (*BackupManager, error) {
|
||||
if err := os.MkdirAll(config.Backup.Dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
@@ -50,38 +52,79 @@ func NewBackupManager(config Config, db *db.Queries) (*BackupManager, error) {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
return &BackupManager{
|
||||
bm := &BackupManager{
|
||||
Config: config.Backup,
|
||||
db: db,
|
||||
DBType: config.Database.Type,
|
||||
DBPath: config.DBPath(),
|
||||
cronJob: cron.New(),
|
||||
}, nil
|
||||
}
|
||||
if err := bm.Start(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) Start(ctx context.Context) error {
|
||||
if !bm.Config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Schedule backup job
|
||||
_, err := bm.cronJob.AddFunc(bm.Config.Schedule, func() {
|
||||
if err := bm.CreateBackup(ctx); err != nil {
|
||||
slog.Error("Scheduled backup failed", "error", err)
|
||||
// Ensure previous connections are closed
|
||||
if bm.db != nil {
|
||||
if err := bm.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to stop existing connections: %w", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule backup: %w", err)
|
||||
}
|
||||
|
||||
bm.cronJob.Start()
|
||||
// Small delay before opening new connections
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Initialize new database connection with retries
|
||||
maxRetries := 3
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
newDB, err := db.InitDB()
|
||||
if err == nil {
|
||||
bm.q = db.New(newDB)
|
||||
bm.db = newDB
|
||||
break
|
||||
}
|
||||
if i == maxRetries-1 {
|
||||
return fmt.Errorf(
|
||||
"failed to initialize database after %d attempts: %w",
|
||||
maxRetries,
|
||||
err,
|
||||
)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
if bm.Config.Enabled {
|
||||
// Schedule backup job
|
||||
_, err := bm.cronJob.AddFunc(bm.Config.Schedule, func() {
|
||||
if err := bm.CreateBackup(ctx); err != nil {
|
||||
slog.Error("Scheduled backup failed", "error", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule backup: %w", err)
|
||||
}
|
||||
bm.cronJob.Start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) Stop() {
|
||||
func (bm *BackupManager) Stop() error {
|
||||
if bm.cronJob != nil {
|
||||
bm.cronJob.Stop()
|
||||
}
|
||||
|
||||
if bm.db != nil {
|
||||
if err := bm.db.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close database connection: %w", err)
|
||||
}
|
||||
}
|
||||
// Small delay to ensure SQLite cleanup
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) CreateBackup(ctx context.Context) error {
|
||||
@@ -89,8 +132,7 @@ func (bm *BackupManager) CreateBackup(ctx context.Context) error {
|
||||
backupName := fmt.Sprintf("backup_%s.db", timestamp.Format("20060102_150405"))
|
||||
backupPath := filepath.Join(bm.Config.Dir, backupName)
|
||||
|
||||
// Create backup file
|
||||
if err := bm.performBackup(ctx, backupPath); err != nil {
|
||||
if err := bm.backupSQLite(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -114,18 +156,7 @@ func (bm *BackupManager) CreateBackup(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) performBackup(ctx context.Context, destPath string) error {
|
||||
switch bm.DBType {
|
||||
case "sqlite":
|
||||
return bm.backupSQLite(ctx, destPath)
|
||||
case "postgres":
|
||||
return bm.backupPostgres(ctx, destPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s", bm.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
func (bm *BackupManager) backupSQLite(ctx context.Context, destPath string) error {
|
||||
func (bm *BackupManager) backupSQLite(destPath string) error {
|
||||
// For SQLite, we can simply copy the database file
|
||||
src, err := os.Open(bm.DBPath)
|
||||
if err != nil {
|
||||
@@ -146,21 +177,6 @@ func (bm *BackupManager) backupSQLite(ctx context.Context, destPath string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) backupPostgres(ctx context.Context, destPath string) error {
|
||||
// Implement pg_dump here
|
||||
// Example implementation:
|
||||
/*
|
||||
cmd := exec.CommandContext(ctx, "pg_dump",
|
||||
"-h", host,
|
||||
"-U", username,
|
||||
"-d", dbname,
|
||||
"-f", destPath,
|
||||
)
|
||||
return cmd.Run()
|
||||
*/
|
||||
return fmt.Errorf("postgres backup not implemented")
|
||||
}
|
||||
|
||||
func (bm *BackupManager) saveMetadata(path string, metadata BackupMetadata) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -222,30 +238,6 @@ func (bm *BackupManager) cleanup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores a database from a backup file
|
||||
func (bm *BackupManager) RestoreBackup(ctx context.Context, backupPath string) error {
|
||||
// Verify backup metadata
|
||||
metadataPath := backupPath + ".json"
|
||||
metadata, err := bm.loadMetadata(metadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load backup metadata: %w", err)
|
||||
}
|
||||
|
||||
if metadata.DBType != bm.DBType {
|
||||
return fmt.Errorf("backup database type (%s) doesn't match current type (%s)",
|
||||
metadata.DBType, bm.DBType)
|
||||
}
|
||||
|
||||
switch bm.DBType {
|
||||
case "sqlite":
|
||||
return bm.restoreSQLite(ctx, backupPath)
|
||||
case "postgres":
|
||||
return bm.restorePostgres(ctx, backupPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s", bm.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
func (bm *BackupManager) loadMetadata(path string) (BackupMetadata, error) {
|
||||
var metadata BackupMetadata
|
||||
file, err := os.Open(path)
|
||||
@@ -261,38 +253,174 @@ func (bm *BackupManager) loadMetadata(path string) (BackupMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) restoreSQLite(ctx context.Context, backupPath string) error {
|
||||
// Create a temporary database path
|
||||
tempDB := bm.DBPath + ".tmp"
|
||||
func (bm *BackupManager) RestoreBackup(ctx context.Context, backupPath string) error {
|
||||
ext := filepath.Ext(backupPath)
|
||||
|
||||
// Copy backup to temporary location
|
||||
if err := copyFile(backupPath, tempDB); err != nil {
|
||||
return fmt.Errorf("failed to create temporary database: %w", err)
|
||||
if ext == ".db" {
|
||||
// For .db files, use the existing file replacement method
|
||||
tempDB := bm.DBPath + ".tmp"
|
||||
defer os.Remove(tempDB)
|
||||
|
||||
// Copy backup to temporary location
|
||||
if err := copyFile(backupPath, tempDB); err != nil {
|
||||
return fmt.Errorf("failed to create temporary database: %w", err)
|
||||
}
|
||||
|
||||
// Test the temporary database
|
||||
if err := bm.testDatabase(tempDB); err != nil {
|
||||
return fmt.Errorf("backup verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Create a backup of the current database
|
||||
currentBackup := bm.DBPath + ".old"
|
||||
if err := copyFile(bm.DBPath, currentBackup); err != nil {
|
||||
return fmt.Errorf("failed to backup current database: %w", err)
|
||||
}
|
||||
|
||||
// Close the current database connection
|
||||
if err := bm.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to close database connections: %w", err)
|
||||
}
|
||||
|
||||
// Replace the current database
|
||||
if err := os.Rename(tempDB, bm.DBPath); err != nil {
|
||||
// If replacement fails, try to restore the old database
|
||||
if err = os.Rename(currentBackup, bm.DBPath); err != nil {
|
||||
return fmt.Errorf("failed to restore old database: %w", err)
|
||||
}
|
||||
slog.Error("Failed to replace database", "error", err)
|
||||
}
|
||||
|
||||
// Clean up the old backup
|
||||
os.Remove(currentBackup)
|
||||
|
||||
// Start the new database
|
||||
if err := bm.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to start database connections: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Test the temporary database
|
||||
if err := bm.testDatabase(tempDB); err != nil {
|
||||
os.Remove(tempDB)
|
||||
return fmt.Errorf("backup verification failed: %w", err)
|
||||
if ext == ".sql" {
|
||||
// For .sql files, execute the SQL statements
|
||||
sqlContent, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SQL backup file: %w", err)
|
||||
}
|
||||
|
||||
// Create a new database connection for the restore
|
||||
db, err := sql.Open("sqlite", bm.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database for restore: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Start a transaction
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Execute SQL statements
|
||||
if _, err := tx.ExecContext(ctx, string(sqlContent)); err != nil {
|
||||
return fmt.Errorf("failed to execute SQL backup: %w", err)
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit restore transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace the current database
|
||||
if err := os.Rename(tempDB, bm.DBPath); err != nil {
|
||||
os.Remove(tempDB)
|
||||
return fmt.Errorf("failed to replace database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BackupManager) restorePostgres(ctx context.Context, backupPath string) error {
|
||||
// Implement pg_restore here
|
||||
return fmt.Errorf("postgres restore not implemented")
|
||||
return fmt.Errorf("unsupported backup file format: %s", ext)
|
||||
}
|
||||
|
||||
func (bm *BackupManager) testDatabase(dbPath string) error {
|
||||
// Implement database verification here
|
||||
// For example, try to open the database and run a simple query
|
||||
// Try to open the database
|
||||
testDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open backup database: %w", err)
|
||||
}
|
||||
defer testDB.Close()
|
||||
|
||||
// Test if database is accessible with a simple query
|
||||
if err = testDB.Ping(); err != nil {
|
||||
return fmt.Errorf("backup database is not accessible: %w", err)
|
||||
}
|
||||
|
||||
// Start a read-only transaction
|
||||
tx, err := testDB.BeginTx(context.Background(), &sql.TxOptions{
|
||||
ReadOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get list of tables
|
||||
rows, err := tx.Query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT LIKE 'goose_%'
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Check each table's schema
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err = rows.Scan(&tableName); err != nil {
|
||||
return fmt.Errorf("failed to scan table name: %w", err)
|
||||
}
|
||||
tables = append(tables, tableName)
|
||||
}
|
||||
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("backup database contains no tables")
|
||||
}
|
||||
|
||||
// Verify schema for each table
|
||||
for _, table := range tables {
|
||||
// Get table info
|
||||
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get schema for table %s: %w", table, err)
|
||||
}
|
||||
|
||||
var hasColumns bool
|
||||
for rows.Next() {
|
||||
hasColumns = true
|
||||
break
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if !hasColumns {
|
||||
return fmt.Errorf("table %s has no columns", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Test foreign key constraints
|
||||
_, err = tx.Exec("PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreign key check failed: %w", err)
|
||||
}
|
||||
|
||||
// Try to read a row from each table to ensure data is accessible
|
||||
for _, table := range tables {
|
||||
query := fmt.Sprintf("SELECT * FROM %s LIMIT 1", table)
|
||||
_, err := tx.Query(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read from table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -363,11 +491,17 @@ func (bm *BackupManager) IsValidBackupFile(filename string) bool {
|
||||
}
|
||||
|
||||
// Check if file matches expected pattern
|
||||
matched, err := filepath.Match("backup_*.db", filename)
|
||||
matched, err := filepath.Match("backup_*.*", filename)
|
||||
if err != nil || !matched {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := filepath.Ext(filename)
|
||||
if ext != ".db" && ext != ".sql" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if file exists in backup directory
|
||||
fullPath := filepath.Join(bm.Config.Dir, filename)
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
|
||||
+10
-12
@@ -22,12 +22,6 @@ type App struct {
|
||||
}
|
||||
|
||||
func Setup() (*App, error) {
|
||||
// Initialize database
|
||||
DB, err := db.InitDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read environment variables
|
||||
config, err := ReadConfig()
|
||||
if err != nil {
|
||||
@@ -40,28 +34,32 @@ func Setup() (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Setup backup manager
|
||||
bm, err := NewBackupManager(*config, db.New(DB))
|
||||
// Setup backup manager and initialize database
|
||||
bm, err := NewBackupManager(context.Background(), *config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create backup manager: %w", err)
|
||||
}
|
||||
|
||||
// Setup settings manager
|
||||
sm := NewSettingsManager(db.New(DB))
|
||||
sm := NewSettingsManager(bm.q)
|
||||
if err := sm.Initialize(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize settings: %w", err)
|
||||
}
|
||||
|
||||
app := App{
|
||||
Config: config,
|
||||
DB: db.New(DB),
|
||||
DB: bm.q,
|
||||
BM: bm,
|
||||
SM: sm,
|
||||
}
|
||||
|
||||
app.setupLogger()
|
||||
app.setDefaultAdminUser()
|
||||
app.setDefaultProfile()
|
||||
if err := app.setDefaultAdminUser(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := app.setDefaultProfile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update self
|
||||
util.UpdateSelf(flags.Update)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Package traefik provides a client for the Traefik API
|
||||
// Here are all the models used to convert between the API and the UI
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"github.com/traefik/traefik/v3/pkg/config/dynamic"
|
||||
)
|
||||
|
||||
type Dynamic struct {
|
||||
ProfileID int64 `json:"profile_id,omitempty"`
|
||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||
}
|
||||
|
||||
type Entrypoint struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
AsDefault bool `json:"asDefault,omitempty"`
|
||||
HTTP struct {
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
TLS *dynamic.RouterTLSConfig `json:"tls,omitempty"`
|
||||
} `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Codename string `json:"codename,omitempty"`
|
||||
StartDate string `json:"startDate,omitempty"`
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
HTTP HTTPOverview `json:"http,omitempty"`
|
||||
TCP TCPOverview `json:"tcp,omitempty"`
|
||||
UDP UDPOverview `json:"udp,omitempty"`
|
||||
Features struct {
|
||||
Tracing string `json:"tracing,omitempty"`
|
||||
Metrics string `json:"metrics,omitempty"`
|
||||
AccessLog bool `json:"accessLog,omitempty"`
|
||||
} `json:"features,omitempty"`
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
type BasicOverview struct {
|
||||
Total int `json:"total,omitempty"`
|
||||
Warnings int `json:"warnings,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPOverview struct {
|
||||
Routers BasicOverview `json:"routers,omitempty"`
|
||||
Services BasicOverview `json:"services,omitempty"`
|
||||
Middleware BasicOverview `json:"middlewares,omitempty"`
|
||||
}
|
||||
|
||||
type TCPOverview struct {
|
||||
Routers BasicOverview `json:"routers,omitempty"`
|
||||
Services BasicOverview `json:"services,omitempty"`
|
||||
Middleware BasicOverview `json:"middlewares,omitempty"`
|
||||
}
|
||||
|
||||
type UDPOverview struct {
|
||||
Routers BasicOverview `json:"routers,omitempty"`
|
||||
Services BasicOverview `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Author string `json:"author"`
|
||||
Type string `json:"type"`
|
||||
Import string `json:"import"`
|
||||
Summary string `json:"summary"`
|
||||
IconUrl string `json:"iconUrl"`
|
||||
BannerUrl string `json:"bannerUrl"`
|
||||
Readme string `json:"readme"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
Versions []string `json:"versions"`
|
||||
Stars int `json:"stars"`
|
||||
Snippet PluginSnippet `json:"snippet"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type PluginSnippet struct {
|
||||
Yaml string `json:"yaml"`
|
||||
}
|
||||
+58
-5
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
TraefikSource,
|
||||
type Agent,
|
||||
type BackupMetadata,
|
||||
type DNSProvider,
|
||||
type Plugin,
|
||||
type Profile,
|
||||
@@ -48,6 +49,7 @@ export const dnsProviders: Writable<DNSProvider[]> = writable([]);
|
||||
export const agents: Writable<Agent[]> = writable([]);
|
||||
export const settings: Writable<Settings> = writable({} as Settings);
|
||||
export const plugins: Writable<Plugin[]> = writable([]);
|
||||
export const backups: Writable<BackupMetadata[]> = writable([]);
|
||||
|
||||
// App state
|
||||
export const profile: Writable<Profile> = writable({} as Profile);
|
||||
@@ -75,10 +77,15 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
|
||||
if (token) {
|
||||
headers.set('Authorization', 'Bearer ' + token); // Add the Authorization header
|
||||
}
|
||||
// Don't set Content-Type for FormData
|
||||
const isFormData = options?.body instanceof FormData;
|
||||
if (!isFormData) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const customOptions = {
|
||||
'Content-Type': 'application/json',
|
||||
...options,
|
||||
headers
|
||||
headers,
|
||||
body: isFormData ? options?.body : options?.body ? JSON.stringify(options.body) : undefined
|
||||
};
|
||||
return fetch ? fetch(url, customOptions) : window.fetch(url, customOptions); // Use custom fetch or default
|
||||
};
|
||||
@@ -87,14 +94,17 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
|
||||
loading.set(true);
|
||||
const response = await customFetch(`${BASE_URL}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
body: options.body ? JSON.stringify(options.body) : undefined
|
||||
body: options.body,
|
||||
headers: options.headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${await response.text()}`);
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (response.status !== 204 && response.status !== 201) {
|
||||
if (response.headers.get('Content-Type') === 'application/json') {
|
||||
return await response.json();
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.set(err instanceof Error ? err.message : String(err));
|
||||
@@ -393,6 +403,49 @@ export const api = {
|
||||
await api.listSettings();
|
||||
},
|
||||
|
||||
// Backups -------------------------------------------------------------------
|
||||
async listBackups() {
|
||||
const data = await send('/backup/list');
|
||||
backups.set(data);
|
||||
},
|
||||
|
||||
async createBackup() {
|
||||
await send('/backup', { method: 'POST' });
|
||||
await api.listBackups();
|
||||
},
|
||||
|
||||
async downloadBackup() {
|
||||
try {
|
||||
const response = await send('/backup');
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename =
|
||||
response.headers.get('content-disposition')?.split('filename=')[1] || 'backup.db';
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to download backup: ${error}`);
|
||||
}
|
||||
},
|
||||
|
||||
async restoreBackup(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', files[0]);
|
||||
|
||||
await send(`/backup/restore`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
toast.success('Backup restored successfully');
|
||||
},
|
||||
|
||||
// Plugins
|
||||
async getMiddlewarePlugins() {
|
||||
const data = await send('/middleware/plugins');
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { z } from 'zod';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
// Define validation schema for addPrefix content
|
||||
const schema = z.object({
|
||||
prefix: z
|
||||
.string({ required_error: 'Prefix is required' })
|
||||
.trim()
|
||||
.min(1, 'Prefix is required')
|
||||
.default('/foo')
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
// Parse and validate middleware.content for addPrefix
|
||||
let errors: Record<string, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="prefix" class="text-right">Prefix</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
type="text"
|
||||
id="prefix"
|
||||
placeholder="/foo"
|
||||
bind:value={middleware.content.prefix}
|
||||
on:input={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.prefix}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.prefix}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.users}
|
||||
label="Users"
|
||||
placeholder="user:password"
|
||||
helpText="Username and password are separated by a colon. Password will be hashed automatically. You will not be able to see the password again!"
|
||||
{disabled}
|
||||
/>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="users-file" class="text-right">Users File</Label>
|
||||
<Input
|
||||
id="users-file"
|
||||
name="users-file"
|
||||
type="text"
|
||||
bind:value={middleware.content.usersFile}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="/path/to/my/usersfile"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="realm" class="text-right">Realm</Label>
|
||||
<Input
|
||||
id="realm"
|
||||
name="realm"
|
||||
type="text"
|
||||
bind:value={middleware.content.realm}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="traefik"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="remove-header" class="text-right">Remove Header</Label>
|
||||
<Switch
|
||||
id="remove-header"
|
||||
bind:checked={middleware.content.removeHeader}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="header-field" class="text-right">Header Field</Label>
|
||||
<Input
|
||||
id="header-field"
|
||||
name="header-field"
|
||||
type="text"
|
||||
bind:value={middleware.content.headerField}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="X-WebAuth-User"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { z } from 'zod';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
maxRequestBodyBytes: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
memRequestBodyBytes: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
maxResponseBodyBytes: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
memResponseBodyBytes: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
retryExpression: z.string().trim().nullish()
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="max-request-body-bytes" class="text-right">Max Request Body Bytes</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="max-request-body-bytes"
|
||||
name="max-request-body-bytes"
|
||||
type="number"
|
||||
bind:value={middleware.content.maxRequestBodyBytes}
|
||||
on:input={validate}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.maxRequestBodyBytes}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.maxRequestBodyBytes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mem-request-body-bytes" class="text-right">Mem Request Body Bytes</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="mem-request-body-bytes"
|
||||
name="mem-request-body-bytes"
|
||||
type="number"
|
||||
bind:value={middleware.content.memRequestBodyBytes}
|
||||
on:input={validate}
|
||||
placeholder="1048576"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.memRequestBodyBytes}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.memRequestBodyBytes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="max-response-body-bytes" class="text-right">Max Response Body Bytes</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="max-response-body-bytes"
|
||||
name="max-response-body-bytes"
|
||||
type="number"
|
||||
bind:value={middleware.content.maxResponseBodyBytes}
|
||||
on:input={validate}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.maxResponseBodyBytes}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.maxResponseBodyBytes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mem-response-body-bytes" class="text-right">Mem Response Body Bytes</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="mem-response-body-bytes"
|
||||
name="mem-response-body-bytes"
|
||||
type="number"
|
||||
bind:value={middleware.content.memResponseBodyBytes}
|
||||
on:input={validate}
|
||||
placeholder="1048576"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.memResponseBodyBytes}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.memResponseBodyBytes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="retry-expression" class="text-right">Retry Expression</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="retry-expression"
|
||||
name="retry-expression"
|
||||
type="text"
|
||||
bind:value={middleware.content.retryExpression}
|
||||
on:input={validate}
|
||||
placeholder="IsNetworkError() && Attempts() < 2"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.retryExpression}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.retryExpression}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type { Selected } from 'bits-ui';
|
||||
import { middlewares } from '$lib/api';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
// Create a reactive variable for selected middlewares
|
||||
let selectedMiddlewares: Selected<string>[] | undefined = $state(middleware.content?.middlewares?.map(
|
||||
(m: string) => ({ value: m, label: m })
|
||||
));
|
||||
|
||||
const changeMiddlewares = (middlewares: Selected<string>[] | undefined) => {
|
||||
if (!middleware.content) return;
|
||||
middleware.content.middlewares = middlewares ? middlewares.map((m) => m.value) : [];
|
||||
selectedMiddlewares = middlewares || [];
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<ArrayInput
|
||||
items={middleware.content.middlewares}
|
||||
label="Middlewares"
|
||||
placeholder=""
|
||||
disabled={true}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="middlewares" class="text-right">Middlewares</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<Select.Root multiple selected={selectedMiddlewares} onSelectedChange={changeMiddlewares}>
|
||||
<Select.Trigger>
|
||||
<Select.Value placeholder="Select middlewares to" />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="no-scrollbar max-h-[200px] overflow-y-auto">
|
||||
{#each $middlewares as m}
|
||||
<Select.Item value={m.name} label={m.name}>
|
||||
{m.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { z } from 'zod';
|
||||
import { CustomTimeUnitSchemaOptional } from '../utils/validation';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
expression: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Expression is required')
|
||||
.default('LatencyAtQuantileMS(50.0) > 100'),
|
||||
checkPeriod: CustomTimeUnitSchemaOptional,
|
||||
fallbackDuration: CustomTimeUnitSchemaOptional,
|
||||
recoveryDuration: CustomTimeUnitSchemaOptional,
|
||||
responseCode: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish()
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="expression" class="text-right">Expression</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="expression"
|
||||
name="expression"
|
||||
type="text"
|
||||
bind:value={middleware.content.expression}
|
||||
on:input={validate}
|
||||
placeholder="LatencyAtQuantileMS(50.0) > 100"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.expression}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.expression}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="check-period" class="text-right">Check Period</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="check-period"
|
||||
name="check-period"
|
||||
type="text"
|
||||
bind:value={middleware.content.checkPeriod}
|
||||
on:input={validate}
|
||||
placeholder="100ms"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.checkPeriod}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.checkPeriod}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="fallback-duration" class="text-right">Fallback Duration</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="fallback-duration"
|
||||
name="fallback-duration"
|
||||
type="text"
|
||||
bind:value={middleware.content.fallbackDuration}
|
||||
on:input={validate}
|
||||
placeholder="10s"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.fallbackDuration}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.fallbackDuration}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="recovery-duration" class="text-right">Recovery Duration</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="recovery-duration"
|
||||
name="recovery-duration"
|
||||
type="text"
|
||||
bind:value={middleware.content.recoveryDuration}
|
||||
on:input={validate}
|
||||
placeholder="10s"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.recoveryDuration}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.recoveryDuration}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="response-code" class="text-right">Response Code</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="response-code"
|
||||
name="response-code"
|
||||
type="number"
|
||||
bind:value={middleware.content.responseCode}
|
||||
on:input={validate}
|
||||
placeholder="503"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.responseCode}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.responseCode}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
minResponseBodyBytes: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
defaultEncoding: z.string({ required_error: 'Default Encoding is required' }).trim().optional(),
|
||||
excludedContentTypes: z
|
||||
.array(z.string({ required_error: 'Excluded Content Types is required' }).trim())
|
||||
.optional(),
|
||||
includeContentTypes: z
|
||||
.array(z.string({ required_error: 'Include Content Types is required' }).trim())
|
||||
.optional()
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content); // Parse the compress object
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="min-response-body-bytes" class="text-right">Min Response Body Bytes</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="min-response-body-bytes"
|
||||
name="min-response-body-bytes"
|
||||
type="number"
|
||||
bind:value={middleware.content.minResponseBodyBytes}
|
||||
on:input={validate}
|
||||
placeholder="1024"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.minResponseBodyBytes}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.minResponseBodyBytes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="default-encoding" class="text-right">Default Encoding</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="default-encoding"
|
||||
name="default-encoding"
|
||||
type="text"
|
||||
bind:value={middleware.content.defaultEncoding}
|
||||
on:input={validate}
|
||||
placeholder="gzip"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.defaultEncoding}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.defaultEncoding}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.excludedContentTypes}
|
||||
label="Excluded Content Types"
|
||||
placeholder="text/event-stream"
|
||||
{disabled}
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.includeContentTypes}
|
||||
label="Include Content Types"
|
||||
placeholder="application/json"
|
||||
{disabled}
|
||||
/>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.users}
|
||||
label="Users"
|
||||
placeholder="user:password"
|
||||
helpText="Username and password are separated by a colon. Password will be hashed automatically. You will not be able to see the password again!"
|
||||
{disabled}
|
||||
/>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="users-file" class="text-right">Users File</Label>
|
||||
<Input
|
||||
id="users-file"
|
||||
name="users-file"
|
||||
type="text"
|
||||
bind:value={middleware.content.usersFile}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="/path/to/my/usersfile"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="realm" class="text-right">Realm</Label>
|
||||
<Input
|
||||
id="realm"
|
||||
name="realm"
|
||||
type="text"
|
||||
bind:value={middleware.content.realm}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="traefik"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="remove-header" class="text-right">Remove Header</Label>
|
||||
<Switch
|
||||
id="remove-header"
|
||||
bind:checked={middleware.content.removeHeader}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="header-field" class="text-right">Header Field</Label>
|
||||
<Input
|
||||
id="header-field"
|
||||
name="header-field"
|
||||
type="text"
|
||||
bind:value={middleware.content.headerField}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="X-WebAuth-User"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="service" class="text-right">Service</Label>
|
||||
<Input
|
||||
id="service"
|
||||
name="service"
|
||||
type="text"
|
||||
bind:value={middleware.content.service}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="serviceError"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="query" class="text-right">Query</Label>
|
||||
<Input
|
||||
id="query"
|
||||
name="query"
|
||||
type="text"
|
||||
bind:value={middleware.content.query}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="/status.html"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<ArrayInput bind:items={middleware.content.status} label="Status" placeholder="500" {disabled} />
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="address" class="text-right">Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
type="text"
|
||||
bind:value={middleware.content.address}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="https://example.com/auth"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="trust-forward-header" class="text-right">Trust Forward Header</Label>
|
||||
<Switch
|
||||
id="trust-forward-header"
|
||||
bind:checked={middleware.content.trustForwardHeader}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="auth-response-headers-regex" class="text-right">Auth Response Headers Regex</Label>
|
||||
<Input
|
||||
id="auth-response-headers-regex"
|
||||
name="auth-response-headers-regex"
|
||||
type="text"
|
||||
bind:value={middleware.content.authResponseHeadersRegex}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="^X-"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="auth-request-headers" class="text-right">Auth Request Headers</Label>
|
||||
<Input
|
||||
id="auth-request-headers"
|
||||
name="auth-request-headers"
|
||||
type="text"
|
||||
bind:value={middleware.content.authRequestHeaders}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="X-CustomHeader"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.authResponseHeaders}
|
||||
label="Auth Response Headers"
|
||||
placeholder="X-Auth-User"
|
||||
{disabled}
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.authRequestHeaders}
|
||||
label="Auth Request Headers"
|
||||
placeholder="Accept"
|
||||
{disabled}
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.addAuthCookiesToResponse}
|
||||
label="Add Auth Cookies To Response"
|
||||
placeholder="Session-Cookie"
|
||||
{disabled}
|
||||
/>
|
||||
@@ -1,400 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import ObjectInput from '../ui/object-input/object-input.svelte';
|
||||
import { z } from 'zod';
|
||||
import { CustomIPSchemaOptional } from '../utils/validation';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
}
|
||||
|
||||
let { middleware = $bindable() }: Props = $props();
|
||||
const emptyHeaders = {
|
||||
// SSL and Security Headers (commonly used)
|
||||
sslProxyHeaders: {},
|
||||
|
||||
// Security and Privacy Policies (high importance)
|
||||
contentSecurityPolicy: '',
|
||||
contentTypeNosniff: false,
|
||||
browserXssFilter: false,
|
||||
frameDeny: false,
|
||||
customFrameOptionsValue: '',
|
||||
referrerPolicy: '',
|
||||
permissionsPolicy: '',
|
||||
|
||||
// Access Control Headers (important for CORS and security)
|
||||
accessControlAllowOriginList: [],
|
||||
accessControlAllowOriginListRegex: [],
|
||||
accessControlAllowHeaders: [],
|
||||
accessControlAllowMethods: [],
|
||||
accessControlAllowCredentials: false,
|
||||
accessControlExposeHeaders: [],
|
||||
|
||||
// STS (HTTP Strict Transport Security)
|
||||
stsIncludeSubdomains: false,
|
||||
stsPreload: false,
|
||||
forceSTSHeader: false,
|
||||
|
||||
// Custom Headers (for custom configurations)
|
||||
customRequestHeaders: {},
|
||||
customResponseHeaders: {},
|
||||
|
||||
// Less frequently used security options
|
||||
addVaryHeader: false,
|
||||
allowedHosts: [],
|
||||
hostsProxyHeaders: [],
|
||||
publicKey: '',
|
||||
|
||||
// Miscellaneous
|
||||
customBrowserXSSValue: ''
|
||||
};
|
||||
|
||||
const defaultTemplate = {
|
||||
// SSL and Security Headers
|
||||
sslProxyHeaders: {
|
||||
'X-Forwarded-Proto': 'https'
|
||||
},
|
||||
|
||||
// Security and Privacy Policies
|
||||
contentSecurityPolicy:
|
||||
"default-src 'self'; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';", // Mitigates XSS attacks
|
||||
contentTypeNosniff: true, // Prevents MIME-type sniffing
|
||||
browserXssFilter: true, // Helps prevent XSS attacks
|
||||
frameDeny: true, // Denies embedding in iframes
|
||||
customFrameOptionsValue: '', // Can be set for more granular control
|
||||
referrerPolicy: 'no-referrer', // Prevents referrer leakage
|
||||
permissionsPolicy: 'geolocation=(), microphone=(), camera=(), fullscreen=(self)', // Restricts access to sensitive APIs
|
||||
|
||||
// Access Control Headers (CORS and Security)
|
||||
accessControlAllowHeaders: ['Authorization', 'Content-Type'],
|
||||
accessControlAllowMethods: ['GET', 'POST', 'OPTIONS'],
|
||||
accessControlAllowCredentials: true, // Allow sending credentials
|
||||
accessControlExposeHeaders: ['Authorization'],
|
||||
accessControlMaxAge: 86400, // Cache CORS preflight requests for 1 day
|
||||
|
||||
// STS (HTTP Strict Transport Security)
|
||||
stsSeconds: 31536000, // Enforce HTTPS for 1 year
|
||||
stsIncludeSubdomains: true, // Apply STS to subdomains
|
||||
stsPreload: true, // Preload into browsers for STS
|
||||
forceSTSHeader: true, // Force the STS header
|
||||
|
||||
// Custom Headers (for custom configurations)
|
||||
customResponseHeaders: {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'X-Robots-Tag': 'none,noarchive,nosnippet,notranslate,noimageindex'
|
||||
},
|
||||
customRequestHeaders: {
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Permitted-Cross-Domain-Policies': 'none'
|
||||
},
|
||||
|
||||
// Less frequently used security options
|
||||
addVaryHeader: true, // Useful for caching and negotiation
|
||||
hostsProxyHeaders: ['X-Forwarded-Host']
|
||||
};
|
||||
|
||||
const headersSchema = z.object({
|
||||
sslProxyHeaders: z.record(z.string(), z.string()).nullish(),
|
||||
contentSecurityPolicy: z.string().nullish(),
|
||||
contentTypeNosniff: z.boolean().nullish(),
|
||||
browserXssFilter: z.boolean().nullish(),
|
||||
frameDeny: z.boolean().nullish(),
|
||||
customFrameOptionsValue: z.string().nullish(),
|
||||
referrerPolicy: z.string().nullish(),
|
||||
permissionsPolicy: z.string().nullish(),
|
||||
accessControlAllowOriginList: z.array(CustomIPSchemaOptional).default([]).nullish(),
|
||||
accessControlAllowOriginListRegex: z.array(z.string()).default([]).nullish(),
|
||||
accessControlAllowHeaders: z.array(z.string()).default([]).nullish(),
|
||||
accessControlAllowMethods: z.array(z.string()).default([]).nullish(),
|
||||
accessControlAllowCredentials: z.boolean().default(false).nullish(),
|
||||
accessControlExposeHeaders: z.array(z.string()).default([]).nullish(),
|
||||
accessControlMaxAge: z.coerce.number().int().nonnegative().nullish(),
|
||||
stsSeconds: z.coerce.number().int().nonnegative().nullish(),
|
||||
stsIncludeSubdomains: z.boolean().default(false).nullish(),
|
||||
stsPreload: z.boolean().default(false).nullish(),
|
||||
forceSTSHeader: z.boolean().default(false).nullish(),
|
||||
customResponseHeaders: z.record(z.string(), z.string()).nullish(),
|
||||
customRequestHeaders: z.record(z.string(), z.string()).nullish(),
|
||||
addVaryHeader: z.boolean().default(true).nullish(),
|
||||
hostsProxyHeaders: z.array(z.string()).default([]).nullish()
|
||||
});
|
||||
middleware.content = headersSchema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = headersSchema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let isTemplate = $state(false);
|
||||
const toggleTemplate = () => {
|
||||
isTemplate = !isTemplate;
|
||||
if (isTemplate) {
|
||||
middleware.content = { ...emptyHeaders, ...defaultTemplate };
|
||||
} else {
|
||||
middleware.content = { ...emptyHeaders };
|
||||
}
|
||||
validate();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button on:click={toggleTemplate}>
|
||||
{isTemplate ? 'Clear Config' : 'Use Secure Template'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- SSL and Security Headers (commonly used) -->
|
||||
<ObjectInput
|
||||
bind:items={middleware.content.sslProxyHeaders}
|
||||
label="SSL Proxy Headers"
|
||||
keyPlaceholder="Header Name"
|
||||
valuePlaceholder="Header Value"
|
||||
on:update={validate}
|
||||
class="my-4"
|
||||
/>
|
||||
{#if errors.sslProxyHeaders}
|
||||
<span class="text-sm text-red-500">{errors.sslProxyHeaders}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Security and Privacy Policies (high importance) -->
|
||||
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Security and Privacy Policies</span>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="content-security-policy" class="text-right">Content Security Policy</Label>
|
||||
<Input
|
||||
id="content-security-policy"
|
||||
name="content-security-policy"
|
||||
type="text"
|
||||
bind:value={middleware.content.contentSecurityPolicy}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="default-src 'self'; script-src 'self' 'unsafe-inline';"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="custom-frame-options-value" class="text-right">Custom Frame Options Value</Label>
|
||||
<Input
|
||||
id="custom-frame-options-value"
|
||||
name="custom-frame-options-value"
|
||||
type="text"
|
||||
bind:value={middleware.content.customFrameOptionsValue}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="SAMEORIGIN"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="referrer-policy" class="text-right">Referrer Policy</Label>
|
||||
<Input
|
||||
id="referrer-policy"
|
||||
name="referrer-policy"
|
||||
type="text"
|
||||
bind:value={middleware.content.referrerPolicy}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="permissions-policy" class="text-right">Permissions Policy</Label>
|
||||
<Input
|
||||
id="permissions-policy"
|
||||
name="permissions-policy"
|
||||
type="text"
|
||||
bind:value={middleware.content.permissionsPolicy}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="geolocation 'none'; microphone 'none';"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="content-type-no-sniff" class="text-right">Content Type No Sniff</Label>
|
||||
<Switch
|
||||
id="content-type-no-snuff"
|
||||
bind:checked={middleware.content.contentTypeNosniff}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="browser-xss-filter" class="text-right">Browser XSS Filter</Label>
|
||||
<Switch
|
||||
id="browser-xss-filter"
|
||||
bind:checked={middleware.content.browserXssFilter}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="frame-deny" class="text-right">Frame Deny</Label>
|
||||
<Switch id="frame-deny" bind:checked={middleware.content.frameDeny} class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<!-- Access Control Headers -->
|
||||
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Access Control Headers</span>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.accessControlAllowOriginList}
|
||||
placeholder="*"
|
||||
label="Access Control Allow Origin List"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.accessControlAllowOriginListRegex}
|
||||
placeholder="example\\.com"
|
||||
label="Access Control Allow Origin List Regex"
|
||||
class="my-2"
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.accessControlAllowHeaders}
|
||||
placeholder="Authorization"
|
||||
label="Access Control Allow Headers"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.accessControlAllowMethods}
|
||||
placeholder="GET, POST, PUT, DELETE, OPTIONS"
|
||||
label="Access Control Allow Methods"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.accessControlExposeHeaders}
|
||||
placeholder="Authorization"
|
||||
label="Access Control Expose Headers"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="access-control-max-age" class="text-right">Access Control Max Age</Label>
|
||||
<Input
|
||||
id="access-control-max-age"
|
||||
name="access-control-max-age"
|
||||
type="number"
|
||||
bind:value={middleware.content.accessControlMaxAge}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="access-control-allow-credentials" class="text-right"
|
||||
>Access Control Allow Credentials</Label
|
||||
>
|
||||
<Switch
|
||||
id="access-control-allow-credentials"
|
||||
bind:checked={middleware.content.accessControlAllowCredentials}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- STS -->
|
||||
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Strict Transport Security (STS)</span>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="sts-seconds" class="text-right">STS Seconds</Label>
|
||||
<Input
|
||||
id="sts-seconds"
|
||||
name="sts-seconds"
|
||||
type="number"
|
||||
bind:value={middleware.content.stsSeconds}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="86400"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="sts-include-sub-domains" class="text-right">STS Include Sub Domains</Label>
|
||||
<Switch
|
||||
id="sts-include-sub-domains"
|
||||
bind:checked={middleware.content.stsIncludeSubdomains}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="sts-pre-load" class="text-right">STS Pre Load</Label>
|
||||
<Switch id="sts-pre-load" bind:checked={middleware.content.stsPreload} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="force-sts-header" class="text-right">Force STS Header</Label>
|
||||
<Switch
|
||||
id="force-sts-header"
|
||||
bind:checked={middleware.content.forceSTSHeader}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Custom Headers -->
|
||||
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Custom Headers</span>
|
||||
<ObjectInput
|
||||
bind:items={middleware.content.customResponseHeaders}
|
||||
label="Custom Response Headers"
|
||||
keyPlaceholder="Header Name"
|
||||
valuePlaceholder="Header Value"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<ObjectInput
|
||||
bind:items={middleware.content.customRequestHeaders}
|
||||
label="Custom Request Headers"
|
||||
keyPlaceholder="Header Name"
|
||||
valuePlaceholder="Header Value"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
|
||||
<!-- Less frequently used headers -->
|
||||
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Various extra headers</span>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="add-vary-header" class="text-right">Add Vary Header</Label>
|
||||
<Switch id="add-vary-header" bind:checked={middleware.content.addVaryHeader} class="col-span-3" />
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.allowedHosts}
|
||||
placeholder="example.com"
|
||||
label="Allowed Hosts"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.hostsProxyHeaders}
|
||||
placeholder="X-Forwarded-Host"
|
||||
label="Hosts Proxy Headers"
|
||||
on:update={validate}
|
||||
class="my-2"
|
||||
/>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="public-key" class="text-right">Public Key</Label>
|
||||
<Input
|
||||
id="public-key"
|
||||
name="public-key"
|
||||
type="text"
|
||||
bind:value={middleware.content.publicKey}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="MIIBIjANBgkqhkiG9w0BAQEFAA..."
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="custom-browser-xss-value" class="text-right">Custom Browser XSS Value</Label>
|
||||
<Input
|
||||
id="custom-browser-xss-value"
|
||||
name="custom-browser-xss-value"
|
||||
type="text"
|
||||
bind:value={middleware.content.customBrowserXSSValue}
|
||||
on:input={validate}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="1; mode=block"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { z } from 'zod';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
amount: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish()
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="amount" class="text-right">Amount</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
bind:value={middleware.content.amount}
|
||||
on:input={validate}
|
||||
placeholder="50"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.amount}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.amount}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import { z } from 'zod';
|
||||
import { CustomIPSchemaOptional } from '../utils/validation';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
amount: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
sourceCriterion: z
|
||||
.object({
|
||||
ipStrategy: z
|
||||
.object({
|
||||
depth: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
|
||||
})
|
||||
.default({}),
|
||||
requestHeaderName: z.string().trim().nullish(),
|
||||
requestHost: z.boolean().nullish()
|
||||
})
|
||||
.default({})
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content); // Parse the inFlightReq object
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="amount" class="text-right">Amount</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
bind:value={middleware.content.amount}
|
||||
on:input={validate}
|
||||
placeholder="50"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.amount}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.amount}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="border-b border-gray-200 py-2 font-bold">Source Criterion</header>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="depth" class="text-right">Depth</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="depth"
|
||||
name="depth"
|
||||
type="number"
|
||||
bind:value={middleware.content.sourceCriterion.ipStrategy.depth}
|
||||
on:input={validate}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.ipStrategy}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="request-header-name" class="text-right">Request Header Name</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="request-header-name"
|
||||
name="request-header-name"
|
||||
type="text"
|
||||
bind:value={middleware.content.sourceCriterion.requestHeaderName}
|
||||
on:input={validate}
|
||||
placeholder="X-CustomHeader"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.requestHeaderName}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.requestHeaderName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="request-host" class="text-right">Request Host</Label>
|
||||
<Switch
|
||||
id="request-host"
|
||||
bind:checked={middleware.content.sourceCriterion.requestHost}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.sourceCriterion.ipStrategy.excludedIPs}
|
||||
label="Excluded IPs"
|
||||
placeholder="192.168.1.1"
|
||||
on:update={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.sourceCriterion}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceCriterion}</div>
|
||||
{/if}
|
||||
@@ -1,107 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import { z } from 'zod';
|
||||
import { CustomIPSchema, CustomIPSchemaOptional } from '../utils/validation';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const templateRange = ['192.168.0.0/16', '172.16.0.0/12', '127.0.0.1/32', '10.0.0.0/8'];
|
||||
|
||||
const ipAllowListSchema = z.object({
|
||||
sourceRange: z.array(CustomIPSchema).default([]),
|
||||
ipStrategy: z
|
||||
.object({
|
||||
depth: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
|
||||
})
|
||||
.default({})
|
||||
});
|
||||
middleware.content = ipAllowListSchema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = ipAllowListSchema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let isTemplate = $state(middleware.content?.sourceRange?.length > 0);
|
||||
const toggleIpAllowList = () => {
|
||||
isTemplate = !isTemplate;
|
||||
if (isTemplate) {
|
||||
middleware.content.sourceRange = templateRange;
|
||||
} else {
|
||||
middleware.content.sourceRange = [];
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button on:click={toggleIpAllowList}>
|
||||
{isTemplate ? 'Clear IPs' : 'Add Private IP range'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.sourceRange}
|
||||
label="Source Range"
|
||||
placeholder="192.168.1.1/32"
|
||||
on:update={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.sourceRange}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceRange}</div>
|
||||
{/if}
|
||||
|
||||
{#if middleware.content.ipStrategy && middleware.protocol === 'http'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="depth" class="text-right">Depth</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="depth"
|
||||
name="depth"
|
||||
type="text"
|
||||
bind:value={middleware.content.ipStrategy.depth}
|
||||
on:input={validate}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.ipStrategy}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.ipStrategy.excludedIPs}
|
||||
label="Excluded IPs"
|
||||
placeholder="192.168.1.1"
|
||||
on:update={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.ipStrategy}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,145 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { profile } from '$lib/api';
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import logo from '$lib/images/logo.svg';
|
||||
import Testform from './testform.svelte';
|
||||
import type { Component, SvelteComponent } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
mode: 'create' | 'edit';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable({} as Middleware), mode, disabled = false }: Props = $props();
|
||||
|
||||
// Computed properties
|
||||
let middlewareProvider = $derived(middleware.name ? middleware.name.split('@')[1] : 'http');
|
||||
let isHttpProvider = $derived(middlewareProvider === 'http' || !middlewareProvider);
|
||||
|
||||
const middlewareTypes: Record<string, { value: string; label: string; form: any | undefined }[]> =
|
||||
{
|
||||
http: [
|
||||
{ value: 'rateLimit', label: 'Rate Limit', form: Testform },
|
||||
{ value: 'headers', label: 'Headers' }
|
||||
// { value: 'compress', label: 'Compress' },
|
||||
// { value: 'retry', label: 'Retry' },
|
||||
// { value: 'ipAllowList', label: 'Whitelist' },
|
||||
// { value: 'basicAuth', label: 'Basic Auth' },
|
||||
// { value: 'forwardAuth', label: 'Forward Auth' },
|
||||
// { value: 'digestAuth', label: 'Digest Auth' },
|
||||
// { value: 'chain', label: 'Chain' },
|
||||
// { value: 'redirectScheme', label: 'Redirect Scheme' },
|
||||
// { value: 'redirectRegex', label: 'Redirect Regex' },
|
||||
// { value: 'addPrefix', label: 'Add Prefix' },
|
||||
// { value: 'stripPrefix', label: 'Strip Prefix' },
|
||||
// { value: 'stripPrefixRegex', label: 'Strip Prefix Regex' },
|
||||
// { value: 'replacePath', label: 'Replace Path' },
|
||||
// { value: 'replacePathRegex', label: 'Replace Path Regex' },
|
||||
// { value: 'inFlightReq', label: 'InFlightReq' },
|
||||
// { value: 'circuitBreaker', label: 'Circuit Breaker' },
|
||||
// { value: 'buffering', label: 'Buffering' },
|
||||
// { value: 'errors', label: 'Errors' },
|
||||
// { value: 'passTLSClientCert', label: 'Pass TLS Client Cert' },
|
||||
// { value: 'plugin', label: 'Plugin' }
|
||||
],
|
||||
tcp: [
|
||||
{ value: 'inFlightConn', label: 'InFlightConn', form: Testform },
|
||||
{ value: 'ipAllowList', label: 'Whitelist', form: null }
|
||||
]
|
||||
};
|
||||
|
||||
let isHTTP = $state(middleware.protocol === 'http');
|
||||
let MiddlewareForm = $state(null);
|
||||
|
||||
async function handleMiddlewareTypeChange(value: string) {
|
||||
if (!value || !$profile.id) return;
|
||||
|
||||
middleware.type = isHTTP ? 'http' : 'tcp';
|
||||
middleware.protocol = isHTTP ? 'http' : 'tcp';
|
||||
MiddlewareForm = middlewareTypes[isHTTP ? 'http' : 'tcp'].find((t) => t.value === value)?.form;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="mt-4">
|
||||
<Card.Header>
|
||||
<Card.Title>{mode === 'create' ? 'Add' : 'Update'} Middleware</Card.Title>
|
||||
<Card.Description>
|
||||
{mode === 'create' ? 'Create a new middleware' : 'Edit existing middleware'}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<!-- Protocol Switch -->
|
||||
{#if isHttpProvider}
|
||||
<div class="flex items-center justify-end gap-2 pb-2">
|
||||
<Switch id="protocol-type" bind:checked={isHTTP} />
|
||||
<Label for="protocol-type">{isHTTP ? 'HTTP' : 'TCP'}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Type Selector -->
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="type" class="text-right">Type</Label>
|
||||
<Select.Root type="single" value={middleware.type} onValueChange={handleMiddlewareTypeChange}>
|
||||
<Select.Trigger class="col-span-3">
|
||||
{middleware.type
|
||||
? middlewareTypes[isHTTP ? 'http' : 'tcp'].find((t) => t.value === middleware.type)
|
||||
?.label
|
||||
: 'Select type'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each middlewareTypes[isHTTP ? 'http' : 'tcp'] as type}
|
||||
<Select.Item value={type.value}>
|
||||
{type.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Name Input -->
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={middleware.name}
|
||||
placeholder="Middleware name"
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
{#if middlewareProvider}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-400"
|
||||
>
|
||||
{#if isHttpProvider}
|
||||
<img src={logo} alt="HTTP" width="20" />
|
||||
{:else if middlewareProvider.includes('docker')}
|
||||
<iconify-icon icon="logos:docker-icon" height="20"></iconify-icon>
|
||||
{:else if middlewareProvider.includes('kubernetes')}
|
||||
<iconify-icon icon="logos:kubernetes" height="20"></iconify-icon>
|
||||
{:else if middlewareProvider === 'consul'}
|
||||
<iconify-icon icon="logos:consul" height="20"></iconify-icon>
|
||||
{:else if middlewareProvider === 'nomad'}
|
||||
<iconify-icon icon="logos:nomad-icon" height="20"></iconify-icon>
|
||||
{:else if middlewareProvider === 'kv'}
|
||||
<iconify-icon icon="logos:redis" height="20"></iconify-icon>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Form -->
|
||||
{#if MiddlewareForm}
|
||||
<div class="mt-6 flex flex-col gap-2">
|
||||
<MiddlewareForm bind:middleware {disabled} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pem" class="text-right">Pem</Label>
|
||||
<Switch id="remove-header" bind:checked={middleware.content.pem} class="col-span-3" {disabled} />
|
||||
</div>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
let pluginData = $state('{}');
|
||||
|
||||
function extractInnerPluginData() {
|
||||
if (!middleware.content) return;
|
||||
pluginData = JSON.stringify(middleware.content, null, 2) || '{}';
|
||||
}
|
||||
|
||||
run(() => {
|
||||
middleware.content, extractInnerPluginData();
|
||||
});
|
||||
let error = $state('');
|
||||
function validateJSON() {
|
||||
if (!pluginData || !middleware.content) return;
|
||||
try {
|
||||
JSON.parse(pluginData);
|
||||
middleware.content = JSON.parse(pluginData);
|
||||
error = '';
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-8 items-center gap-2">
|
||||
<Label for="config" class="text-right">Config</Label>
|
||||
<Textarea
|
||||
id="config"
|
||||
name="config"
|
||||
rows={pluginData ? pluginData.split('\n').length + 1 : 3}
|
||||
bind:value={pluginData}
|
||||
on:input={validateJSON}
|
||||
class="col-span-7 max-h-[500px] overflow-y-auto"
|
||||
{disabled}
|
||||
/>
|
||||
{#if error}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,169 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
import { CustomIPSchemaOptional } from '../utils/validation';
|
||||
import { z } from 'zod';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const schema = z.object({
|
||||
period: z.string({ required_error: 'Period is required' }).trim().default('1s'),
|
||||
average: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
burst: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
sourceCriterion: z
|
||||
.object({
|
||||
ipStrategy: z
|
||||
.object({
|
||||
depth: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
|
||||
})
|
||||
.default({}),
|
||||
requestHeaderName: z.string().trim().nullish(),
|
||||
requestHost: z.boolean().nullish()
|
||||
})
|
||||
.default({})
|
||||
});
|
||||
middleware.content = schema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = schema.parse(middleware.content); // Parse the rateLimit object
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
validate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="average" class="text-right">Average</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="average"
|
||||
name="average"
|
||||
type="number"
|
||||
bind:value={middleware.content.average}
|
||||
on:input={validate}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.average}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.average}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="period" class="text-right">Period</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="period"
|
||||
name="period"
|
||||
type="text"
|
||||
bind:value={middleware.content.period}
|
||||
on:input={validate}
|
||||
placeholder="1s"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.period}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.period}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="burst" class="text-right">Burst</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="burst"
|
||||
name="burst"
|
||||
type="number"
|
||||
bind:value={middleware.content.burst}
|
||||
on:input={validate}
|
||||
placeholder="1"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.burst}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.burst}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="border-b border-gray-200 py-2 font-bold">Source Criterion</header>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="depth" class="text-right">Depth</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="depth"
|
||||
name="depth"
|
||||
type="number"
|
||||
bind:value={middleware.content.sourceCriterion.ipStrategy.depth}
|
||||
placeholder="0"
|
||||
on:input={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.ipStrategy}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="request-header-name" class="text-right">Request Header Name</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="request-header-name"
|
||||
name="request-header-name"
|
||||
type="text"
|
||||
bind:value={middleware.content.sourceCriterion.requestHeaderName}
|
||||
on:input={validate}
|
||||
placeholder="username"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.requestHeaderName}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.requestHeaderName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="request-host" class="text-right">Request Host</Label>
|
||||
<Switch
|
||||
id="request-host"
|
||||
bind:checked={middleware.content.sourceCriterion.requestHost}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.sourceCriterion.ipStrategy.excludedIPs}
|
||||
label="Excluded IPs"
|
||||
placeholder="192.168.1.1"
|
||||
on:update={validate}
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.sourceCriterion}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceCriterion}</div>
|
||||
{/if}
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-2">
|
||||
<Label for="permanent" class="text-right">Permanent</Label>
|
||||
<Switch
|
||||
id="permanent"
|
||||
bind:checked={middleware.content.permanent}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="regex" class="text-right">Regex</Label>
|
||||
<Input
|
||||
id="regex"
|
||||
name="regex"
|
||||
type="text"
|
||||
bind:value={middleware.content.regex}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="^http://localhost/(.*)"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="replacement" class="text-right">Replacement</Label>
|
||||
<Input
|
||||
id="replacement"
|
||||
name="replacement"
|
||||
type="text"
|
||||
bind:value={middleware.content.replacement}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="http://mydomain/$1"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="permanent" class="text-right">Permanent</Label>
|
||||
<Switch
|
||||
id="permanent"
|
||||
bind:checked={middleware.content.permanent}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="scheme" class="text-right">Scheme</Label>
|
||||
<Input
|
||||
id="scheme"
|
||||
name="scheme"
|
||||
type="text"
|
||||
bind:value={middleware.content.scheme}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="https"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="port" class="text-right">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
name="port"
|
||||
type="text"
|
||||
bind:value={middleware.content.port}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="443"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="path" class="text-right">Path</Label>
|
||||
<Input
|
||||
id="path"
|
||||
name="path"
|
||||
type="text"
|
||||
bind:value={middleware.content.path}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="/foo"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="regex" class="text-right">Regex</Label>
|
||||
<Input
|
||||
id="regex"
|
||||
name="regex"
|
||||
type="text"
|
||||
bind:value={middleware.content.regex}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="^/foo/(.*)"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="replacement" class="text-right">Replacement</Label>
|
||||
<Input
|
||||
id="replacement"
|
||||
name="replacement"
|
||||
type="text"
|
||||
bind:value={middleware.content.replacement}
|
||||
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="/bar/$1"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { z } from 'zod';
|
||||
import { CustomTimeUnitSchemaOptional } from '../utils/validation';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
|
||||
const retrySchema = z.object({
|
||||
attempts: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (value === '' ? null : Number(value)))
|
||||
.nullish(),
|
||||
initialInterval: CustomTimeUnitSchemaOptional
|
||||
});
|
||||
middleware.content = retrySchema.parse({ ...middleware.content });
|
||||
|
||||
let errors: Record<any, string[] | undefined> = $state({});
|
||||
const validate = () => {
|
||||
try {
|
||||
middleware.content = retrySchema.parse(middleware.content);
|
||||
errors = {};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
errors = err.flatten().fieldErrors;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="attempts" class="text-right">Attempts</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="attempts"
|
||||
name="attempts"
|
||||
type="number"
|
||||
bind:value={middleware.content.attempts}
|
||||
on:input={validate}
|
||||
placeholder="3"
|
||||
min="0"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.attempts}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.attempts}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="initial-interval" class="text-right">Initial Interval</Label>
|
||||
<div class="relative col-span-3">
|
||||
<Input
|
||||
id="initial-interval"
|
||||
name="initial-interval"
|
||||
type="text"
|
||||
bind:value={middleware.content.initialInterval}
|
||||
on:input={validate}
|
||||
placeholder="100ms"
|
||||
{disabled}
|
||||
/>
|
||||
{#if errors.initialInterval}
|
||||
<div class="col-span-4 text-right text-sm text-red-500">{errors.initialInterval}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="force-slash" class="text-right">Force Slash</Label>
|
||||
<Switch
|
||||
id="force-slash"
|
||||
bind:checked={middleware.content.forceSlash}
|
||||
class="col-span-3"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.prefixes}
|
||||
label="Prefixes"
|
||||
placeholder="/foo"
|
||||
{disabled}
|
||||
/>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Middleware } from '$lib/types/middlewares';
|
||||
import ArrayInput from '../ui/array-input/array-input.svelte';
|
||||
|
||||
interface Props {
|
||||
middleware: Middleware;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { middleware = $bindable(), disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ArrayInput
|
||||
bind:items={middleware.content.regex}
|
||||
label="Regex"
|
||||
placeholder="/foo/[a-z0-9]+/[0-9]+/"
|
||||
{disabled}
|
||||
/>
|
||||
@@ -131,3 +131,11 @@ export interface Plugin {
|
||||
snippet: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BackupMetadata {
|
||||
filename: string;
|
||||
size: number;
|
||||
created: string;
|
||||
dbType: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
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 { SaveIcon, Settings } from 'lucide-svelte';
|
||||
import { settings, api } from '$lib/api';
|
||||
import { Download, List, SaveIcon, Settings, Upload } from 'lucide-svelte';
|
||||
import { settings, api, backups, loading } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Setting } from '$lib/types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// State management
|
||||
// let fileInput = $state<HTMLInputElement>();
|
||||
|
||||
// async function handleFileUpload(event: Event) {
|
||||
// const file = (event.target as HTMLInputElement).files?.[0];
|
||||
// if (file) {
|
||||
// await uploadBackup(file);
|
||||
// fileInput.value = '';
|
||||
// }
|
||||
// }
|
||||
import { DateFormat } from '$lib/store';
|
||||
|
||||
let hasChanges = $state(false);
|
||||
let changedValues = $state<Record<string, Setting['value']>>({});
|
||||
@@ -92,8 +83,18 @@
|
||||
if (e.key === 'Enter') saveSetting(key, value);
|
||||
}
|
||||
|
||||
// Backup handling
|
||||
let fileInput: HTMLInputElement;
|
||||
let showBackupList = $state(false);
|
||||
|
||||
function humanFileSize(size: number) {
|
||||
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await api.listSettings();
|
||||
await api.listBackups();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -101,7 +102,39 @@
|
||||
<title>Settings</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div class="container flex flex-col gap-6">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Backup Management</Card.Title>
|
||||
<Card.Description>Download or restore database backups</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button onclick={() => api.downloadBackup()} variant="outline">
|
||||
<Download class="mr-2 size-4" />
|
||||
Download Backup
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onclick={() => fileInput?.click()} disabled={$loading}>
|
||||
<Upload class="mr-2 size-4" />
|
||||
{$loading ? 'Uploading...' : 'Restore Backup'}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".sql,.db"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={(e) => api.restoreBackup(e.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<Button variant="outline" onclick={() => (showBackupList = true)}>
|
||||
<List class="mr-2 size-4" />
|
||||
View Backups
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="mb-3">
|
||||
@@ -169,3 +202,25 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={showBackupList}>
|
||||
<Dialog.Content class="max-w-[600px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Available Backups</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $backups as backup}
|
||||
<Button variant="link" class="flex items-center justify-between p-3">
|
||||
<span class="font-mono text-sm">
|
||||
Backup:
|
||||
{DateFormat.format(new Date(backup.created))}
|
||||
</span>
|
||||
<span class="font-mono text-sm">{humanFileSize(backup.size)}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{#if $backups.length === 0}
|
||||
<p class="text-center text-sm text-muted-foreground">No backups available</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
Reference in New Issue
Block a user