feat(setting): Implemented the settings administrative menu
This commit is contained in:
@@ -153,6 +153,43 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
func ExpireBox(boxID string) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
if delta <= 0 {
|
||||
return manifest, fmt.Errorf("Invalid bump duration")
|
||||
}
|
||||
if manifest.OneTimeDownload {
|
||||
return manifest, fmt.Errorf("One-time boxes cannot be extended")
|
||||
}
|
||||
|
||||
base := manifest.ExpiresAt
|
||||
now := time.Now().UTC()
|
||||
if base.IsZero() || base.Before(now) {
|
||||
base = now
|
||||
}
|
||||
manifest.ExpiresAt = base.Add(delta)
|
||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
@@ -204,3 +204,57 @@ func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
|
||||
t.Fatal("expected legacy password hash to verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireBoxMarksManifestExpired(t *testing.T) {
|
||||
restoreUploadRoot := UploadRoot()
|
||||
defer SetUploadRoot(restoreUploadRoot)
|
||||
SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "0123456789abcdef0123456789abcdef"
|
||||
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
manifest := models.BoxManifest{
|
||||
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
if err := WriteManifest(boxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
expired, err := ExpireBox(boxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireBox returned error: %v", err)
|
||||
}
|
||||
if !expired.ExpiresAt.Before(time.Now().UTC()) {
|
||||
t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) {
|
||||
restoreUploadRoot := UploadRoot()
|
||||
defer SetUploadRoot(restoreUploadRoot)
|
||||
SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "fedcba9876543210fedcba9876543210"
|
||||
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
base := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
manifest := models.BoxManifest{
|
||||
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||
ExpiresAt: base,
|
||||
}
|
||||
if err := WriteManifest(boxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
bumped, err := BumpBoxExpiry(boxID, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("BumpBoxExpiry returned error: %v", err)
|
||||
}
|
||||
expected := base.Add(24 * time.Hour)
|
||||
if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) {
|
||||
t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +145,14 @@ func TestSettingsOverrideValidation(t *testing.T) {
|
||||
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
|
||||
t.Fatal("expected negative expiry override to fail")
|
||||
}
|
||||
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
|
||||
t.Fatal("expected hard limit override to fail")
|
||||
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err != nil {
|
||||
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
||||
}
|
||||
if cfg.GlobalMaxFileSizeBytes != 1 {
|
||||
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
||||
t.Fatal("expected data_dir override to remain locked")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ var Definitions = []SettingDefinition{
|
||||
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||
|
||||
@@ -29,6 +29,7 @@ func Load() (*Config, error) {
|
||||
ThumbnailIntervalSeconds: 30,
|
||||
sources: make(map[string]Source),
|
||||
values: make(map[string]string),
|
||||
defaults: make(map[string]string),
|
||||
}
|
||||
|
||||
// Config precedence: defaults -> env -> overrides.
|
||||
@@ -152,25 +153,32 @@ func (cfg *Config) EnsureDirectories() error {
|
||||
return nil
|
||||
}
|
||||
func (cfg *Config) captureDefaults() {
|
||||
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
||||
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
||||
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
|
||||
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
||||
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
||||
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
||||
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
||||
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
||||
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
|
||||
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
|
||||
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
|
||||
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
|
||||
cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
|
||||
cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
|
||||
cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
|
||||
cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
|
||||
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
|
||||
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
|
||||
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
|
||||
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10))
|
||||
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10))
|
||||
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10))
|
||||
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10))
|
||||
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
|
||||
}
|
||||
|
||||
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||
cfg.setValue(key, value, SourceDefault)
|
||||
if cfg.defaults != nil {
|
||||
cfg.defaults[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||
|
||||
@@ -97,4 +97,5 @@ type Config struct {
|
||||
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
defaults map[string]string
|
||||
}
|
||||
|
||||
68
lib/config/override_store.go
Normal file
68
lib/config/override_store.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const AdminSettingsOverrideFilename = "admin_settings_overrides.json"
|
||||
|
||||
type adminSettingsOverrideFile struct {
|
||||
Format string `json:"format"`
|
||||
SavedAt string `json:"saved_at"`
|
||||
Overrides map[string]string `json:"overrides"`
|
||||
}
|
||||
|
||||
func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload adminSettingsOverrideFile
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.Overrides == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return payload.Overrides, nil
|
||||
}
|
||||
|
||||
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
|
||||
if overrides == nil {
|
||||
overrides = map[string]string{}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(overrides))
|
||||
for key := range overrides {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
normalized := make(map[string]string, len(overrides))
|
||||
for _, key := range keys {
|
||||
normalized[key] = overrides[key]
|
||||
}
|
||||
|
||||
payload := adminSettingsOverrideFile{
|
||||
Format: "warpbox.admin.settings.overrides.v1",
|
||||
SavedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Overrides: normalized,
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
@@ -76,6 +76,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
||||
cfg.MaxGuestExpirySeconds = value
|
||||
case SettingOneTimeDownloadExpirySecs:
|
||||
cfg.OneTimeDownloadExpirySeconds = value
|
||||
case SettingGlobalMaxFileSizeBytes:
|
||||
cfg.GlobalMaxFileSizeBytes = value
|
||||
case SettingGlobalMaxBoxSizeBytes:
|
||||
cfg.GlobalMaxBoxSizeBytes = value
|
||||
case SettingDefaultUserMaxFileBytes:
|
||||
cfg.DefaultUserMaxFileSizeBytes = value
|
||||
case SettingDefaultUserMaxBoxBytes:
|
||||
@@ -113,3 +117,10 @@ func (cfg *Config) sourceFor(key string) Source {
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func (cfg *Config) DefaultValue(key string) string {
|
||||
if cfg.defaults == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.defaults[key]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ type Handlers struct {
|
||||
AdminLogout gin.HandlerFunc
|
||||
AdminDashboard gin.HandlerFunc
|
||||
AdminAlerts gin.HandlerFunc
|
||||
AdminBoxes gin.HandlerFunc
|
||||
AdminBoxesAction gin.HandlerFunc
|
||||
AdminSettings gin.HandlerFunc
|
||||
AdminSettingsExport gin.HandlerFunc
|
||||
AdminSettingsSave gin.HandlerFunc
|
||||
AdminSettingsImport gin.HandlerFunc
|
||||
AdminSettingsReset gin.HandlerFunc
|
||||
AdminAuth gin.HandlerFunc
|
||||
}
|
||||
|
||||
@@ -52,4 +59,11 @@ func Register(router *gin.Engine, handlers Handlers) {
|
||||
protected := router.Group("/admin", handlers.AdminAuth)
|
||||
protected.GET("/dashboard", handlers.AdminDashboard)
|
||||
protected.GET("/alerts", handlers.AdminAlerts)
|
||||
protected.GET("/boxes", handlers.AdminBoxes)
|
||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||
protected.GET("/settings", handlers.AdminSettings)
|
||||
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||
protected.POST("/settings/import", handlers.AdminSettingsImport)
|
||||
protected.POST("/settings/reset", handlers.AdminSettingsReset)
|
||||
}
|
||||
|
||||
316
lib/server/admin_boxes.go
Normal file
316
lib/server/admin_boxes.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
)
|
||||
|
||||
type adminBoxesActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
BoxIDs []string `json:"box_ids"`
|
||||
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type adminBoxFileView struct {
|
||||
Name string `json:"name"`
|
||||
SizeLabel string `json:"size_label"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"status_label"`
|
||||
DownloadPath string `json:"download_path"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"status_label"`
|
||||
FileCount int `json:"file_count"`
|
||||
CompleteFiles int `json:"complete_files"`
|
||||
PendingFiles int `json:"pending_files"`
|
||||
FailedFiles int `json:"failed_files"`
|
||||
TotalSizeLabel string `json:"total_size_label"`
|
||||
CreatedAtLabel string `json:"created_at_label"`
|
||||
CreatedAtISO string `json:"created_at_iso"`
|
||||
ExpiresAtLabel string `json:"expires_at_label"`
|
||||
ExpiresAtISO string `json:"expires_at_iso"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
ZipDisabled bool `json:"zip_disabled"`
|
||||
ZipAvailable bool `json:"zip_available"`
|
||||
Consumed bool `json:"consumed"`
|
||||
HasManifest bool `json:"has_manifest"`
|
||||
OpenURL string `json:"open_url"`
|
||||
ZipURL string `json:"zip_url"`
|
||||
Flags []string `json:"flags"`
|
||||
Files []adminBoxFileView `json:"files"`
|
||||
SearchText string `json:"search_text"`
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
|
||||
boxes, err := app.listAdminBoxes()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load boxes")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "boxes",
|
||||
"Boxes": boxes,
|
||||
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
|
||||
var request adminBoxesActionRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(request.BoxIDs) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
||||
return
|
||||
}
|
||||
|
||||
switch request.Action {
|
||||
case "delete", "expire", "bump":
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Action == "bump" && request.DeltaSeconds <= 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
||||
return
|
||||
}
|
||||
|
||||
processed := 0
|
||||
warnings := make([]string, 0)
|
||||
|
||||
for _, boxID := range request.BoxIDs {
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
switch request.Action {
|
||||
case "delete":
|
||||
err = boxstore.DeleteBox(boxID)
|
||||
case "expire":
|
||||
_, err = boxstore.ExpireBox(boxID)
|
||||
case "bump":
|
||||
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
|
||||
boxes, err := app.listAdminBoxes()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if processed == 0 && len(warnings) > 0 {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
ctx.JSON(status, gin.H{
|
||||
"ok": len(warnings) == 0,
|
||||
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
|
||||
"warnings": warnings,
|
||||
"boxes": boxes,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boxes := make([]adminBoxView, 0, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
boxView, err := app.buildAdminBoxView(summary.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
boxes = append(boxes, boxView)
|
||||
}
|
||||
|
||||
sort.Slice(boxes, func(i, j int) bool {
|
||||
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
|
||||
})
|
||||
return boxes, nil
|
||||
}
|
||||
|
||||
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
|
||||
summary, err := boxstore.BoxSummary(boxID)
|
||||
if err != nil {
|
||||
return adminBoxView{}, err
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
return adminBoxView{}, err
|
||||
}
|
||||
|
||||
manifest, manifestErr := boxstore.ReadManifest(boxID)
|
||||
hasManifest := manifestErr == nil
|
||||
|
||||
boxView := adminBoxView{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
|
||||
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
|
||||
ExpiresAtLabel: "Not set",
|
||||
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
|
||||
RetentionLabel: "Legacy / unmanaged",
|
||||
PasswordProtected: summary.PasswordProtected,
|
||||
OneTimeDownload: summary.OneTimeDownload,
|
||||
HasManifest: hasManifest,
|
||||
OpenURL: "/box/" + summary.ID,
|
||||
Files: make([]adminBoxFileView, 0, len(files)),
|
||||
}
|
||||
|
||||
if !summary.ExpiresAt.IsZero() {
|
||||
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
|
||||
}
|
||||
|
||||
searchParts := []string{summary.ID, summary.TotalSizeLabel}
|
||||
for _, file := range files {
|
||||
if file.IsComplete {
|
||||
boxView.CompleteFiles++
|
||||
}
|
||||
if file.Status == "failed" {
|
||||
boxView.FailedFiles++
|
||||
}
|
||||
if !file.IsComplete && file.Status != "failed" {
|
||||
boxView.PendingFiles++
|
||||
}
|
||||
|
||||
boxView.Files = append(boxView.Files, adminBoxFileView{
|
||||
Name: file.Name,
|
||||
SizeLabel: file.SizeLabel,
|
||||
MimeType: file.MimeType,
|
||||
Status: file.Status,
|
||||
StatusLabel: file.StatusLabel,
|
||||
DownloadPath: file.DownloadPath,
|
||||
ThumbnailURL: file.ThumbnailURL,
|
||||
IsComplete: file.IsComplete,
|
||||
})
|
||||
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
|
||||
}
|
||||
|
||||
if hasManifest {
|
||||
boxView.RetentionLabel = manifest.RetentionLabel
|
||||
boxView.ZipDisabled = manifest.DisableZip
|
||||
boxView.Consumed = manifest.Consumed
|
||||
} else {
|
||||
boxView.ZipDisabled = false
|
||||
}
|
||||
|
||||
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
|
||||
if boxView.ZipAvailable {
|
||||
boxView.ZipURL = "/box/" + summary.ID + "/download"
|
||||
}
|
||||
|
||||
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
|
||||
boxView.Flags = deriveAdminBoxFlags(boxView)
|
||||
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
|
||||
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
|
||||
|
||||
return boxView, nil
|
||||
}
|
||||
|
||||
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
|
||||
switch {
|
||||
case !hasManifest:
|
||||
return "legacy", "Legacy"
|
||||
case consumed:
|
||||
return "consumed", "Consumed"
|
||||
case expired:
|
||||
return "expired", "Expired"
|
||||
case pendingFiles > 0:
|
||||
return "uploading", "Uploading"
|
||||
case failedFiles > 0:
|
||||
return "attention", "Needs review"
|
||||
default:
|
||||
return "ready", "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveAdminBoxFlags(box adminBoxView) []string {
|
||||
flags := make([]string, 0, 5)
|
||||
if box.PasswordProtected {
|
||||
flags = append(flags, "protected")
|
||||
}
|
||||
if box.OneTimeDownload {
|
||||
flags = append(flags, "one-time")
|
||||
}
|
||||
if box.ZipDisabled {
|
||||
flags = append(flags, "zip off")
|
||||
}
|
||||
if !box.HasManifest {
|
||||
flags = append(flags, "legacy")
|
||||
}
|
||||
if box.Consumed {
|
||||
flags = append(flags, "consumed")
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func adminTimeLabel(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "Not set"
|
||||
}
|
||||
return value.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
|
||||
switch action {
|
||||
case "delete":
|
||||
return fmt.Sprintf("Deleted %d box(es)", processed)
|
||||
case "expire":
|
||||
return fmt.Sprintf("Expired %d box(es)", processed)
|
||||
case "bump":
|
||||
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
|
||||
default:
|
||||
return "Action complete"
|
||||
}
|
||||
}
|
||||
|
||||
func adminBoxesDeltaLabel(deltaSeconds int64) string {
|
||||
switch deltaSeconds {
|
||||
case 24 * 60 * 60:
|
||||
return "24h"
|
||||
case 7 * 24 * 60 * 60:
|
||||
return "7d"
|
||||
default:
|
||||
return (time.Duration(deltaSeconds) * time.Second).String()
|
||||
}
|
||||
}
|
||||
461
lib/server/admin_settings.go
Normal file
461
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
type adminSettingsCategoryView struct {
|
||||
Key string
|
||||
Label string
|
||||
Icon string
|
||||
Count int
|
||||
Rows []adminSettingRowView
|
||||
}
|
||||
|
||||
type adminSettingRowView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
EnvName string `json:"env_name"`
|
||||
Category string `json:"category"`
|
||||
CategoryLabel string `json:"category_label"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
Source string `json:"source"`
|
||||
SourceBadge string `json:"source_badge"`
|
||||
Editable bool `json:"editable"`
|
||||
Locked bool `json:"locked"`
|
||||
HardLimit bool `json:"hard_limit"`
|
||||
Minimum int64 `json:"minimum"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type adminSettingsSaveRequest struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type adminSettingsImportRequest struct {
|
||||
Settings map[string]string `json:"settings"`
|
||||
EditableSettings map[string]string `json:"editable_settings"`
|
||||
Values map[string]string `json:"values"`
|
||||
Changes map[string]string `json:"changes"`
|
||||
}
|
||||
|
||||
type adminSettingsResetRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type adminSettingsExportResponse struct {
|
||||
Format string `json:"format"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
EditableSettings map[string]string `json:"editable_settings"`
|
||||
Rows []adminSettingRowView `json:"rows"`
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||
rows, categories := app.buildAdminSettingsRows()
|
||||
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "settings",
|
||||
"Rows": rows,
|
||||
"Categories": categories,
|
||||
"RowsJSON": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
|
||||
var request adminSettingsSaveRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, warnings, err := app.applySettingsOverrideSet(request.Values)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
|
||||
var request adminSettingsImportRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
|
||||
return
|
||||
}
|
||||
|
||||
values := request.Values
|
||||
if len(values) == 0 {
|
||||
values = request.Settings
|
||||
}
|
||||
if len(values) == 0 {
|
||||
values = request.EditableSettings
|
||||
}
|
||||
if len(values) == 0 {
|
||||
values = request.Changes
|
||||
}
|
||||
if len(values) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
|
||||
return
|
||||
}
|
||||
|
||||
editable := map[string]bool{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = true
|
||||
}
|
||||
filtered := make(map[string]string, len(values))
|
||||
warnings := make([]string, 0)
|
||||
for key, value := range values {
|
||||
if editable[key] {
|
||||
filtered[key] = value
|
||||
continue
|
||||
}
|
||||
if _, found := config.Definition(key); found {
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||
}
|
||||
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||
return
|
||||
}
|
||||
for key, value := range currentOverrides {
|
||||
if _, exists := filtered[key]; !exists {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, applyWarnings...)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
||||
var request adminSettingsResetRequest
|
||||
_ = ctx.ShouldBindJSON(&request)
|
||||
|
||||
defs := config.EditableDefinitions()
|
||||
overrideSet := make(map[string]string, len(defs))
|
||||
targetKeys := map[string]bool{}
|
||||
for _, key := range request.Keys {
|
||||
targetKeys[key] = true
|
||||
}
|
||||
|
||||
if len(targetKeys) == 0 {
|
||||
for _, def := range defs {
|
||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||
}
|
||||
} else {
|
||||
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
||||
return
|
||||
}
|
||||
for key, value := range currentOverrides {
|
||||
overrideSet[key] = value
|
||||
}
|
||||
for _, def := range defs {
|
||||
if targetKeys[def.Key] {
|
||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": "Editable settings reset to application defaults",
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
|
||||
}
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
|
||||
overrideSet := make(map[string]string, len(values))
|
||||
warnings := make([]string, 0)
|
||||
editable := map[string]config.SettingDefinition{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = def
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(values[key])
|
||||
def, ok := editable[key]
|
||||
if !ok {
|
||||
if _, found := config.Definition(key); found {
|
||||
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||
continue
|
||||
}
|
||||
if value == "" && def.Type != config.SettingTypeText {
|
||||
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
|
||||
}
|
||||
overrideSet[key] = value
|
||||
}
|
||||
|
||||
nextCfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
app.config = nextCfg
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
return rows, warnings, nil
|
||||
}
|
||||
|
||||
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
|
||||
settings := make(map[string]string, len(rows))
|
||||
editable := make(map[string]string)
|
||||
for _, row := range rows {
|
||||
settings[row.Key] = row.Value
|
||||
if row.Editable && !row.Locked {
|
||||
editable[row.Key] = row.Value
|
||||
}
|
||||
}
|
||||
return adminSettingsExportResponse{
|
||||
Format: "warpbox.settings.export.v1",
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Settings: settings,
|
||||
EditableSettings: editable,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
|
||||
cfgRows := app.config.SettingRows()
|
||||
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
|
||||
for _, row := range cfgRows {
|
||||
rows = append(rows, app.makeDefinitionSettingRow(row))
|
||||
}
|
||||
rows = append(rows,
|
||||
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
|
||||
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
|
||||
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
|
||||
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
|
||||
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
|
||||
)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Category == rows[j].Category {
|
||||
return rows[i].Label < rows[j].Label
|
||||
}
|
||||
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
|
||||
})
|
||||
|
||||
categoryMeta := settingsCategoryMeta()
|
||||
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
|
||||
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
|
||||
categories = append(categories, allCategory)
|
||||
|
||||
grouped := map[string][]adminSettingRowView{}
|
||||
for _, row := range rows {
|
||||
grouped[row.Category] = append(grouped[row.Category], row)
|
||||
}
|
||||
|
||||
for _, meta := range categoryMeta {
|
||||
categories = append(categories, adminSettingsCategoryView{
|
||||
Key: meta.Key,
|
||||
Label: meta.Label,
|
||||
Icon: meta.Icon,
|
||||
Count: len(grouped[meta.Key]),
|
||||
Rows: grouped[meta.Key],
|
||||
})
|
||||
}
|
||||
|
||||
return rows, categories
|
||||
}
|
||||
|
||||
func boolString(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
|
||||
def := row.Definition
|
||||
locked := !def.Editable || def.HardLimit
|
||||
source := string(row.Source)
|
||||
sourceBadge := source
|
||||
if locked {
|
||||
sourceBadge = "hard env"
|
||||
}
|
||||
return adminSettingRowView{
|
||||
Key: def.Key,
|
||||
Label: def.Label,
|
||||
EnvName: def.EnvName,
|
||||
Category: settingsCategoryForKey(def.Key),
|
||||
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
|
||||
Type: string(def.Type),
|
||||
Value: row.Value,
|
||||
DefaultValue: app.config.DefaultValue(def.Key),
|
||||
Source: source,
|
||||
SourceBadge: sourceBadge,
|
||||
Editable: def.Editable && !def.HardLimit,
|
||||
Locked: locked,
|
||||
HardLimit: def.HardLimit,
|
||||
Minimum: def.Minimum,
|
||||
Description: settingsDescription(def.Key),
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
|
||||
return adminSettingRowView{
|
||||
Key: key,
|
||||
Label: label,
|
||||
EnvName: envName,
|
||||
Category: category,
|
||||
CategoryLabel: settingsCategoryLabel(category),
|
||||
Type: rowType,
|
||||
Value: value,
|
||||
DefaultValue: "",
|
||||
Source: "environment",
|
||||
SourceBadge: "hard env",
|
||||
Editable: false,
|
||||
Locked: true,
|
||||
HardLimit: true,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
type settingsCategoryInfo struct {
|
||||
Key string
|
||||
Label string
|
||||
Icon string
|
||||
}
|
||||
|
||||
func settingsCategoryMeta() []settingsCategoryInfo {
|
||||
return []settingsCategoryInfo{
|
||||
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||
{Key: "api", Label: "API", Icon: "{ }"},
|
||||
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||
{Key: "workers", Label: "Workers", Icon: "⚙"},
|
||||
}
|
||||
}
|
||||
|
||||
func settingsCategoryLabel(key string) string {
|
||||
for _, meta := range settingsCategoryMeta() {
|
||||
if meta.Key == key {
|
||||
return meta.Label
|
||||
}
|
||||
}
|
||||
return "General"
|
||||
}
|
||||
|
||||
func settingsCategoryRank(key string) int {
|
||||
for index, meta := range settingsCategoryMeta() {
|
||||
if meta.Key == key {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(settingsCategoryMeta()) + 1
|
||||
}
|
||||
|
||||
func settingsCategoryForKey(key string) string {
|
||||
switch key {
|
||||
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||
return "uploads"
|
||||
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||
return "downloads"
|
||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||
return "retention"
|
||||
case config.SettingSessionTTLSeconds:
|
||||
return "accounts"
|
||||
case config.SettingAPIEnabled:
|
||||
return "api"
|
||||
case config.SettingDataDir:
|
||||
return "storage"
|
||||
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||
return "workers"
|
||||
default:
|
||||
return "accounts"
|
||||
}
|
||||
}
|
||||
|
||||
func settingsDescription(key string) string {
|
||||
descriptions := map[string]string{
|
||||
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
|
||||
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
|
||||
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
|
||||
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
|
||||
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
|
||||
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
|
||||
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
|
||||
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
||||
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
||||
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
||||
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling applied to future requests across the whole app.",
|
||||
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling applied to future requests across the whole app.",
|
||||
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling used by future account-aware flows.",
|
||||
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling used by future account-aware flows.",
|
||||
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
||||
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
258
lib/server/admin_settings_test.go
Normal file
258
lib/server/admin_settings_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
func TestAdminSettingsRequiresAuth(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect, got %q", location)
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatal("expected app setup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsPageRenders(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "WarpBox Settings") {
|
||||
t.Fatalf("expected settings page title, got %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
|
||||
t.Fatalf("expected API env var in page body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Format string `json:"format"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if payload.Format != "warpbox.settings.export.v1" {
|
||||
t.Fatalf("unexpected export format: %q", payload.Format)
|
||||
}
|
||||
if payload.Settings[config.SettingAPIEnabled] != "false" {
|
||||
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected APIEnabled override to be applied")
|
||||
}
|
||||
if app.config.BoxPollIntervalMS != 6000 {
|
||||
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
||||
}
|
||||
|
||||
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
|
||||
}
|
||||
if overrides[config.SettingAPIEnabled] != "true" {
|
||||
t.Fatalf("expected persisted API override, got %#v", overrides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected editable import value to apply")
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if len(payload.Warnings) != 2 {
|
||||
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected reset to built-in defaults to restore APIEnabled=true")
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd returned error: %v", err)
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("Chdir returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(cwd)
|
||||
})
|
||||
clearAdminSettingsEnv(t)
|
||||
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
|
||||
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
|
||||
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
|
||||
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
t.Fatalf("EnsureDirectories returned error: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
|
||||
}
|
||||
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadHTMLTemplates returned error: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.SetHTMLTemplate(htmlTemplates)
|
||||
admin := router.Group("/admin")
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
protected := router.Group("/admin", app.adminAuthMiddleware)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.GET("/settings/export", app.handleAdminSettingsExport)
|
||||
protected.POST("/settings/save", app.handleAdminSettingsSave)
|
||||
protected.POST("/settings/import", app.handleAdminSettingsImport)
|
||||
protected.POST("/settings/reset", app.handleAdminSettingsReset)
|
||||
return app, router
|
||||
}
|
||||
|
||||
func authCookie(app *App) *http.Cookie {
|
||||
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
|
||||
}
|
||||
|
||||
func clearAdminSettingsEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"WARPBOX_DATA_DIR",
|
||||
"WARPBOX_ADMIN_PASSWORD",
|
||||
"WARPBOX_ADMIN_USERNAME",
|
||||
"WARPBOX_ADMIN_EMAIL",
|
||||
"WARPBOX_ADMIN_ENABLED",
|
||||
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||
"WARPBOX_API_ENABLED",
|
||||
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
|
||||
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
|
||||
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_SESSION_TTL_SECONDS",
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
settingsOverridesPath string
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -24,10 +27,18 @@ func Run(addr string) error {
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
|
||||
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
app := &App{config: cfg}
|
||||
app := &App{config: cfg, settingsOverridesPath: overridesPath}
|
||||
|
||||
router := gin.Default()
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
@@ -56,6 +67,13 @@ func Run(addr string) error {
|
||||
AdminLogout: app.handleAdminLogout,
|
||||
AdminDashboard: app.handleAdminDashboard,
|
||||
AdminAlerts: app.handleAdminAlerts,
|
||||
AdminBoxes: app.handleAdminBoxes,
|
||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||
AdminSettings: app.handleAdminSettings,
|
||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||
AdminAuth: app.adminAuthMiddleware,
|
||||
})
|
||||
|
||||
@@ -68,7 +86,15 @@ func Run(addr string) error {
|
||||
}
|
||||
|
||||
func loadHTMLTemplates() (*template.Template, error) {
|
||||
tmpl := template.New("")
|
||||
tmpl := template.New("").Funcs(template.FuncMap{
|
||||
"toJSON": func(value any) template.JS {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return template.JS(data)
|
||||
},
|
||||
})
|
||||
for _, pattern := range []string{
|
||||
"templates/*.html",
|
||||
"templates/admin/*.html",
|
||||
|
||||
501
static/css/boxes.css
Normal file
501
static/css/boxes.css
Normal file
@@ -0,0 +1,501 @@
|
||||
.boxes-page-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boxes-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boxes-stat-card {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||
}
|
||||
|
||||
.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||
.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||
.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||
.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||
|
||||
.boxes-stat-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.boxes-stat-value {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boxes-stat-note {
|
||||
margin: 6px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: rgba(255,255,255,.65);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #a0a0a0;
|
||||
border-bottom: 1px solid #a0a0a0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-hero-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
color: #000000;
|
||||
background: #ffffcc;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #a08000;
|
||||
border-bottom: 1px solid #a08000;
|
||||
}
|
||||
|
||||
.boxes-hero-note strong {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.boxes-hero-note span {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.boxes-hero-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.boxes-hero-tag,
|
||||
.boxes-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: #f1f1f1;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boxes-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boxes-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.boxes-files-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.boxes-panel-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||
}
|
||||
|
||||
.boxes-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 22px;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.boxes-panel-sub {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.boxes-panel-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.boxes-panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||
}
|
||||
|
||||
.boxes-tool-button,
|
||||
.boxes-page-button,
|
||||
.boxes-action-button,
|
||||
.boxes-row-button {
|
||||
min-width: 62px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-tool-button.is-danger,
|
||||
.boxes-action-button.is-danger {
|
||||
color: #ffffff;
|
||||
background: #800000;
|
||||
}
|
||||
|
||||
.boxes-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.boxes-input,
|
||||
.boxes-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boxes-table-wrap {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: 460px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.boxes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.boxes-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
box-shadow: inset 0 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||
.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||
.boxes-table tbody tr:hover { background: #d8e5f8; }
|
||||
.boxes-table tbody tr.is-selected { background: #c5dcff; }
|
||||
|
||||
.boxes-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boxes-col-check { width: 34px; }
|
||||
.boxes-col-id { width: 190px; }
|
||||
.boxes-col-status { width: 84px; }
|
||||
.boxes-col-files { width: 58px; }
|
||||
.boxes-col-size { width: 76px; }
|
||||
.boxes-col-retention { width: 96px; }
|
||||
.boxes-col-expires { width: 126px; }
|
||||
.boxes-col-actions { width: 98px; }
|
||||
|
||||
.boxes-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: #f1f1f1;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-status-pill.ready { background: #def2e0; }
|
||||
.boxes-status-pill.uploading { background: #fff1c9; }
|
||||
.boxes-status-pill.attention { background: #ffe2bf; }
|
||||
.boxes-status-pill.expired { background: #ffdcdc; }
|
||||
.boxes-status-pill.consumed { background: #ead7ff; }
|
||||
.boxes-status-pill.legacy { background: #ececec; }
|
||||
|
||||
.boxes-flags-cell,
|
||||
.boxes-action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.boxes-action-cell a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.boxes-empty-state {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: #444444;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95));
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.boxes-footer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boxes-detail-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boxes-info-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.boxes-info-item {
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 6px 8px;
|
||||
background: #f5f5f5;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
.boxes-info-item strong {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.boxes-info-item span {
|
||||
min-width: 0;
|
||||
color: #222222;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.boxes-action-stack {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.boxes-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.boxes-action-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.boxes-file-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
height: 320px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.boxes-column:first-child > .boxes-panel {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.boxes-column:first-child > .boxes-panel > .boxes-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.boxes-column:first-child .boxes-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.boxes-file-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: #f8f8f8;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
.boxes-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boxes-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boxes-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: #333333;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.boxes-file-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.boxes-summary-grid,
|
||||
.boxes-content-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.boxes-column-side {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.boxes-toolbar-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.boxes-summary-grid,
|
||||
.boxes-content-grid,
|
||||
.boxes-toolbar-grid,
|
||||
.boxes-action-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.boxes-hero-note,
|
||||
.boxes-footer-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.boxes-table-wrap {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.boxes-column:first-child .boxes-table-wrap {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
510
static/css/settings.css
Normal file
510
static/css/settings.css
Normal file
@@ -0,0 +1,510 @@
|
||||
.settings-page-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-stat-card {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||
}
|
||||
|
||||
.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||
.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||
.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||
.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||
|
||||
.settings-stat-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.settings-stat-value {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.settings-stat-note {
|
||||
margin: 6px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: rgba(255,255,255,.65);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #a0a0a0;
|
||||
border-bottom: 1px solid #a0a0a0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.settings-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 238px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-sidebar-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
position: sticky;
|
||||
top: 48px;
|
||||
}
|
||||
|
||||
.settings-workbench {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-panel,
|
||||
.settings-hero-panel {
|
||||
min-width: 0;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-panel-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||
}
|
||||
|
||||
.settings-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 22px;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.settings-panel-sub {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.settings-panel-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||
}
|
||||
|
||||
.settings-hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58));
|
||||
}
|
||||
|
||||
.settings-hero-copy h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-hero-copy p {
|
||||
margin: 0;
|
||||
color: #222222;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.settings-hero-legend {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #222222;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.settings-search {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-search label {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.settings-input,
|
||||
.settings-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-input,
|
||||
.settings-select {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.settings-category-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.settings-category-button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 6px;
|
||||
color: #000000;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-category-button.is-active {
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
border-top-color: #000000;
|
||||
border-left-color: #000000;
|
||||
border-right-color: #ffffff;
|
||||
border-bottom-color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-category-count,
|
||||
.settings-dirty-chip,
|
||||
.settings-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: #f1f1f1;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.settings-category-button.is-active .settings-category-count {
|
||||
color: #000000;
|
||||
background: #ffffcc;
|
||||
}
|
||||
|
||||
.settings-dirty-chip {
|
||||
min-width: 78px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-dirty-chip.is-dirty {
|
||||
background: #ffffcc;
|
||||
border: 3px solid transparent;
|
||||
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
|
||||
}
|
||||
|
||||
.badge-default { background: #ececec; }
|
||||
.badge-env { background: #c7d8f2; }
|
||||
.badge-db { background: #d2efcf; }
|
||||
.badge-hard { background: #ffd9d9; }
|
||||
|
||||
.settings-tool-button,
|
||||
.settings-mini-button,
|
||||
.settings-popup-close {
|
||||
min-width: 64px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.settings-action-summary {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
color: #000000;
|
||||
background: #ffffcc;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #a08000;
|
||||
border-bottom: 1px solid #a08000;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.settings-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-group-title {
|
||||
min-height: 28px;
|
||||
padding: 6px 8px;
|
||||
color: #000000;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.settings-table-wrap {
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
color: #000000;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.settings-table th,
|
||||
.settings-table td {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
}
|
||||
|
||||
.settings-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #dfdfdf;
|
||||
box-shadow: inset 0 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||
.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||
.setting-row.is-locked { color: #555555; background: #efefef; }
|
||||
.setting-row.is-hidden { display: none; }
|
||||
.setting-row.is-invalid { background: #fff1c9; }
|
||||
|
||||
.setting-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-meta strong {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.setting-meta code {
|
||||
color: #1b325f;
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
color: #444444;
|
||||
font-size: 11px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(0,0,0,.35);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.settings-modal-backdrop.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-popup {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
display: none;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 2px solid #ffffff;
|
||||
border-left: 2px solid #ffffff;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
box-shadow: 6px 6px 0 rgba(0,0,0,.35);
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
.settings-popup.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-popup-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 6px;
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
}
|
||||
|
||||
.settings-popup-body {
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.settings-popup-body p,
|
||||
.settings-popup-body ul {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.settings-summary-grid,
|
||||
.settings-main-grid,
|
||||
.settings-hero-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-sidebar-panel,
|
||||
.settings-workbench {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-summary-grid,
|
||||
.settings-main-grid,
|
||||
.settings-hero-panel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-category-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
526
static/js/admin/boxes.js
Normal file
526
static/js/admin/boxes.js
Normal file
@@ -0,0 +1,526 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||
close() {
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||
item.classList.remove("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toastTarget = document.getElementById("toast");
|
||||
const dataNode = document.getElementById("boxes-data");
|
||||
const tableBody = document.getElementById("boxes-table-body");
|
||||
const emptyState = document.getElementById("boxes-empty-state");
|
||||
const searchInput = document.getElementById("boxes-search");
|
||||
const statusFilter = document.getElementById("boxes-status-filter");
|
||||
const flagFilter = document.getElementById("boxes-flag-filter");
|
||||
const sortFilter = document.getElementById("boxes-sort");
|
||||
const pageSizeFilter = document.getElementById("boxes-page-size");
|
||||
const selectAll = document.getElementById("boxes-select-all");
|
||||
const prevPageButton = document.getElementById("boxes-prev-page");
|
||||
const nextPageButton = document.getElementById("boxes-next-page");
|
||||
const pageLabel = document.getElementById("boxes-page-label");
|
||||
const rangeLabel = document.getElementById("boxes-range-label");
|
||||
const selectedLabel = document.getElementById("boxes-selected-label");
|
||||
const footerSummary = document.getElementById("boxes-footer-summary");
|
||||
const detailFileList = document.getElementById("detail-file-list");
|
||||
|
||||
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
|
||||
|
||||
const statEls = {
|
||||
total: document.querySelector("[data-stat-total]"),
|
||||
ready: document.querySelector("[data-stat-ready]"),
|
||||
uploading: document.querySelector("[data-stat-uploading]"),
|
||||
expired: document.querySelector("[data-stat-expired]")
|
||||
};
|
||||
|
||||
const detailEls = {
|
||||
boxId: document.getElementById("detail-box-id"),
|
||||
status: document.getElementById("detail-status"),
|
||||
created: document.getElementById("detail-created"),
|
||||
expires: document.getElementById("detail-expires"),
|
||||
retention: document.getElementById("detail-retention"),
|
||||
files: document.getElementById("detail-files"),
|
||||
size: document.getElementById("detail-size"),
|
||||
flags: document.getElementById("detail-flags"),
|
||||
open: document.getElementById("detail-open"),
|
||||
zip: document.getElementById("detail-zip")
|
||||
};
|
||||
|
||||
function showToast(message, type = "info", duration = 2200) {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
|
||||
return;
|
||||
}
|
||||
if (!toastTarget) return;
|
||||
toastTarget.textContent = message;
|
||||
toastTarget.classList.add("is-visible");
|
||||
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
|
||||
}
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
boxes: parseData(),
|
||||
selected: new Set(),
|
||||
activeId: null,
|
||||
page: 1
|
||||
};
|
||||
|
||||
function pageSize() {
|
||||
return Number(pageSizeFilter.value || 10);
|
||||
}
|
||||
|
||||
function allBoxes() {
|
||||
return state.boxes.slice();
|
||||
}
|
||||
|
||||
function sortBoxes(boxes) {
|
||||
const sorted = boxes.slice();
|
||||
switch (sortFilter.value) {
|
||||
case "name":
|
||||
sorted.sort((a, b) => a.id.localeCompare(b.id));
|
||||
break;
|
||||
case "largest":
|
||||
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
|
||||
break;
|
||||
case "expires":
|
||||
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function compareSizeLabel(left, right) {
|
||||
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
|
||||
}
|
||||
|
||||
function sizeLabelToBytes(label) {
|
||||
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
|
||||
if (!match) return 0;
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
|
||||
return value * (map[unit] || 1);
|
||||
}
|
||||
|
||||
function compareExpiry(left, right) {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function filteredBoxes() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const status = statusFilter.value;
|
||||
const flag = flagFilter.value;
|
||||
|
||||
return sortBoxes(allBoxes().filter((box) => {
|
||||
const matchesSearch = !query || String(box.search_text || "").includes(query);
|
||||
const matchesStatus = status === "all" || box.status === status;
|
||||
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
|
||||
return matchesSearch && matchesStatus && matchesFlag;
|
||||
}));
|
||||
}
|
||||
|
||||
function pagedBoxes(boxes) {
|
||||
const size = pageSize();
|
||||
const pages = Math.max(1, Math.ceil(boxes.length / size));
|
||||
if (state.page > pages) state.page = pages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
const start = (state.page - 1) * size;
|
||||
return {
|
||||
items: boxes.slice(start, start + size),
|
||||
start,
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
function selectedBoxes() {
|
||||
return allBoxes().filter((box) => state.selected.has(box.id));
|
||||
}
|
||||
|
||||
function currentActiveBox() {
|
||||
const boxes = allBoxes();
|
||||
return boxes.find((box) => box.id === state.activeId) || null;
|
||||
}
|
||||
|
||||
function ensureActiveBox(filtered) {
|
||||
if (filtered.length === 0) {
|
||||
state.activeId = null;
|
||||
return null;
|
||||
}
|
||||
if (!filtered.some((box) => box.id === state.activeId)) {
|
||||
state.activeId = filtered[0].id;
|
||||
}
|
||||
return filtered.find((box) => box.id === state.activeId) || filtered[0];
|
||||
}
|
||||
|
||||
function renderSummary(filtered) {
|
||||
const total = filtered.length;
|
||||
const ready = filtered.filter((box) => box.status === "ready").length;
|
||||
const uploading = filtered.filter((box) => box.status === "uploading").length;
|
||||
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
|
||||
statEls.total.textContent = String(total);
|
||||
statEls.ready.textContent = String(ready);
|
||||
statEls.uploading.textContent = String(uploading);
|
||||
statEls.expired.textContent = String(expired);
|
||||
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const filtered = filteredBoxes();
|
||||
const active = ensureActiveBox(filtered);
|
||||
const page = pagedBoxes(filtered);
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
|
||||
emptyState.hidden = page.items.length !== 0;
|
||||
|
||||
const startIndex = filtered.length ? page.start + 1 : 0;
|
||||
const endIndex = page.start + page.items.length;
|
||||
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
|
||||
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
|
||||
prevPageButton.disabled = state.page <= 1;
|
||||
nextPageButton.disabled = state.page >= page.pages;
|
||||
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||
|
||||
renderSummary(filtered);
|
||||
renderDetails(active);
|
||||
}
|
||||
|
||||
function buildRow(box) {
|
||||
const row = document.createElement("tr");
|
||||
if (box.id === state.activeId) row.classList.add("is-selected");
|
||||
|
||||
row.innerHTML = `
|
||||
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
|
||||
<td title="${escapeAttr(box.id)}">${box.id}</td>
|
||||
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
|
||||
<td>${box.complete_files}/${box.file_count}</td>
|
||||
<td>${box.total_size_label}</td>
|
||||
<td>${box.retention_label || "Not set"}</td>
|
||||
<td>${box.expires_at_label || "Not set"}</td>
|
||||
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
|
||||
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
|
||||
`;
|
||||
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
|
||||
state.activeId = box.id;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
|
||||
if (event.target.checked) {
|
||||
state.selected.add(box.id);
|
||||
} else {
|
||||
state.selected.delete(box.id);
|
||||
}
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
syncSelectAllForPage();
|
||||
});
|
||||
|
||||
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
|
||||
state.activeId = box.id;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFlags(flags) {
|
||||
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
|
||||
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderRowActions(box) {
|
||||
const parts = [
|
||||
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
|
||||
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
|
||||
];
|
||||
if (box.zip_available && box.zip_url) {
|
||||
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function renderDetails(box) {
|
||||
if (!box) {
|
||||
detailEls.boxId.textContent = "-";
|
||||
detailEls.status.textContent = "-";
|
||||
detailEls.created.textContent = "-";
|
||||
detailEls.expires.textContent = "-";
|
||||
detailEls.retention.textContent = "-";
|
||||
detailEls.files.textContent = "-";
|
||||
detailEls.size.textContent = "-";
|
||||
detailEls.flags.textContent = "-";
|
||||
detailEls.open.href = "#";
|
||||
detailEls.zip.href = "#";
|
||||
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
detailEls.boxId.textContent = box.id;
|
||||
detailEls.status.textContent = box.status_label;
|
||||
detailEls.created.textContent = box.created_at_label || "Not set";
|
||||
detailEls.expires.textContent = box.expires_at_label || "Not set";
|
||||
detailEls.retention.textContent = box.retention_label || "Not set";
|
||||
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
|
||||
detailEls.size.textContent = box.total_size_label;
|
||||
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
|
||||
detailEls.open.href = box.open_url || "#";
|
||||
|
||||
if (box.zip_available && box.zip_url) {
|
||||
detailEls.zip.href = box.zip_url;
|
||||
detailEls.zip.removeAttribute("aria-disabled");
|
||||
detailEls.zip.style.pointerEvents = "";
|
||||
detailEls.zip.style.opacity = "";
|
||||
} else {
|
||||
detailEls.zip.href = "#";
|
||||
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||
detailEls.zip.style.pointerEvents = "none";
|
||||
detailEls.zip.style.opacity = ".55";
|
||||
}
|
||||
|
||||
renderFiles(box.files || []);
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files.length) {
|
||||
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
detailFileList.innerHTML = files.map((file) => `
|
||||
<div class="boxes-file-card">
|
||||
<div class="boxes-file-row">
|
||||
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
|
||||
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
|
||||
</div>
|
||||
<div class="boxes-file-meta">
|
||||
<span>${escapeHtml(file.size_label || "0 B")}</span>
|
||||
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
|
||||
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function syncSelectAllForPage() {
|
||||
const filtered = filteredBoxes();
|
||||
const page = pagedBoxes(filtered);
|
||||
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchInput.value = "";
|
||||
statusFilter.value = "all";
|
||||
flagFilter.value = "all";
|
||||
sortFilter.value = "newest";
|
||||
pageSizeFilter.value = "10";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function runBulkAction(action, ids, deltaSeconds = 0) {
|
||||
if (!ids.length) {
|
||||
showToast("Select one or more boxes first", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/boxes/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = payload.error || payload.message || "Action failed";
|
||||
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
|
||||
showToast(`${message}${warning}`, "error", 3200);
|
||||
return;
|
||||
}
|
||||
|
||||
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
|
||||
state.selected.clear();
|
||||
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
|
||||
state.activeId = null;
|
||||
}
|
||||
renderTable();
|
||||
|
||||
let message = payload.message || "Action complete";
|
||||
if (Array.isArray(payload.warnings) && payload.warnings.length) {
|
||||
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
|
||||
}
|
||||
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
|
||||
} catch (_) {
|
||||
showToast("Network error while updating boxes", "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIDsOrActive() {
|
||||
if (state.selected.size) return Array.from(state.selected);
|
||||
const active = currentActiveBox();
|
||||
return active ? [active.id] : [];
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
switch (command) {
|
||||
case "refresh":
|
||||
window.location.reload();
|
||||
return;
|
||||
case "export":
|
||||
exportVisibleCSV();
|
||||
showToast("Visible boxes exported");
|
||||
return;
|
||||
case "status-ready":
|
||||
statusFilter.value = "ready";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
return;
|
||||
case "status-expired":
|
||||
statusFilter.value = "expired";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
return;
|
||||
case "clear-filters":
|
||||
clearFilters();
|
||||
showToast("Filters cleared");
|
||||
return;
|
||||
case "expire":
|
||||
case "active-expire":
|
||||
await runBulkAction("expire", selectedIDsOrActive());
|
||||
return;
|
||||
case "extend-day":
|
||||
case "active-extend-day":
|
||||
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
|
||||
return;
|
||||
case "extend-week":
|
||||
case "active-extend-week":
|
||||
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
|
||||
return;
|
||||
case "delete":
|
||||
case "active-delete":
|
||||
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
||||
await runBulkAction("delete", selectedIDsOrActive());
|
||||
return;
|
||||
case "help-scope":
|
||||
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
||||
return;
|
||||
case "help-flags":
|
||||
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
|
||||
return;
|
||||
default:
|
||||
showToast(`Unknown command: ${command}`, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
function exportVisibleCSV() {
|
||||
const rows = filteredBoxes().map((box) => ([
|
||||
box.id,
|
||||
box.status_label,
|
||||
box.file_count,
|
||||
box.total_size_label,
|
||||
box.retention_label,
|
||||
box.expires_at_label,
|
||||
(box.flags || []).join("|")
|
||||
]));
|
||||
const csv = [
|
||||
["box_id", "status", "files", "size", "retention", "expires", "flags"],
|
||||
...rows
|
||||
].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "warpbox-boxes.csv";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? "");
|
||||
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
pageSizeFilter.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
const filtered = filteredBoxes();
|
||||
const page = pagedBoxes(filtered);
|
||||
page.items.forEach((box) => {
|
||||
if (selectAll.checked) state.selected.add(box.id);
|
||||
else state.selected.delete(box.id);
|
||||
});
|
||||
renderTable();
|
||||
});
|
||||
|
||||
prevPageButton?.addEventListener("click", () => {
|
||||
state.page -= 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
nextPageButton?.addEventListener("click", () => {
|
||||
state.page += 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
await runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
await runCommand("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
if (state.boxes.length > 0) {
|
||||
state.activeId = state.boxes[0].id;
|
||||
}
|
||||
renderTable();
|
||||
})();
|
||||
434
static/js/admin/settings.js
Normal file
434
static/js/admin/settings.js
Normal file
@@ -0,0 +1,434 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const rowsNode = document.getElementById("settings-rows");
|
||||
const searchInput = document.getElementById("settingsSearch");
|
||||
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
|
||||
const groups = Array.from(document.querySelectorAll(".settings-group"));
|
||||
const saveButton = document.getElementById("saveButton");
|
||||
const exportButton = document.getElementById("exportButton");
|
||||
const importButton = document.getElementById("importButton");
|
||||
const resetButton = document.getElementById("resetButton");
|
||||
const importInput = document.getElementById("settingsImportInput");
|
||||
const dirtyChip = document.getElementById("dirtyChip");
|
||||
const actionSummary = document.getElementById("actionSummary");
|
||||
const visibleCount = document.getElementById("visibleCount");
|
||||
const editableCount = document.getElementById("editableCount");
|
||||
const unsavedCount = document.getElementById("unsavedCount");
|
||||
const lockedCount = document.getElementById("lockedCount");
|
||||
const statusLeft = document.getElementById("statusLeft");
|
||||
const statusMiddle = document.getElementById("statusMiddle");
|
||||
const statusRight = document.getElementById("statusRight");
|
||||
const popupClose = document.getElementById("doc-popup-close");
|
||||
const toastTarget = document.getElementById("toast");
|
||||
|
||||
if (!rowsNode || !searchInput || !saveButton) return;
|
||||
|
||||
const state = {
|
||||
currentCategory: "all",
|
||||
showChangedOnly: false,
|
||||
showLockedOnly: false
|
||||
};
|
||||
|
||||
function parseRows() {
|
||||
try {
|
||||
return JSON.parse(rowsNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const rowData = parseRows().reduce((map, row) => {
|
||||
map[row.key] = row;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
|
||||
element: row,
|
||||
input: row.querySelector(".setting-input"),
|
||||
hint: row.querySelector('[data-role="hint"]'),
|
||||
badge: row.querySelector('[data-role="source-badge"]'),
|
||||
key: row.dataset.key,
|
||||
label: row.dataset.label,
|
||||
category: row.dataset.category,
|
||||
envName: row.dataset.envName,
|
||||
type: row.dataset.type,
|
||||
minimum: Number(row.dataset.minimum || 0),
|
||||
locked: row.classList.contains("is-locked")
|
||||
}));
|
||||
|
||||
function showToast(message, type = "info", duration = 2400) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
function currentValue(row) {
|
||||
if (!row.input) return row.element.dataset.original || "";
|
||||
return String(row.input.value ?? "").trim();
|
||||
}
|
||||
|
||||
function isDirty(row) {
|
||||
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
|
||||
}
|
||||
|
||||
function validateRow(row) {
|
||||
if (row.locked || !row.input) {
|
||||
row.element.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = currentValue(row);
|
||||
let valid = true;
|
||||
|
||||
if (row.type === "int" || row.type === "int64") {
|
||||
if (!/^\d+$/.test(value)) valid = false;
|
||||
else if (Number(value) < row.minimum) valid = false;
|
||||
} else if (row.type === "bool") {
|
||||
valid = value === "true" || value === "false";
|
||||
}
|
||||
|
||||
row.element.classList.toggle("is-invalid", !valid);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row) {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
const data = [
|
||||
row.label,
|
||||
row.envName,
|
||||
row.element.dataset.description,
|
||||
row.key
|
||||
].join(" ").toLowerCase();
|
||||
return data.includes(query);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
let visible = 0;
|
||||
|
||||
groups.forEach((group) => {
|
||||
let groupVisible = 0;
|
||||
group.querySelectorAll(".setting-row").forEach((node) => {
|
||||
const row = rows.find((item) => item.element === node);
|
||||
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
|
||||
const searchMatch = rowMatchesSearch(row);
|
||||
const changedMatch = !state.showChangedOnly || isDirty(row);
|
||||
const lockedMatch = !state.showLockedOnly || row.locked;
|
||||
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
|
||||
node.classList.toggle("is-hidden", !show);
|
||||
if (show) {
|
||||
visible += 1;
|
||||
groupVisible += 1;
|
||||
}
|
||||
});
|
||||
group.hidden = groupVisible === 0;
|
||||
});
|
||||
|
||||
visibleCount.textContent = String(visible);
|
||||
statusMiddle.textContent = `category: ${state.currentCategory}`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
let dirty = 0;
|
||||
let editable = 0;
|
||||
let locked = 0;
|
||||
let invalid = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.locked) locked += 1;
|
||||
else editable += 1;
|
||||
if (isDirty(row)) dirty += 1;
|
||||
if (!validateRow(row)) invalid += 1;
|
||||
});
|
||||
|
||||
editableCount.textContent = String(editable);
|
||||
lockedCount.textContent = String(locked);
|
||||
unsavedCount.textContent = String(dirty);
|
||||
dirtyChip.textContent = `${dirty} unsaved`;
|
||||
dirtyChip.classList.toggle("is-dirty", dirty > 0);
|
||||
saveButton.disabled = dirty === 0 || invalid > 0;
|
||||
|
||||
if (invalid > 0) {
|
||||
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
|
||||
statusLeft.textContent = "Invalid values";
|
||||
statusRight.textContent = "fix before save";
|
||||
} else if (dirty > 0) {
|
||||
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
|
||||
statusLeft.textContent = "Unsaved changes";
|
||||
statusRight.textContent = "draft ready";
|
||||
} else {
|
||||
actionSummary.textContent = "No unsaved changes.";
|
||||
statusLeft.textContent = "No unsaved changes";
|
||||
statusRight.textContent = "admin only";
|
||||
}
|
||||
}
|
||||
|
||||
function updateView() {
|
||||
updateStats();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function setCategory(category) {
|
||||
state.currentCategory = category;
|
||||
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function draftValues() {
|
||||
const values = {};
|
||||
rows.forEach((row) => {
|
||||
if (!row.locked) values[row.key] = currentValue(row);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function updateRowFromPayload(payload) {
|
||||
const row = rows.find((item) => item.key === payload.key);
|
||||
if (!row) return;
|
||||
|
||||
row.element.dataset.original = payload.value;
|
||||
row.element.dataset.default = payload.default_value || "";
|
||||
row.element.dataset.source = payload.source || "default";
|
||||
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
|
||||
row.element.dataset.description = payload.description || "";
|
||||
row.element.dataset.minimum = String(payload.minimum || 0);
|
||||
row.element.classList.toggle("is-locked", Boolean(payload.locked));
|
||||
row.locked = Boolean(payload.locked);
|
||||
row.minimum = Number(payload.minimum || 0);
|
||||
|
||||
if (row.input) {
|
||||
row.input.value = payload.value ?? "";
|
||||
row.input.disabled = Boolean(payload.locked);
|
||||
}
|
||||
if (row.hint) {
|
||||
row.hint.textContent = payload.locked
|
||||
? "Locked by environment or hard runtime implication."
|
||||
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
||||
}
|
||||
if (row.badge) {
|
||||
row.badge.textContent = payload.source_badge || payload.source || "default";
|
||||
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
|
||||
}
|
||||
rowData[payload.key] = payload;
|
||||
}
|
||||
|
||||
function badgeClass(source) {
|
||||
if (source === "default") return "badge-default";
|
||||
if (source === "environment") return "badge-env";
|
||||
if (source === "db override") return "badge-db";
|
||||
return "badge-hard";
|
||||
}
|
||||
|
||||
function hydrateRows(payloadRows) {
|
||||
if (!Array.isArray(payloadRows)) return;
|
||||
payloadRows.forEach(updateRowFromPayload);
|
||||
updateView();
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
const payload = await postJSON("/admin/settings/save", { values: draftValues() });
|
||||
hydrateRows(payload.rows);
|
||||
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
|
||||
try {
|
||||
const payload = await postJSON("/admin/settings/reset", {});
|
||||
hydrateRows(payload.rows);
|
||||
showToast(payload.message || "Defaults restored", "success");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
try {
|
||||
const response = await fetch("/admin/settings/export");
|
||||
if (!response.ok) throw new Error("Could not export settings");
|
||||
const payload = await response.json();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Settings JSON exported");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function importSettingsFile(file) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
const result = await postJSON("/admin/settings/import", payload);
|
||||
hydrateRows(result.rows);
|
||||
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not import settings JSON", "error", 3200);
|
||||
} finally {
|
||||
importInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function discardUnsaved() {
|
||||
rows.forEach((row) => {
|
||||
if (!row.input) return;
|
||||
row.input.value = row.element.dataset.original || "";
|
||||
});
|
||||
updateView();
|
||||
showToast("Unsaved changes discarded");
|
||||
}
|
||||
|
||||
function explainSources() {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
"Setting Sources",
|
||||
`
|
||||
<ul>
|
||||
<li><strong>default</strong>: built-in application value.</li>
|
||||
<li><strong>environment</strong>: loaded from an environment variable.</li>
|
||||
<li><strong>db override</strong>: saved from the admin settings page.</li>
|
||||
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
|
||||
</ul>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function explainReset() {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
"Reset Behavior",
|
||||
`
|
||||
<p>Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.</p>
|
||||
<p>Environment-only settings stay locked and unchanged.</p>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function showRowInfo(row) {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
row.label,
|
||||
`
|
||||
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
|
||||
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
|
||||
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
|
||||
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
switch (command) {
|
||||
case "save":
|
||||
await saveChanges();
|
||||
return;
|
||||
case "export":
|
||||
await exportSettings();
|
||||
return;
|
||||
case "import":
|
||||
importInput.click();
|
||||
return;
|
||||
case "discard":
|
||||
discardUnsaved();
|
||||
return;
|
||||
case "show-all":
|
||||
state.showChangedOnly = false;
|
||||
state.showLockedOnly = false;
|
||||
applyFilters();
|
||||
showToast("Showing all matching settings");
|
||||
return;
|
||||
case "show-changed":
|
||||
state.showChangedOnly = !state.showChangedOnly;
|
||||
if (state.showChangedOnly) state.showLockedOnly = false;
|
||||
applyFilters();
|
||||
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
|
||||
return;
|
||||
case "show-locked":
|
||||
state.showLockedOnly = !state.showLockedOnly;
|
||||
if (state.showLockedOnly) state.showChangedOnly = false;
|
||||
applyFilters();
|
||||
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
|
||||
return;
|
||||
case "reset-defaults":
|
||||
await resetDefaults();
|
||||
return;
|
||||
case "reload":
|
||||
window.location.reload();
|
||||
return;
|
||||
case "legend":
|
||||
explainSources();
|
||||
return;
|
||||
case "reset-help":
|
||||
explainReset();
|
||||
return;
|
||||
default:
|
||||
showToast(`Unknown command: ${command}`, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
||||
row.element.querySelector(".row-reset")?.addEventListener("click", () => {
|
||||
if (row.locked || !row.input) return;
|
||||
row.input.value = row.element.dataset.default || row.element.dataset.original || "";
|
||||
updateView();
|
||||
});
|
||||
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", applyFilters);
|
||||
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
|
||||
saveButton.addEventListener("click", saveChanges);
|
||||
exportButton.addEventListener("click", exportSettings);
|
||||
importButton.addEventListener("click", () => importInput.click());
|
||||
resetButton.addEventListener("click", resetDefaults);
|
||||
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
|
||||
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
await runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
await saveChanges();
|
||||
}
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
menuController.close();
|
||||
window.WarpBoxUI?.closePopup?.();
|
||||
}
|
||||
});
|
||||
|
||||
updateView();
|
||||
})();
|
||||
245
templates/admin/boxes.html
Normal file
245
templates/admin/boxes.html
Normal file
@@ -0,0 +1,245 @@
|
||||
{{ define "admin/boxes.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WarpBox Admin Boxes</title>
|
||||
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/css/window.css">
|
||||
<link rel="stylesheet" href="/static/css/components/buttons.css">
|
||||
<link rel="stylesheet" href="/static/css/components/toast.css">
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
<link rel="stylesheet" href="/static/css/boxes.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-shell">
|
||||
<div class="admin-frame">
|
||||
{{ template "admin/header.html" . }}
|
||||
|
||||
<div class="win98-window admin-workspace-window" role="main">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||
<h1>WarpBox Boxes</h1>
|
||||
</div>
|
||||
<div class="win98-window-controls" aria-hidden="true">
|
||||
<button class="win98-control" type="button">_</button>
|
||||
<button class="win98-control" type="button">□</button>
|
||||
<button class="win98-control" type="button">x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="menu-bar" aria-label="Boxes toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></button>
|
||||
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="status-ready"><span>V</span><span>Show ready only</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="status-expired"><span>X</span><span>Show expired only</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="clear-filters"><span>C</span><span>Clear filters</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Boxes</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="expire"><span>!</span><span>Expire selected now</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="extend-day"><span>+</span><span>Extend selected by 24h</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="extend-week"><span>7</span><span>Extend selected by 7d</span><span></span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Help</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="help-scope"><span>?</span><span>Ownership scope note</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="help-flags"><span>F</span><span>Flag meanings</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="admin-workspace-body boxes-page-body">
|
||||
<section class="boxes-summary-grid" aria-label="Boxes summary">
|
||||
<article class="boxes-stat-card is-info">
|
||||
<p class="boxes-stat-label">Total boxes</p>
|
||||
<p class="boxes-stat-value" data-stat-total>0</p>
|
||||
<p class="boxes-stat-note">All stored manifests and legacy boxes</p>
|
||||
</article>
|
||||
<article class="boxes-stat-card is-ok">
|
||||
<p class="boxes-stat-label">Ready</p>
|
||||
<p class="boxes-stat-value" data-stat-ready>0</p>
|
||||
<p class="boxes-stat-note">Complete and still available</p>
|
||||
</article>
|
||||
<article class="boxes-stat-card is-warning">
|
||||
<p class="boxes-stat-label">Uploading</p>
|
||||
<p class="boxes-stat-value" data-stat-uploading>0</p>
|
||||
<p class="boxes-stat-note">Still waiting on files</p>
|
||||
</article>
|
||||
<article class="boxes-stat-card is-danger">
|
||||
<p class="boxes-stat-label">Expired / consumed</p>
|
||||
<p class="boxes-stat-value" data-stat-expired>0</p>
|
||||
<p class="boxes-stat-note">Needs cleanup or review</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="boxes-hero-note">
|
||||
<div>
|
||||
<strong>Scope note.</strong>
|
||||
<span>This page lists real stored boxes and real file state. Per-user ownership scoping is still pending backend account data.</span>
|
||||
</div>
|
||||
<div class="boxes-hero-tags">
|
||||
<span class="boxes-hero-tag">real data</span>
|
||||
<span class="boxes-hero-tag">real actions</span>
|
||||
<span class="boxes-hero-tag">ownership TODO</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="boxes-content-grid">
|
||||
<div class="boxes-column">
|
||||
<section class="boxes-panel">
|
||||
<div class="boxes-panel-header">
|
||||
<div class="boxes-panel-title">Box list <span class="boxes-panel-sub">search, filter, bulk actions</span></div>
|
||||
<div class="boxes-panel-tools">
|
||||
<button class="win98-button boxes-tool-button" type="button" data-command="refresh">Refresh</button>
|
||||
<button class="win98-button boxes-tool-button" type="button" data-command="export">Export CSV</button>
|
||||
<button class="win98-button boxes-tool-button" type="button" data-command="expire">Expire</button>
|
||||
<button class="win98-button boxes-tool-button" type="button" data-command="extend-day">+24h</button>
|
||||
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxes-panel-body">
|
||||
<div class="boxes-toolbar-grid">
|
||||
<input class="boxes-input" id="boxes-search" type="search" placeholder="Search box id, file name, mime, retention">
|
||||
<select class="boxes-select" id="boxes-status-filter">
|
||||
<option value="all" selected>All statuses</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="uploading">Uploading</option>
|
||||
<option value="attention">Needs review</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="consumed">Consumed</option>
|
||||
<option value="legacy">Legacy</option>
|
||||
</select>
|
||||
<select class="boxes-select" id="boxes-flag-filter">
|
||||
<option value="all" selected>All flags</option>
|
||||
<option value="protected">Protected</option>
|
||||
<option value="one-time">One-time</option>
|
||||
<option value="zip off">ZIP off</option>
|
||||
<option value="legacy">Legacy</option>
|
||||
</select>
|
||||
<select class="boxes-select" id="boxes-sort">
|
||||
<option value="newest" selected>Newest first</option>
|
||||
<option value="expires">Soonest expiry</option>
|
||||
<option value="largest">Largest size</option>
|
||||
<option value="name">Box id</option>
|
||||
</select>
|
||||
<select class="boxes-select" id="boxes-page-size">
|
||||
<option value="10" selected>10 / page</option>
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="9999">All rows</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="boxes-table-wrap">
|
||||
<table class="boxes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="boxes-col-check"><input type="checkbox" id="boxes-select-all"></th>
|
||||
<th class="boxes-col-id">Box ID</th>
|
||||
<th class="boxes-col-status">Status</th>
|
||||
<th class="boxes-col-files">Files</th>
|
||||
<th class="boxes-col-size">Size</th>
|
||||
<th class="boxes-col-retention">Retention</th>
|
||||
<th class="boxes-col-expires">Expires</th>
|
||||
<th>Flags</th>
|
||||
<th class="boxes-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="boxes-table-body"></tbody>
|
||||
</table>
|
||||
<div class="boxes-empty-state" id="boxes-empty-state" hidden>No boxes match current filters.</div>
|
||||
</div>
|
||||
|
||||
<div class="boxes-footer-bar">
|
||||
<span id="boxes-range-label">Showing 0-0 of 0</span>
|
||||
<span id="boxes-selected-label">Selected: 0</span>
|
||||
<div class="boxes-pagination">
|
||||
<button class="win98-button boxes-page-button" type="button" id="boxes-prev-page">Prev</button>
|
||||
<span id="boxes-page-label">Page 1 / 1</span>
|
||||
<button class="win98-button boxes-page-button" type="button" id="boxes-next-page">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="boxes-column boxes-column-side">
|
||||
<section class="boxes-panel">
|
||||
<div class="boxes-panel-header">
|
||||
<div class="boxes-panel-title">Box details <span class="boxes-panel-sub">selected box preview</span></div>
|
||||
</div>
|
||||
<div class="boxes-panel-body boxes-detail-body">
|
||||
<ul class="boxes-info-list">
|
||||
<li class="boxes-info-item"><strong>Box</strong><span id="detail-box-id">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Status</strong><span id="detail-status">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Created</strong><span id="detail-created">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Expires</strong><span id="detail-expires">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Retention</strong><span id="detail-retention">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Files</strong><span id="detail-files">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Size</strong><span id="detail-size">-</span></li>
|
||||
<li class="boxes-info-item"><strong>Flags</strong><span id="detail-flags">-</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="boxes-action-stack">
|
||||
<div class="boxes-action-grid">
|
||||
<a class="win98-button boxes-action-button" id="detail-open" href="#" target="_blank" rel="noreferrer">Open</a>
|
||||
<a class="win98-button boxes-action-button" id="detail-zip" href="#" target="_blank" rel="noreferrer">ZIP</a>
|
||||
</div>
|
||||
<div class="boxes-action-grid">
|
||||
<button class="win98-button boxes-action-button" type="button" data-command="active-expire">Expire now</button>
|
||||
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-day">+24h</button>
|
||||
</div>
|
||||
<div class="boxes-action-grid">
|
||||
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-week">+7d</button>
|
||||
<button class="win98-button boxes-action-button is-danger" type="button" data-command="active-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="boxes-panel boxes-files-panel">
|
||||
<div class="boxes-panel-header">
|
||||
<div class="boxes-panel-title">Files <span class="boxes-panel-sub">real file inventory</span></div>
|
||||
</div>
|
||||
<div class="boxes-panel-body">
|
||||
<div class="boxes-file-list" id="detail-file-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="status-bar admin-dashboard-statusbar">
|
||||
<span id="boxes-footer-summary">0 boxes loaded</span>
|
||||
<span id="boxes-footer-scope">scope: global admin view</span>
|
||||
<span id="boxes-footer-zip">{{ if .ZipDownloadsOn }}zip downloads enabled{{ else }}zip downloads disabled{{ end }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
|
||||
<script id="boxes-data" type="application/json">{{ toJSON .Boxes }}</script>
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/boxes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -7,8 +7,8 @@
|
||||
<nav class="admin-taskbar-nav" aria-label="Primary">
|
||||
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
|
||||
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
|
||||
<a class="admin-taskbar-button" href="/admin/dashboard#recent-boxes">Boxes</a>
|
||||
<a class="admin-taskbar-button" href="/admin/dashboard#recent-activity">Activity</a>
|
||||
<a class="admin-taskbar-button{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/boxes">Boxes</a>
|
||||
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
|
||||
</nav>
|
||||
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
||||
<a class="admin-alert-chip is-warning" href="/admin/alerts" id="topAlertChip">! 15 alerts</a>
|
||||
|
||||
241
templates/admin/settings.html
Normal file
241
templates/admin/settings.html
Normal file
@@ -0,0 +1,241 @@
|
||||
{{ define "admin/settings.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WarpBox Admin Settings</title>
|
||||
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/css/window.css">
|
||||
<link rel="stylesheet" href="/static/css/components/buttons.css">
|
||||
<link rel="stylesheet" href="/static/css/components/toast.css">
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
<link rel="stylesheet" href="/static/css/settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-shell">
|
||||
<div class="admin-frame">
|
||||
{{ template "admin/header.html" . }}
|
||||
|
||||
<div class="win98-window admin-workspace-window" role="main">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||
<h1>WarpBox Settings</h1>
|
||||
</div>
|
||||
<div class="win98-window-controls" aria-hidden="true">
|
||||
<button class="win98-control" type="button">_</button>
|
||||
<button class="win98-control" type="button">□</button>
|
||||
<button class="win98-control" type="button">x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="menu-bar" aria-label="Settings toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="save"><span>S</span><span>Save overrides</span><span class="shortcut">Ctrl+S</span></button>
|
||||
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export settings JSON</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="import"><span>I</span><span>Import settings JSON</span><span></span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="discard"><span>D</span><span>Discard unsaved changes</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="show-all"><span>A</span><span>Show all settings</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="show-changed"><span>C</span><span>Show changed only</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="show-locked"><span>L</span><span>Show locked only</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Settings</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="reset-defaults"><span>R</span><span>Reset editable to defaults</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="reload"><span>F</span><span>Reload current values</span><span class="shortcut">F5</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Help</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="legend"><span>?</span><span>Explain sources</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="reset-help"><span>!</span><span>Reset semantics</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="admin-workspace-body settings-page-body">
|
||||
<section class="settings-summary-grid" aria-label="Settings summary">
|
||||
<article class="settings-stat-card is-info">
|
||||
<p class="settings-stat-label">Visible settings</p>
|
||||
<p class="settings-stat-value" id="visibleCount">{{ len .Rows }}</p>
|
||||
<p class="settings-stat-note">Filtered by search and category</p>
|
||||
</article>
|
||||
<article class="settings-stat-card is-ok">
|
||||
<p class="settings-stat-label">Editable</p>
|
||||
<p class="settings-stat-value" id="editableCount">0</p>
|
||||
<p class="settings-stat-note">Runtime override supported</p>
|
||||
</article>
|
||||
<article class="settings-stat-card is-warning">
|
||||
<p class="settings-stat-label">Unsaved</p>
|
||||
<p class="settings-stat-value" id="unsavedCount">0</p>
|
||||
<p class="settings-stat-note">Draft changes in browser</p>
|
||||
</article>
|
||||
<article class="settings-stat-card is-danger">
|
||||
<p class="settings-stat-label">Locked</p>
|
||||
<p class="settings-stat-value" id="lockedCount">0</p>
|
||||
<p class="settings-stat-note">Environment only</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="settings-main-grid">
|
||||
<aside class="settings-sidebar-panel">
|
||||
<section class="settings-panel settings-sidebar">
|
||||
<div class="settings-panel-header">
|
||||
<div class="settings-panel-title">Categories <span class="settings-panel-sub">search and scope</span></div>
|
||||
</div>
|
||||
<div class="settings-panel-body">
|
||||
<div class="settings-search">
|
||||
<label for="settingsSearch">Search</label>
|
||||
<input class="settings-input" id="settingsSearch" type="search" placeholder="Search label, env var, description">
|
||||
</div>
|
||||
<ul class="settings-category-list" id="categoryList">
|
||||
{{ range .Categories }}
|
||||
<li>
|
||||
<button class="settings-category-button{{ if eq .Key "all" }} is-active{{ end }}" type="button" data-category="{{ .Key }}">
|
||||
<span>{{ .Icon }}</span>
|
||||
<span>{{ .Label }}</span>
|
||||
<span class="settings-category-count">{{ .Count }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="settings-workbench">
|
||||
<section class="settings-hero-panel">
|
||||
<div class="settings-hero-copy">
|
||||
<h2>Administrative runtime settings</h2>
|
||||
<p>Edit safe runtime overrides without hiding where each value came from. Hard storage and security-sensitive environment settings stay visible but locked.</p>
|
||||
</div>
|
||||
<div class="settings-hero-legend">
|
||||
<div class="settings-legend-row"><span class="settings-badge badge-default">default</span><span>Built-in application value</span></div>
|
||||
<div class="settings-legend-row"><span class="settings-badge badge-env">environment</span><span>Loaded from env</span></div>
|
||||
<div class="settings-legend-row"><span class="settings-badge badge-db">db override</span><span>Saved from admin UI</span></div>
|
||||
<div class="settings-legend-row"><span class="settings-badge badge-hard">hard env</span><span>Visible, not editable here</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel">
|
||||
<div class="settings-panel-header">
|
||||
<div class="settings-panel-title">Settings grid <span class="settings-panel-sub">edit, inspect, import, export</span></div>
|
||||
<div class="settings-panel-tools">
|
||||
<span class="settings-dirty-chip" id="dirtyChip">0 unsaved</span>
|
||||
<button class="win98-button settings-tool-button" id="exportButton" type="button">Export JSON</button>
|
||||
<button class="win98-button settings-tool-button" id="importButton" type="button">Import JSON</button>
|
||||
<button class="win98-button settings-tool-button" id="resetButton" type="button">Reset Defaults</button>
|
||||
<button class="win98-button settings-tool-button" id="saveButton" type="button" disabled>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panel-body">
|
||||
<div class="settings-action-summary" id="actionSummary">No unsaved changes.</div>
|
||||
<div class="settings-groups" id="settingsGroups">
|
||||
{{ range .Categories }}
|
||||
{{ if ne .Key "all" }}
|
||||
<section class="settings-group" data-category="{{ .Key }}">
|
||||
<div class="settings-group-title">{{ .Label }}</div>
|
||||
<div class="settings-table-wrap">
|
||||
<table class="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Source</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr class="setting-row{{ if .Locked }} is-locked{{ end }}"
|
||||
data-key="{{ .Key }}"
|
||||
data-label="{{ .Label }}"
|
||||
data-category="{{ .Category }}"
|
||||
data-type="{{ .Type }}"
|
||||
data-original="{{ .Value }}"
|
||||
data-default="{{ .DefaultValue }}"
|
||||
data-env-name="{{ .EnvName }}"
|
||||
data-source="{{ .Source }}"
|
||||
data-source-badge="{{ .SourceBadge }}"
|
||||
data-description="{{ .Description }}"
|
||||
data-minimum="{{ .Minimum }}">
|
||||
<td>
|
||||
<div class="setting-meta">
|
||||
<strong>{{ .Label }}</strong>
|
||||
<code>{{ .EnvName }}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="settings-badge{{ if eq .SourceBadge "default" }} badge-default{{ else if eq .SourceBadge "environment" }} badge-env{{ else if eq .SourceBadge "db override" }} badge-db{{ else }} badge-hard{{ end }}" data-role="source-badge">{{ .SourceBadge }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="setting-control">
|
||||
{{ if eq .Type "bool" }}
|
||||
<select class="settings-select setting-input"{{ if .Locked }} disabled{{ end }}>
|
||||
<option value="true"{{ if eq .Value "true" }} selected{{ end }}>true</option>
|
||||
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
|
||||
</select>
|
||||
{{ else }}
|
||||
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
||||
{{ end }}
|
||||
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="setting-actions">
|
||||
<button class="win98-button settings-mini-button row-reset" type="button"{{ if .Locked }} disabled{{ end }}>Reset</button>
|
||||
<button class="win98-button settings-mini-button row-info" type="button">Info</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="status-bar admin-dashboard-statusbar">
|
||||
<span id="statusLeft">No unsaved changes</span>
|
||||
<span id="statusMiddle">category: all</span>
|
||||
<span id="statusRight">admin only</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-backdrop" class="settings-modal-backdrop"></div>
|
||||
<div id="doc-popup" class="settings-popup" role="dialog" aria-modal="true" aria-labelledby="doc-popup-title">
|
||||
<div class="settings-popup-titlebar">
|
||||
<strong id="doc-popup-title">Details</strong>
|
||||
<button class="win98-button settings-popup-close" id="doc-popup-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="settings-popup-body" id="doc-popup-body"></div>
|
||||
</div>
|
||||
<input id="settingsImportInput" type="file" accept="application/json,.json" hidden>
|
||||
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script id="settings-rows" type="application/json">{{ toJSON .RowsJSON }}</script>
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user