Compare commits
3 Commits
5f3f63b710
...
d0aa86205f
| Author | SHA1 | Date | |
|---|---|---|---|
| d0aa86205f | |||
| 36d49a970e | |||
| 3844473eb3 |
@@ -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 {
|
||||
|
||||
@@ -95,6 +95,7 @@ type Config struct {
|
||||
ThumbnailBatchSize int
|
||||
ThumbnailIntervalSeconds int
|
||||
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,20 @@ type Handlers struct {
|
||||
FileStatusUpdate gin.HandlerFunc
|
||||
DirectBoxUpload gin.HandlerFunc
|
||||
LegacyUpload gin.HandlerFunc
|
||||
|
||||
AdminLogin gin.HandlerFunc
|
||||
AdminLoginPost gin.HandlerFunc
|
||||
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
|
||||
}
|
||||
|
||||
func Register(router *gin.Engine, handlers Handlers) {
|
||||
@@ -36,4 +50,20 @@ func Register(router *gin.Engine, handlers Handlers) {
|
||||
// Legacy upload routes are kept for compatibility with older clients.
|
||||
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
||||
router.POST("/upload", handlers.LegacyUpload)
|
||||
|
||||
admin := router.Group("/admin")
|
||||
admin.GET("/login", handlers.AdminLogin)
|
||||
admin.POST("/login", handlers.AdminLoginPost)
|
||||
admin.GET("/logout", handlers.AdminLogout)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
116
lib/server/admin.go
Normal file
116
lib/server/admin.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
const adminSessionCookie = "warpbox_admin_session"
|
||||
const adminSessionMarker = "1"
|
||||
|
||||
func (app *App) adminLoginEnabled() bool {
|
||||
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
|
||||
}
|
||||
|
||||
func (app *App) adminAuthMiddleware(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil || token != app.adminSessionToken() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) adminSessionToken() string {
|
||||
// A simple deterministic token derived from the admin credentials.
|
||||
// This will improve when proper user/session storage is added.
|
||||
return app.config.AdminUsername + ":" + app.config.AdminPassword
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Already logged in.
|
||||
if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/login.html", gin.H{})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
||||
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Invalid username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
secure := app.config.AdminCookieSecure
|
||||
maxAge := int(app.config.SessionTTLSeconds)
|
||||
|
||||
ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||
secure := app.config.AdminCookieSecure
|
||||
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
|
||||
dashboardEnabled := config.AdminEnabledTrue
|
||||
if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue {
|
||||
dashboardEnabled = cfgVal
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "dashboard",
|
||||
"DashboardEnabled": string(dashboardEnabled),
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminAlerts(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "alerts",
|
||||
})
|
||||
}
|
||||
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,6 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
@@ -12,7 +15,8 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
config *config.Config
|
||||
settingsOverridesPath string
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -23,13 +27,25 @@ 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()
|
||||
router.LoadHTMLGlob("templates/*.html")
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router.SetHTMLTemplate(htmlTemplates)
|
||||
|
||||
routing.Register(router, routing.Handlers{
|
||||
Index: app.handleIndex,
|
||||
@@ -45,6 +61,20 @@ func Run(addr string) error {
|
||||
FileStatusUpdate: app.handleFileStatusUpdate,
|
||||
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||
LegacyUpload: app.handleLegacyUpload,
|
||||
|
||||
AdminLogin: app.handleAdminLogin,
|
||||
AdminLoginPost: app.handleAdminLoginPost,
|
||||
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,
|
||||
})
|
||||
|
||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||
@@ -55,6 +85,30 @@ func Run(addr string) error {
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
func loadHTMLTemplates() (*template.Template, error) {
|
||||
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",
|
||||
"templates/admin/partials/*.html",
|
||||
} {
|
||||
var err error
|
||||
tmpl, err = tmpl.ParseGlob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
|
||||
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
|
||||
|
||||
738
static/css/admin.css
Normal file
738
static/css/admin.css
Normal file
@@ -0,0 +1,738 @@
|
||||
/* ===========================
|
||||
Admin Shell / Frame
|
||||
=========================== */
|
||||
.admin-shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 10px 16px 34px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-frame {
|
||||
width: min(var(--admin-frame-width, 1320px), 100%);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Admin Taskbar (top nav)
|
||||
=========================== */
|
||||
.admin-taskbar {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #000000;
|
||||
background-color: var(--w98-gray);
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.36), rgba(0,0,0,.08)), repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px);
|
||||
border-top: 2px solid #ffffff;
|
||||
border-left: 2px solid #ffffff;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 4px 4px 0 rgba(0,0,0,.45);
|
||||
padding: 3px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end);
|
||||
}
|
||||
|
||||
.admin-taskbar.is-scrolled {
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0,0,0,.55), 0 11px 0 rgba(0,0,0,.18);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.admin-taskbar.is-scrolled::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -10px;
|
||||
height: 10px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,.46), rgba(0,0,0,0));
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Start Button
|
||||
=========================== */
|
||||
.admin-start-button {
|
||||
min-width: 108px;
|
||||
height: 24px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
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: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-start-button:active {
|
||||
border-top-color: #000000;
|
||||
border-left-color: #000000;
|
||||
border-right-color: #ffffff;
|
||||
border-bottom-color: #ffffff;
|
||||
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.admin-start-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0;
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Taskbar Nav Buttons
|
||||
=========================== */
|
||||
.admin-taskbar-nav {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.admin-taskbar-button {
|
||||
height: 24px;
|
||||
min-width: 76px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-taskbar-button:active {
|
||||
border-top-color: #000000;
|
||||
border-left-color: #000000;
|
||||
border-right-color: #ffffff;
|
||||
border-bottom-color: #ffffff;
|
||||
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.admin-taskbar-button.is-active {
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
border-top-color: #000000;
|
||||
border-left-color: #000000;
|
||||
border-right-color: #ffffff;
|
||||
border-bottom-color: #ffffff;
|
||||
}
|
||||
|
||||
.admin-taskbar-button:hover {
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Taskbar Session Chips
|
||||
=========================== */
|
||||
.admin-taskbar-session {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-session-chip,
|
||||
.admin-alert-chip {
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-alert-chip.is-ok { background: #e8ffe8; border-color: #008000 #ffffff #ffffff #008000; }
|
||||
.admin-alert-chip.is-info { background: #d8e5f8; }
|
||||
.admin-alert-chip.is-warning {
|
||||
background: #ffffcc;
|
||||
border: 3px solid transparent;
|
||||
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
|
||||
}
|
||||
.admin-alert-chip.is-danger {
|
||||
color: #ffffff;
|
||||
background: #800000;
|
||||
border: 3px solid transparent;
|
||||
border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Dashboard Window
|
||||
=========================== */
|
||||
.admin-dashboard-window,
|
||||
.admin-workspace-window {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
color: #000000;
|
||||
background-color: var(--w98-gray);
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
|
||||
}
|
||||
|
||||
.admin-dashboard-window > .win98-titlebar,
|
||||
.admin-workspace-window > .win98-titlebar {
|
||||
margin: 2px 2px 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-window > .menu-bar,
|
||||
.admin-workspace-window > .menu-bar {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: 24px;
|
||||
margin: 0 2px;
|
||||
padding: 1px 6px;
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.admin-dashboard-window > .menu-bar .menu-button,
|
||||
.admin-workspace-window > .menu-bar .menu-button {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.admin-dashboard-window > .dashboard-body,
|
||||
.admin-workspace-window > .admin-workspace-body {
|
||||
flex: 1 1 auto;
|
||||
margin-top: 0;
|
||||
padding: 0 10px 10px;
|
||||
background-color: var(--w98-gray);
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.05));
|
||||
}
|
||||
|
||||
.admin-dashboard-statusbar {
|
||||
grid-template-columns: minmax(0, 1fr) 160px 210px;
|
||||
height: 28px;
|
||||
padding: 3px 4px 4px;
|
||||
background: var(--w98-gray);
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-statusbar span {
|
||||
min-height: 19px;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Menu Bar (toolbar)
|
||||
=========================== */
|
||||
.admin-menu-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-height: 24px;
|
||||
padding: 1px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.admin-menu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-menu-button {
|
||||
height: 20px;
|
||||
min-width: 54px;
|
||||
padding: 0 8px;
|
||||
color: #000000;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-menu-button:hover,
|
||||
.admin-menu-button:focus-visible {
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-menu-popup {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
padding: 2px;
|
||||
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: 3px 3px 0 rgba(0,0,0,.35);
|
||||
display: none;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.admin-menu-item.is-open .admin-menu-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-menu-action {
|
||||
width: 100%;
|
||||
min-height: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
color: #000000;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-menu-action:hover,
|
||||
.admin-menu-action:focus-visible {
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-menu-separator {
|
||||
height: 1px;
|
||||
margin: 3px 2px;
|
||||
background: #808080;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.admin-menu-action .shortcut {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.admin-menu-action:hover .shortcut {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Hero Section
|
||||
=========================== */
|
||||
.admin-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 10px;
|
||||
padding: 9px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-hero-copy h2 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 22px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.admin-hero-copy p {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.admin-hero-status {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
padding: 7px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.admin-hero-status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-status-ok { color: #008000; }
|
||||
.admin-status-warn { color: #8a6200; }
|
||||
.admin-status-danger { color: #800000; }
|
||||
|
||||
/* ===========================
|
||||
Stats Grid
|
||||
=========================== */
|
||||
.admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
position: relative;
|
||||
min-height: 122px;
|
||||
padding: 10px 11px 10px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left accent bar */
|
||||
.admin-stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 7px;
|
||||
border-left: 7px solid #000078;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Severity color states */
|
||||
.admin-stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
|
||||
.admin-stat-card.is-ok::before { border-left-color: #008000; }
|
||||
.admin-stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
|
||||
.admin-stat-card.is-info::before { border-left-color: #000078; }
|
||||
.admin-stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
|
||||
.admin-stat-card.is-warning::before { border-left-color: #ffcc00; }
|
||||
.admin-stat-card.is-danger {
|
||||
color: #000000;
|
||||
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
|
||||
}
|
||||
.admin-stat-card.is-danger::before { border-left-color: #800000; }
|
||||
|
||||
.admin-stat-label {
|
||||
margin: 0 0 6px;
|
||||
color: #333333;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-stat-value {
|
||||
margin: 0 0 7px;
|
||||
font-size: 32px;
|
||||
line-height: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-stat-note {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
color: #222222;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.admin-stat-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Main Grid / Section Windows
|
||||
=========================== */
|
||||
.admin-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-section-window {
|
||||
min-height: 0;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
|
||||
}
|
||||
|
||||
.admin-section-body {
|
||||
margin: 0 6px 6px;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Quick Actions
|
||||
=========================== */
|
||||
.admin-link-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-link-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: #000000;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.admin-link-button {
|
||||
min-width: 112px;
|
||||
height: 24px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0 10px;
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #000000;
|
||||
border-bottom: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-link-button:hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
/* Titlebar action links (Show all) */
|
||||
.titlebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.titlebar-link-button {
|
||||
height: 18px;
|
||||
min-width: 64px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 7px;
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #000000;
|
||||
border-bottom: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.titlebar-link-button:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Compact Mode
|
||||
=========================== */
|
||||
body.is-compact .admin-dashboard-body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.is-compact .admin-section-body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Responsive: Medium (tablets)
|
||||
=========================== */
|
||||
@media (max-width: 1180px) {
|
||||
.admin-taskbar {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-taskbar-session {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Responsive: Small (mobile)
|
||||
=========================== */
|
||||
@media (max-width: 760px) {
|
||||
.admin-shell {
|
||||
padding: 0 0 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-frame {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-taskbar {
|
||||
grid-template-columns: 1fr;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-start-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-taskbar-nav {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.admin-taskbar-button {
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.admin-taskbar-session {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.admin-session-chip,
|
||||
.admin-alert-chip {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-window,
|
||||
.admin-workspace-window {
|
||||
min-height: 100dvh;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-dashboard-body {
|
||||
padding: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.admin-menu-popup {
|
||||
position: fixed;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 74px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titlebar-actions {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.titlebar-link-button {
|
||||
min-width: 58px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.admin-dashboard-statusbar {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.win98-titlebar h1,
|
||||
.win98-titlebar h2 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.win98-window-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override global main layout on admin pages since admin uses its own shell */
|
||||
body:has(.admin-shell) main {
|
||||
display: contents;
|
||||
}
|
||||
394
static/css/alerts.css
Normal file
394
static/css/alerts.css
Normal file
@@ -0,0 +1,394 @@
|
||||
.alerts-page-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.alerts-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alerts-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;
|
||||
}
|
||||
|
||||
.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||
.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||
.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||
|
||||
.alerts-stat-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.alerts-stat-value {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alerts-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;
|
||||
}
|
||||
|
||||
.alerts-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.alerts-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.alerts-list-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.alerts-actions-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.alerts-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);
|
||||
}
|
||||
|
||||
.alerts-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;
|
||||
}
|
||||
|
||||
.alerts-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 22px;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.alerts-panel-sub {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.alerts-panel-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alerts-panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
background-color: #ffffff;
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||
}
|
||||
|
||||
.alerts-tool-button,
|
||||
.alerts-row-button,
|
||||
.alerts-footer-button {
|
||||
min-width: 64px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.alerts-action-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alerts-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alerts-input,
|
||||
.alerts-select,
|
||||
.alerts-textarea {
|
||||
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;
|
||||
}
|
||||
|
||||
.alerts-input,
|
||||
.alerts-select {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.alerts-table-wrap {
|
||||
height: 430px;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.alerts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.alerts-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;
|
||||
}
|
||||
|
||||
.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||
.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||
.alerts-table tbody tr:hover { background: #d8e5f8; }
|
||||
.alerts-table tbody tr.is-selected { background: #c5dcff; }
|
||||
|
||||
.alerts-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alerts-col-check { width: 34px; }
|
||||
.alerts-col-severity { width: 76px; }
|
||||
.alerts-col-status { width: 82px; }
|
||||
.alerts-col-code { width: 70px; }
|
||||
.alerts-col-time { width: 110px; }
|
||||
.alerts-col-actions { width: 88px; }
|
||||
|
||||
.alerts-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;
|
||||
}
|
||||
|
||||
.alerts-pill.low { background: #deebff; }
|
||||
.alerts-pill.medium { background: #fff2c8; }
|
||||
.alerts-pill.high { background: #ffdcdc; }
|
||||
.alerts-pill.open { background: #f2e1ff; }
|
||||
.alerts-pill.acked { background: #e2f0e2; }
|
||||
.alerts-pill.closed { background: #ececec; }
|
||||
|
||||
.alerts-info-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.alerts-info-item {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 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;
|
||||
}
|
||||
|
||||
.alerts-info-item strong {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.alerts-info-item span {
|
||||
min-width: 0;
|
||||
color: #222222;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.alerts-json-box {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
color: #b7ffc8;
|
||||
background: #050505;
|
||||
border-top: 2px solid #808080;
|
||||
border-left: 2px solid #808080;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
font-family: "MonoCraft", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.alerts-mini-note {
|
||||
margin-top: 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;
|
||||
}
|
||||
|
||||
.alerts-action-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alerts-footerbar {
|
||||
flex: 0 0 auto;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #ffffff;
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.alerts-footer-left,
|
||||
.alerts-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alerts-status-pill {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.alerts-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.alerts-content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alerts-toolbar-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.alerts-summary-grid,
|
||||
.alerts-toolbar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alerts-table-wrap {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.alerts-panel-header,
|
||||
.alerts-footerbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alerts-info-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
289
static/css/dashboard.css
Normal file
289
static/css/dashboard.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* ==============================================
|
||||
Dashboard-specific styles (shared with admin)
|
||||
Reusable across account dashboard pages
|
||||
============================================== */
|
||||
|
||||
/* Hero section */
|
||||
.dashboard-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 10px;
|
||||
padding: 9px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-copy h2 { margin: 0 0 5px; font-size: 22px; line-height: 24px; }
|
||||
.hero-copy p { margin: 0; color: #333; font-size: 13px; line-height: 15px; }
|
||||
|
||||
.hero-status {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
padding: 7px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.hero-status-row { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.status-ok { color: #008000; }
|
||||
.status-warn { color: #8a6200; }
|
||||
.status-danger { color: #800000; }
|
||||
|
||||
/* Stats grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
min-height: 122px;
|
||||
padding: 10px 11px 10px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 7px;
|
||||
border-left: 7px solid #000078;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
|
||||
.stat-card.is-ok::before { border-left-color: #008000; }
|
||||
.stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
|
||||
.stat-card.is-info::before { border-left-color: #000078; }
|
||||
.stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
|
||||
.stat-card.is-warning::before { border-left-color: #ffcc00; }
|
||||
.stat-card.is-danger {
|
||||
color: #000;
|
||||
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
|
||||
}
|
||||
.stat-card.is-danger::before { border-left-color: #800000; }
|
||||
|
||||
.stat-label { margin: 0 0 6px; color: #333; font-size: 13px; line-height: 13px; font-weight: bold; }
|
||||
.stat-value { margin: 0 0 7px; font-size: 32px; line-height: 32px; font-weight: bold; }
|
||||
.stat-note { display: flex; gap: 4px; flex-wrap: wrap; margin: 0; color: #222; font-size: 12px; line-height: 14px; }
|
||||
.stat-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main two-column grid */
|
||||
.dashboard-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-span-2 { grid-column: 1 / -1; }
|
||||
|
||||
/* Dashboard body */
|
||||
.dashboard-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Section windows */
|
||||
.section-window { min-height: 0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); }
|
||||
.section-body { margin: 0 6px 6px; padding: 8px; min-height: 0; }
|
||||
|
||||
/* Scroll panels */
|
||||
.scroll-panel { overflow: auto; background: #ffffff; border-top: 2px solid #606060; border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; }
|
||||
.alerts-scroll { height: 326px; }
|
||||
.boxes-scroll { height: 352px; }
|
||||
.activity-scroll { height: 326px; }
|
||||
|
||||
/* Alerts */
|
||||
.alert-list { display: grid; min-width: 0; }
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
min-height: 74px;
|
||||
padding: 7px;
|
||||
color: #000;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
background: #ffffff;
|
||||
}
|
||||
.alert-row:nth-child(even) { background: #f5f8ff; }
|
||||
.alert-row.is-dismissed { display: none; }
|
||||
.alert-severity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
min-height: 20px;
|
||||
padding: 2px 5px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
.alert-row[data-severity="low"] .alert-severity { color: #000078; }
|
||||
.alert-row[data-severity="medium"] .alert-severity { color: #8a6200; background: #ffffcc; }
|
||||
.alert-row[data-severity="high"] .alert-severity { color: #ffffff; background: #800000; }
|
||||
.alert-title { margin: 0 0 3px; font-weight: bold; font-size: 14px; line-height: 15px; }
|
||||
.alert-desc { margin: 0 0 3px; color: #333; font-size: 12px; line-height: 14px; }
|
||||
.alert-trace { margin: 0; color: #555; font-family: 'MonoCraft', 'Courier New', monospace; font-size: 10px; line-height: 13px; overflow-wrap: anywhere; }
|
||||
.alert-actions { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
/* Boxes table */
|
||||
.box-table {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
border-collapse: collapse;
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.box-table th, .box-table td { padding: 6px 7px; border-bottom: 1px solid #dfdfdf; text-align: left; vertical-align: middle; }
|
||||
.box-table th { position: sticky; top: 0; z-index: 5; background: #dfdfdf; border-bottom: 1px solid #808080; }
|
||||
.box-table tr:nth-child(even) td { background: #f5f8ff; }
|
||||
.box-actions { display: flex; gap: 5px; flex-wrap: nowrap; }
|
||||
.box-action-button { min-width: 62px; height: 22px; padding: 0 6px; font-size: 12px; line-height: 12px; }
|
||||
|
||||
/* Activity */
|
||||
.activity-list { display: grid; }
|
||||
.activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
background: #ffffff;
|
||||
color: #000;
|
||||
}
|
||||
.activity-row:nth-child(even) { background: #f5f8ff; }
|
||||
.activity-time { font-weight: bold; color: #000078; }
|
||||
.activity-title { margin: 0 0 2px; font-weight: bold; }
|
||||
.activity-meta { margin: 0; color: #555; font-size: 12px; line-height: 13px; }
|
||||
|
||||
/* Modal / Popup */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(128, 128, 128, .42);
|
||||
z-index: 70;
|
||||
}
|
||||
.modal-backdrop.is-visible { display: block; }
|
||||
|
||||
.popup-window {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(calc(-50% - 1px), -50%);
|
||||
width: min(760px, calc(100vw - 24px));
|
||||
max-height: min(760px, calc(100vh - 24px));
|
||||
display: none;
|
||||
z-index: 80;
|
||||
}
|
||||
.popup-window.is-visible { display: flex; animation: popup-open 160ms steps(5, end); }
|
||||
@keyframes popup-open {
|
||||
from { transform: translate(calc(-50% - 1px), calc(-50% + 10px)) scale(.97); opacity: .45; }
|
||||
to { transform: translate(calc(-50% - 1px), -50%) scale(1); opacity: 1; }
|
||||
}
|
||||
.popup-body { margin: 0 6px 6px; padding: 10px; max-height: calc(100vh - 90px); overflow: auto; color: #000; }
|
||||
.metadata-pre {
|
||||
min-height: 240px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
color: #b7ffc8;
|
||||
background: #030403;
|
||||
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
|
||||
font-family: 'MonoCraft', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Tiny button (for alerts / boxes) */
|
||||
.tiny-button {
|
||||
min-width: 56px;
|
||||
height: 22px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0 7px;
|
||||
color: #000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #000000;
|
||||
border-bottom: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.tiny-button:hover { filter: brightness(1.06); }
|
||||
|
||||
/* Compact mode */
|
||||
body.is-compact .dashboard-body { gap: 8px; }
|
||||
body.is-compact .section-body { padding: 5px; }
|
||||
body.is-compact .alerts-scroll,
|
||||
body.is-compact .boxes-scroll { height: 280px; }
|
||||
body.is-compact .activity-scroll { height: 280px; }
|
||||
body.is-compact .alert-row { min-height: 62px; }
|
||||
body.is-compact .activity-row { min-height: 42px; }
|
||||
|
||||
/* Responsive: medium */
|
||||
@media (max-width: 1180px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-hero { grid-template-columns: 1fr; }
|
||||
.dashboard-main-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-span-2 { grid-column: auto; }
|
||||
.alerts-scroll, .boxes-scroll { height: 310px; }
|
||||
.activity-scroll { height: 310px; }
|
||||
}
|
||||
|
||||
/* Responsive: small (mobile) */
|
||||
@media (max-width: 760px) {
|
||||
.dashboard-body { padding: 6px; gap: 8px; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.stat-card { min-height: 112px; }
|
||||
.alert-row { grid-template-columns: 1fr; min-height: 0; }
|
||||
.alert-actions { justify-content: flex-start; }
|
||||
.alerts-scroll, .boxes-scroll, .activity-scroll { height: 320px; }
|
||||
.boxes-scroll { overflow-x: auto; }
|
||||
.activity-row { grid-template-columns: 48px minmax(0, 1fr); }
|
||||
.activity-row .tag { grid-column: 2; justify-self: start; }
|
||||
.popup-window {
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: none;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.popup-window.is-visible { animation: popup-open-mobile 150ms steps(5, end); }
|
||||
@keyframes popup-open-mobile { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
|
||||
.popup-body { max-height: calc(100dvh - 40px); }
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -128,3 +128,81 @@
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
/* Raised panel - appears to sit above the surface */
|
||||
.raised-panel {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Sunken panel - appears to be inset into the surface */
|
||||
.sunken-panel {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
/* Scroll panel - used for scrollable content areas within windows */
|
||||
.scroll-panel {
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
/* Meter track for progress bars */
|
||||
.meter-track {
|
||||
display: block;
|
||||
height: 14px;
|
||||
margin-top: 9px;
|
||||
background-color: #ffffff;
|
||||
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.06) 0 1px, transparent 1px 18px);
|
||||
border-top: 2px solid #808080;
|
||||
border-left: 2px solid #808080;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.meter-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--meter, 0%);
|
||||
background-color: #000078;
|
||||
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.13) 0 1px, transparent 1px 18px);
|
||||
}
|
||||
|
||||
/* Tag styles for status indicators */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 17px;
|
||||
margin: 1px 2px 1px 0;
|
||||
padding: 1px 5px;
|
||||
color: #000000;
|
||||
background: #dfdfdf;
|
||||
border: 1px solid #808080;
|
||||
box-shadow: inset 1px 1px 0 #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag.ok { color: #008000; background: #eeffee; }
|
||||
.tag.info { color: #000078; background: #edf4ff; }
|
||||
.tag.warn { color: #8a6200; background: #ffffcc; }
|
||||
.tag.danger { color: #ffffff; background: #800000; }
|
||||
|
||||
/* Titlebar animation - gradient drift */
|
||||
@keyframes titlebar-drift {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
216
static/js/admin/alerts.js
Normal file
216
static/js/admin/alerts.js
Normal file
@@ -0,0 +1,216 @@
|
||||
(() => {
|
||||
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 toast = document.getElementById("toast");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const severityFilter = document.getElementById("severity-filter");
|
||||
const statusFilter = document.getElementById("status-filter");
|
||||
const sourceFilter = document.getElementById("source-filter");
|
||||
const sortFilter = document.getElementById("sort-filter");
|
||||
const alertsBody = document.getElementById("alerts-body");
|
||||
const selectedCountEl = document.getElementById("selected-count");
|
||||
const openCountEl = document.querySelector("[data-open-count]");
|
||||
const highCountEl = document.querySelector("[data-high-count]");
|
||||
const ackCountEl = document.querySelector("[data-ack-count]");
|
||||
const closedCountEl = document.querySelector("[data-closed-count]");
|
||||
const selectAll = document.getElementById("select-all");
|
||||
|
||||
const detailEls = {
|
||||
title: document.getElementById("detail-title"),
|
||||
severity: document.getElementById("detail-severity"),
|
||||
status: document.getElementById("detail-status"),
|
||||
code: document.getElementById("detail-code"),
|
||||
trace: document.getElementById("detail-trace"),
|
||||
time: document.getElementById("detail-time"),
|
||||
description: document.getElementById("detail-description"),
|
||||
metadata: document.getElementById("detail-metadata")
|
||||
};
|
||||
|
||||
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toast, duration });
|
||||
return;
|
||||
}
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
toast.classList.add("is-visible");
|
||||
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
|
||||
}
|
||||
|
||||
function allRows() {
|
||||
return Array.from(alertsBody.querySelectorAll("tr"));
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
return allRows().filter((row) => row.style.display !== "none");
|
||||
}
|
||||
|
||||
function selectedRows() {
|
||||
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
|
||||
}
|
||||
|
||||
function updateSummaryCounts() {
|
||||
const rows = visibleRows();
|
||||
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
|
||||
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
|
||||
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
|
||||
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
|
||||
}
|
||||
|
||||
function updateDetails(row) {
|
||||
if (!row) return;
|
||||
allRows().forEach((item) => item.classList.remove("is-selected"));
|
||||
row.classList.add("is-selected");
|
||||
detailEls.title.textContent = row.dataset.title || "";
|
||||
detailEls.severity.textContent = row.dataset.severity || "";
|
||||
detailEls.status.textContent = row.dataset.status || "";
|
||||
detailEls.code.textContent = row.dataset.code || "";
|
||||
detailEls.trace.textContent = row.dataset.trace || "";
|
||||
detailEls.time.textContent = row.dataset.time || "";
|
||||
detailEls.description.textContent = row.dataset.description || "";
|
||||
try {
|
||||
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
|
||||
} catch (_) {
|
||||
detailEls.metadata.textContent = row.dataset.metadata || "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const search = searchInput.value.trim().toLowerCase();
|
||||
const severity = severityFilter.value;
|
||||
const status = statusFilter.value;
|
||||
const group = sourceFilter.value;
|
||||
|
||||
allRows().forEach((row) => {
|
||||
const haystack = [
|
||||
row.dataset.title,
|
||||
row.dataset.description,
|
||||
row.dataset.code,
|
||||
row.dataset.trace,
|
||||
row.dataset.group
|
||||
].join(" ").toLowerCase();
|
||||
const matchesSearch = !search || haystack.includes(search);
|
||||
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
|
||||
const matchesStatus = status === "all" || row.dataset.status === status;
|
||||
const matchesGroup = group === "all" || row.dataset.group === group;
|
||||
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
|
||||
});
|
||||
|
||||
const order = { high: 3, medium: 2, low: 1 };
|
||||
visibleRows().sort((a, b) => {
|
||||
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
|
||||
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
|
||||
return Number(b.dataset.id) - Number(a.dataset.id);
|
||||
}).forEach((row) => alertsBody.appendChild(row));
|
||||
|
||||
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
|
||||
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
|
||||
updateSelectedCount();
|
||||
updateSummaryCounts();
|
||||
}
|
||||
|
||||
function setRowStatus(row, nextStatus) {
|
||||
row.dataset.status = nextStatus;
|
||||
const statusCell = row.children[3]?.querySelector(".alerts-pill");
|
||||
if (!statusCell) return;
|
||||
statusCell.className = `alerts-pill ${nextStatus}`;
|
||||
statusCell.textContent = nextStatus;
|
||||
}
|
||||
|
||||
function changeSelectedStatus(nextStatus) {
|
||||
const rows = selectedRows();
|
||||
if (!rows.length) {
|
||||
showToast("Select one or more alerts first", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((row) => {
|
||||
setRowStatus(row, nextStatus);
|
||||
row.querySelector(".row-check").checked = false;
|
||||
});
|
||||
if (selectAll) selectAll.checked = false;
|
||||
updateSelectedCount();
|
||||
updateSummaryCounts();
|
||||
|
||||
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
|
||||
if (currentRow) updateDetails(currentRow);
|
||||
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
|
||||
}
|
||||
|
||||
const commandMessages = {
|
||||
refresh: "Alerts refreshed in mock view",
|
||||
export: "Visible alerts exported in mock view",
|
||||
"copy-meta": "Metadata copied in mock view",
|
||||
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
|
||||
"help-meta": "Metadata explains why the alert happened and includes extra context."
|
||||
};
|
||||
|
||||
function runCommand(command) {
|
||||
switch (command) {
|
||||
case "ack":
|
||||
changeSelectedStatus("acked");
|
||||
return;
|
||||
case "close":
|
||||
changeSelectedStatus("closed");
|
||||
return;
|
||||
case "open-only":
|
||||
statusFilter.value = "open";
|
||||
applyFilters();
|
||||
showToast("Showing open alerts only");
|
||||
return;
|
||||
default:
|
||||
showToast(commandMessages[command] || `Mock action: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
|
||||
});
|
||||
|
||||
allRows().forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("input")) return;
|
||||
updateDetails(row);
|
||||
});
|
||||
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
|
||||
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
|
||||
});
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
visibleRows().forEach((row) => {
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
if (checkbox) checkbox.checked = selectAll.checked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
runCommand("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
updateDetails(allRows()[0]);
|
||||
})();
|
||||
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();
|
||||
})();
|
||||
201
static/js/admin/dashboard.js
Normal file
201
static/js/admin/dashboard.js
Normal file
@@ -0,0 +1,201 @@
|
||||
(() => {
|
||||
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 toast = document.getElementById("toast");
|
||||
const statusText = document.getElementById("statusText");
|
||||
const modal = document.querySelector("[data-alert-modal]");
|
||||
const backdrop = document.querySelector("[data-modal-backdrop]");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const modalMeta = document.getElementById("modalMeta");
|
||||
const alertCountValue = document.getElementById("alertCountValue");
|
||||
const alertStatNote = document.getElementById("alertStatNote");
|
||||
const alertsCard = document.getElementById("alertsCard");
|
||||
const topAlertChip = document.getElementById("topAlertChip");
|
||||
const topTaskbar = document.querySelector(".admin-taskbar");
|
||||
|
||||
if (!statusText || !alertsCard || !topAlertChip) return;
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toast });
|
||||
return;
|
||||
}
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
toast.classList.add("is-visible");
|
||||
window.clearTimeout(showToast.timer);
|
||||
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600);
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusText.textContent = message;
|
||||
}
|
||||
|
||||
function openModal(title, meta) {
|
||||
if (!modal || !backdrop || !modalTitle || !modalMeta) return;
|
||||
modalTitle.textContent = title;
|
||||
modalMeta.textContent = meta;
|
||||
modal.classList.add("is-visible");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
backdrop.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal?.classList.remove("is-visible");
|
||||
modal?.setAttribute("aria-hidden", "true");
|
||||
backdrop?.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
function visibleAlertRows() {
|
||||
return Array.from(document.querySelectorAll(".alert-row")).filter((row) => !row.classList.contains("is-dismissed"));
|
||||
}
|
||||
|
||||
function updateStickyHeader() {
|
||||
topTaskbar?.classList.toggle("is-scrolled", window.scrollY > 4);
|
||||
}
|
||||
|
||||
function updateAlertSummary() {
|
||||
const rows = visibleAlertRows();
|
||||
const counts = rows.reduce((acc, row) => {
|
||||
const severity = row.dataset.severity || "low";
|
||||
acc[severity] = (acc[severity] || 0) + 1;
|
||||
return acc;
|
||||
}, { high: 0, medium: 0, low: 0 });
|
||||
const score = counts.high * 5 + counts.medium * 2 + counts.low;
|
||||
const total = rows.length;
|
||||
const stateClass = counts.high > 0 || score >= 12 ? "is-danger" : counts.medium >= 2 || score >= 5 ? "is-warning" : total > 0 ? "is-info" : "is-ok";
|
||||
|
||||
alertsCard.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
|
||||
alertsCard.classList.add(stateClass);
|
||||
topAlertChip.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
|
||||
topAlertChip.classList.add(stateClass);
|
||||
if (alertCountValue) alertCountValue.textContent = String(total);
|
||||
topAlertChip.textContent = total === 0 ? "OK no alerts" : `! ${total} alerts`;
|
||||
if (alertStatNote) {
|
||||
alertStatNote.innerHTML = total === 0
|
||||
? '<span class="stat-note-pill">all clear</span>'
|
||||
: `<span class="stat-note-pill">${counts.high} high</span><span class="stat-note-pill">${counts.medium} medium</span><span class="stat-note-pill">${counts.low} low</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setStatus(`Focused ${id.replace("-", " ")}`);
|
||||
}
|
||||
|
||||
const commandMessages = {
|
||||
refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.",
|
||||
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.",
|
||||
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.",
|
||||
"compact-mode": "Toggled compact density.",
|
||||
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.",
|
||||
"show-all-alerts": "TO-DO: navigate to /admin/alerts.",
|
||||
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.",
|
||||
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
|
||||
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
|
||||
"dismiss-low-alerts": "Closed visible low-severity alerts in this mock.",
|
||||
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
|
||||
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
|
||||
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
|
||||
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
|
||||
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
|
||||
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
|
||||
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
|
||||
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."
|
||||
};
|
||||
|
||||
function runCommand(command) {
|
||||
if (command === "compact-mode") document.body.classList.toggle("is-compact");
|
||||
if (command === "dismiss-low-alerts") {
|
||||
document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed"));
|
||||
updateAlertSummary();
|
||||
}
|
||||
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
|
||||
if (command === "show-all-alerts") window.location.hash = "alerts";
|
||||
|
||||
const message = commandMessages[command] || `Command: ${command}`;
|
||||
showToast(message);
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-scroll-to]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
scrollToSection(button.dataset.scrollTo);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-view-meta]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const row = button.closest(".alert-row");
|
||||
const title = row?.dataset.alertTitle || "Alert Metadata";
|
||||
let meta = row?.dataset.alertMeta || "{}";
|
||||
try {
|
||||
meta = JSON.stringify(JSON.parse(meta), null, 2);
|
||||
} catch (_) {
|
||||
meta = row?.dataset.alertMeta || "{}";
|
||||
}
|
||||
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-dismiss-alert]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const row = button.closest(".alert-row");
|
||||
row?.classList.add("is-dismissed");
|
||||
updateAlertSummary();
|
||||
showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`);
|
||||
setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
|
||||
backdrop?.addEventListener("click", closeModal);
|
||||
topAlertChip.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
scrollToSection("alerts");
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", updateStickyHeader, { passive: true });
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
menuController.close();
|
||||
closeModal();
|
||||
}
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
runCommand("refresh");
|
||||
}
|
||||
if (event.altKey && event.key.toLowerCase() === "a") {
|
||||
event.preventDefault();
|
||||
scrollToSection("alerts");
|
||||
}
|
||||
if (event.altKey && event.key.toLowerCase() === "b") {
|
||||
event.preventDefault();
|
||||
scrollToSection("recent-boxes");
|
||||
}
|
||||
if (event.altKey && event.key.toLowerCase() === "r") {
|
||||
event.preventDefault();
|
||||
scrollToSection("recent-activity");
|
||||
}
|
||||
});
|
||||
|
||||
updateAlertSummary();
|
||||
updateStickyHeader();
|
||||
})();
|
||||
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();
|
||||
})();
|
||||
@@ -53,5 +53,46 @@ function renderTemplate(template, data = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
return { toast, openPopup, closePopup, htmlEscape, renderTemplate };
|
||||
function bindMenuBar(options = {}) {
|
||||
const root = options.root || document;
|
||||
const itemSelector = options.itemSelector || ".menu-item";
|
||||
const buttonSelector = options.buttonSelector || ".menu-button";
|
||||
const items = Array.from(root.querySelectorAll(itemSelector));
|
||||
|
||||
function close() {
|
||||
items.forEach((item) => {
|
||||
item.classList.remove("is-open");
|
||||
item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
|
||||
function open(item) {
|
||||
close();
|
||||
item.classList.add("is-open");
|
||||
item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
const button = item.querySelector(buttonSelector);
|
||||
button?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const wasOpen = item.classList.contains("is-open");
|
||||
close();
|
||||
if (!wasOpen) open(item);
|
||||
});
|
||||
|
||||
item.addEventListener("mouseenter", () => {
|
||||
if (!root.querySelector(`${itemSelector}.is-open`)) return;
|
||||
open(item);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(itemSelector)) close();
|
||||
});
|
||||
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return { toast, openPopup, closePopup, htmlEscape, renderTemplate, bindMenuBar };
|
||||
})();
|
||||
|
||||
321
templates/admin/alerts.html
Normal file
321
templates/admin/alerts.html
Normal file
@@ -0,0 +1,321 @@
|
||||
{{ define "admin/alerts.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WarpBox Admin Alerts</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/alerts.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 Alerts</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="Alerts 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 alerts</span><span class="shortcut">F5</span></button>
|
||||
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible alerts</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Alerts</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="ack"><span>A</span><span>Acknowledge selected</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="close"><span>C</span><span>Close selected</span><span></span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="open-only"><span>O</span><span>Show open only</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-codes"><span>?</span><span>Tracing and codes</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="help-meta"><span>I</span><span>Metadata preview</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="admin-workspace-body alerts-page-body">
|
||||
<section class="alerts-summary-grid" aria-label="Alerts summary">
|
||||
<article class="alerts-stat-card is-danger">
|
||||
<p class="alerts-stat-label">Open alerts</p>
|
||||
<p class="alerts-stat-value" data-open-count>5</p>
|
||||
<p class="alerts-stat-note">Requires attention</p>
|
||||
</article>
|
||||
<article class="alerts-stat-card is-warning">
|
||||
<p class="alerts-stat-label">High severity</p>
|
||||
<p class="alerts-stat-value" data-high-count>2</p>
|
||||
<p class="alerts-stat-note">Escalate first</p>
|
||||
</article>
|
||||
<article class="alerts-stat-card is-info">
|
||||
<p class="alerts-stat-label">Acknowledged</p>
|
||||
<p class="alerts-stat-value" data-ack-count>3</p>
|
||||
<p class="alerts-stat-note">Seen but not closed</p>
|
||||
</article>
|
||||
<article class="alerts-stat-card is-info">
|
||||
<p class="alerts-stat-label">Closed today</p>
|
||||
<p class="alerts-stat-value" data-closed-count>2</p>
|
||||
<p class="alerts-stat-note">History stays lightweight</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="alerts-content-grid">
|
||||
<div class="alerts-column">
|
||||
<section class="alerts-panel alerts-list-panel">
|
||||
<div class="alerts-panel-header">
|
||||
<div class="alerts-panel-title">Alert list <span class="alerts-panel-sub">search, filter, review</span></div>
|
||||
<div class="alerts-panel-tools">
|
||||
<button class="win98-button alerts-tool-button" type="button" data-command="ack">Acknowledge</button>
|
||||
<button class="win98-button alerts-tool-button" type="button" data-command="close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alerts-panel-body">
|
||||
<div class="alerts-toolbar-grid">
|
||||
<input class="alerts-input" id="search-input" type="search" placeholder="Search title, code, trace or text">
|
||||
<select class="alerts-select" id="severity-filter">
|
||||
<option value="all" selected>All severities</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<select class="alerts-select" id="status-filter">
|
||||
<option value="all" selected>All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="acked">Acknowledged</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<select class="alerts-select" id="source-filter">
|
||||
<option value="all" selected>All groups</option>
|
||||
<option value="thumbnails">Thumbnails</option>
|
||||
<option value="storage">Storage</option>
|
||||
<option value="uploads">Uploads</option>
|
||||
<option value="auth">Auth</option>
|
||||
</select>
|
||||
<select class="alerts-select" id="sort-filter">
|
||||
<option value="newest" selected>Newest first</option>
|
||||
<option value="severity">Severity first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alerts-table-wrap">
|
||||
<table class="alerts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="alerts-col-check"><input type="checkbox" id="select-all"></th>
|
||||
<th>Title</th>
|
||||
<th class="alerts-col-severity">Severity</th>
|
||||
<th class="alerts-col-status">Status</th>
|
||||
<th class="alerts-col-code">Code</th>
|
||||
<th>Trace</th>
|
||||
<th class="alerts-col-time">Created</th>
|
||||
<th class="alerts-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alerts-body">
|
||||
<tr data-id="10" data-severity="high" data-status="open" data-group="storage" data-title="Storage connector unavailable" data-description="Primary local storage connector failed health check and new writes are paused." data-code="301" data-trace="storage.connector.health_failed" data-time="today 14:08" data-metadata='{"connector":"local-main","mode":"read_only","retry_in":"30s"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Storage connector unavailable</td>
|
||||
<td><span class="alerts-pill high">high</span></td>
|
||||
<td><span class="alerts-pill open">open</span></td>
|
||||
<td>301</td>
|
||||
<td>storage.connector.health_failed</td>
|
||||
<td>today 14:08</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="9" data-severity="medium" data-status="open" data-group="thumbnails" data-title="Thumbnail generation failed" data-description="Thumbnail generation failed for one uploaded image. Original file remains available." data-code="601" data-trace="thumbnail.generate.failed" data-time="today 13:40" data-metadata='{"box":"bx_49aa","file":"poster.png","worker":"thumb-2"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Thumbnail generation failed</td>
|
||||
<td><span class="alerts-pill medium">medium</span></td>
|
||||
<td><span class="alerts-pill open">open</span></td>
|
||||
<td>601</td>
|
||||
<td>thumbnail.generate.failed</td>
|
||||
<td>today 13:40</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="8" data-severity="low" data-status="acked" data-group="uploads" data-title="Large upload nearing account cap" data-description="A user is close to their daily upload budget." data-code="124" data-trace="upload.quota.nearing_cap" data-time="today 12:58" data-metadata='{"user":"geo","used":"44 GB","limit":"50 GB"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Large upload nearing account cap</td>
|
||||
<td><span class="alerts-pill low">low</span></td>
|
||||
<td><span class="alerts-pill acked">acked</span></td>
|
||||
<td>124</td>
|
||||
<td>upload.quota.nearing_cap</td>
|
||||
<td>today 12:58</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="7" data-severity="high" data-status="open" data-group="auth" data-title="Repeated admin login failures" data-description="Multiple failed admin login attempts were detected from the same source." data-code="211" data-trace="auth.admin.failed_login_burst" data-time="today 12:10" data-metadata='{"ip":"198.51.100.4","attempts":7,"window":"10m"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Repeated admin login failures</td>
|
||||
<td><span class="alerts-pill high">high</span></td>
|
||||
<td><span class="alerts-pill open">open</span></td>
|
||||
<td>211</td>
|
||||
<td>auth.admin.failed_login_burst</td>
|
||||
<td>today 12:10</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="6" data-severity="medium" data-status="acked" data-group="storage" data-title="Cleanup skipped locked files" data-description="Cleanup job encountered locked files and skipped them." data-code="342" data-trace="cleanup.skip.locked_files" data-time="today 10:22" data-metadata='{"count":3,"connector":"local-main"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Cleanup skipped locked files</td>
|
||||
<td><span class="alerts-pill medium">medium</span></td>
|
||||
<td><span class="alerts-pill acked">acked</span></td>
|
||||
<td>342</td>
|
||||
<td>cleanup.skip.locked_files</td>
|
||||
<td>today 10:22</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="5" data-severity="low" data-status="closed" data-group="uploads" data-title="Archive completed with warnings" data-description="ZIP archive completed but excluded one unreadable temporary file." data-code="145" data-trace="archive.complete.with_warning" data-time="today 09:02" data-metadata='{"box":"bx_3901","skipped":1}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Archive completed with warnings</td>
|
||||
<td><span class="alerts-pill low">low</span></td>
|
||||
<td><span class="alerts-pill closed">closed</span></td>
|
||||
<td>145</td>
|
||||
<td>archive.complete.with_warning</td>
|
||||
<td>today 09:02</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="4" data-severity="medium" data-status="open" data-group="uploads" data-title="Upload session expired mid-transfer" data-description="A long-running upload lost session validity before final commit." data-code="156" data-trace="upload.session.expired_mid_transfer" data-time="yesterday" data-metadata='{"user":"teo","partial_bytes":"1.2 GB"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Upload session expired mid-transfer</td>
|
||||
<td><span class="alerts-pill medium">medium</span></td>
|
||||
<td><span class="alerts-pill open">open</span></td>
|
||||
<td>156</td>
|
||||
<td>upload.session.expired_mid_transfer</td>
|
||||
<td>yesterday</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="3" data-severity="low" data-status="closed" data-group="thumbnails" data-title="Thumbnail worker restarted" data-description="Thumbnail worker restarted after a normal watchdog recycle." data-code="602" data-trace="thumbnail.worker.restarted" data-time="yesterday" data-metadata='{"worker":"thumb-1","reason":"watchdog"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Thumbnail worker restarted</td>
|
||||
<td><span class="alerts-pill low">low</span></td>
|
||||
<td><span class="alerts-pill closed">closed</span></td>
|
||||
<td>602</td>
|
||||
<td>thumbnail.worker.restarted</td>
|
||||
<td>yesterday</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="2" data-severity="medium" data-status="acked" data-group="auth" data-title="User invited without email delivery confirmation" data-description="Invite creation succeeded but email delivery confirmation was not returned." data-code="224" data-trace="auth.invite.delivery_unknown" data-time="2 days ago" data-metadata='{"user":"reo","provider":"smtp-primary"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>User invited without email delivery confirmation</td>
|
||||
<td><span class="alerts-pill medium">medium</span></td>
|
||||
<td><span class="alerts-pill acked">acked</span></td>
|
||||
<td>224</td>
|
||||
<td>auth.invite.delivery_unknown</td>
|
||||
<td>2 days ago</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
<tr data-id="1" data-severity="low" data-status="closed" data-group="storage" data-title="Secondary connector caught up" data-description="Delayed sync on a secondary storage connector completed successfully." data-code="329" data-trace="storage.secondary.sync_recovered" data-time="2 days ago" data-metadata='{"connector":"bucket-archive","lag":"0"}'>
|
||||
<td><input type="checkbox" class="row-check"></td>
|
||||
<td>Secondary connector caught up</td>
|
||||
<td><span class="alerts-pill low">low</span></td>
|
||||
<td><span class="alerts-pill closed">closed</span></td>
|
||||
<td>329</td>
|
||||
<td>storage.secondary.sync_recovered</td>
|
||||
<td>2 days ago</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="alerts-column alerts-column-side">
|
||||
<section class="alerts-panel">
|
||||
<div class="alerts-panel-header">
|
||||
<div class="alerts-panel-title">Alert details <span class="alerts-panel-sub">selected alert preview</span></div>
|
||||
</div>
|
||||
<div class="alerts-panel-body">
|
||||
<ul class="alerts-info-list">
|
||||
<li class="alerts-info-item"><strong>Title</strong><span id="detail-title">Storage connector unavailable</span></li>
|
||||
<li class="alerts-info-item"><strong>Severity</strong><span id="detail-severity">high</span></li>
|
||||
<li class="alerts-info-item"><strong>Status</strong><span id="detail-status">open</span></li>
|
||||
<li class="alerts-info-item"><strong>Code</strong><span id="detail-code">301</span></li>
|
||||
<li class="alerts-info-item"><strong>Trace</strong><span id="detail-trace">storage.connector.health_failed</span></li>
|
||||
<li class="alerts-info-item"><strong>Created</strong><span id="detail-time">today 14:08</span></li>
|
||||
<li class="alerts-info-item"><strong>Description</strong><span id="detail-description">Primary local storage connector failed health check and new writes are paused.</span></li>
|
||||
</ul>
|
||||
<div class="alerts-mini-note">
|
||||
TO-DO: later, limited alert access should only show alerts scoped to the user’s permissions, tags, or groups.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="alerts-panel">
|
||||
<div class="alerts-panel-header">
|
||||
<div class="alerts-panel-title">Metadata <span class="alerts-panel-sub">simple JSON preview</span></div>
|
||||
<div class="alerts-panel-tools">
|
||||
<button class="win98-button alerts-tool-button" type="button" data-command="copy-meta">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alerts-panel-body">
|
||||
<pre class="alerts-json-box" id="detail-metadata">{
|
||||
"connector": "local-main",
|
||||
"mode": "read_only",
|
||||
"retry_in": "30s"
|
||||
}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="alerts-panel alerts-actions-panel">
|
||||
<div class="alerts-panel-header">
|
||||
<div class="alerts-panel-title">Actions <span class="alerts-panel-sub">simple first version</span></div>
|
||||
</div>
|
||||
<div class="alerts-panel-body">
|
||||
<div class="alerts-action-stack">
|
||||
<button class="win98-button alerts-action-button" type="button" data-command="ack">Acknowledge selected</button>
|
||||
<button class="win98-button alerts-action-button" type="button" data-command="close">Close selected</button>
|
||||
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
|
||||
</div>
|
||||
<div class="alerts-mini-note">
|
||||
CURRENTLY_MOCKED_LEAVE_AS_IS: alerts use a lightweight lifecycle for now: open, acknowledged, closed.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="alerts-footerbar">
|
||||
<div class="alerts-footer-left">
|
||||
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
|
||||
<span class="alerts-status-pill">10 mocked alerts</span>
|
||||
</div>
|
||||
<div class="alerts-footer-right">
|
||||
<button class="win98-button alerts-footer-button" type="button" data-command="ack">Acknowledge</button>
|
||||
<button class="win98-button alerts-footer-button" type="button" data-command="close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/alerts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
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 }}
|
||||
337
templates/admin/dashboard.html
Normal file
337
templates/admin/dashboard.html
Normal file
@@ -0,0 +1,337 @@
|
||||
{{ define "admin/dashboard.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WarpBox Admin Dashboard</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/dashboard.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">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-shell">
|
||||
<div class="admin-frame">
|
||||
{{ template "admin/header.html" . }}
|
||||
|
||||
<!-- Dashboard Window -->
|
||||
<div class="win98-window admin-dashboard-window" role="main">
|
||||
<!-- Titlebar -->
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||
<h1>WarpBox Account Control Panel</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>
|
||||
|
||||
<!-- Menu Bar -->
|
||||
<nav class="menu-bar" aria-label="Dashboard 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 dashboard</span><span class="shortcut">F5</span></button>
|
||||
<button class="menu-action" type="button" data-command="dashboard-snapshot"><span>S</span><span>Export dashboard snapshot</span><span></span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="logout"><span>Q</span><span>Log out</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-scroll-to="alerts"><span>!</span><span>Go to alerts</span><span class="shortcut">Alt+A</span></button>
|
||||
<button class="menu-action" type="button" data-scroll-to="recent-boxes"><span>B</span><span>Go to recent boxes</span><span class="shortcut">Alt+B</span></button>
|
||||
<button class="menu-action" type="button" data-scroll-to="recent-activity"><span>T</span><span>Go to recent activity</span><span class="shortcut">Alt+R</span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="compact-mode"><span>C</span><span>Toggle compact density</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="show-all-boxes"><span>B</span><span>Show all boxes</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="export-boxes"><span>C</span><span>Export boxes CSV</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="cleanup-dry-run"><span>D</span><span>Cleanup dry run</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Alerts</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="show-all-alerts"><span>!</span><span>Show all alerts</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="dismiss-low-alerts"><span>L</span><span>Close all low alerts</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="export-alerts"><span>J</span><span>Export alerts JSON</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Admin</button>
|
||||
<div class="menu-popup">
|
||||
<button class="menu-action" type="button" data-command="config-snapshot"><span>S</span><span>Config snapshot</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="support-summary"><span>?</span><span>Support summary</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="thumbnail-rebuild"><span>I</span><span>Queue thumbnail rebuild</span><span></span></button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-action" type="button" data-command="open-users"><span>U</span><span>Open user manager</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="open-settings"><span>G</span><span>Open settings</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="alerts-help"><span>!</span><span>How alert tracing works</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="shortcuts"><span>K</span><span>Keyboard shortcuts</span><span></span></button>
|
||||
<button class="menu-action" type="button" data-command="about"><span>W</span><span>About this mockup</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Body -->
|
||||
<div class="dashboard-body">
|
||||
<!-- Hero -->
|
||||
<section class="dashboard-hero raised-panel" aria-labelledby="dashboardTitle">
|
||||
<div class="hero-copy">
|
||||
<h2 id="dashboardTitle">Dashboard</h2>
|
||||
<p>At-a-glance account and admin overview for boxes, alerts, storage, users, and recent activity.</p>
|
||||
</div>
|
||||
<div class="hero-status" aria-label="System summary">
|
||||
<div class="hero-status-row"><span>Guest uploads</span><strong class="status-ok">enabled</strong></div>
|
||||
<div class="hero-status-row"><span>ZIP downloads</span><strong class="status-ok">enabled</strong></div>
|
||||
<div class="hero-status-row"><span>One-time boxes</span><strong class="status-warn">limited</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="stats-grid" aria-label="Dashboard statistics">
|
||||
<article class="stat-card sunken-panel is-info" id="activeBoxesCard">
|
||||
<p class="stat-label">Active boxes</p>
|
||||
<p class="stat-value">128</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">+12 today</span><span class="stat-note-pill">42 passworded</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info" id="storageCard">
|
||||
<p class="stat-label">Storage available</p>
|
||||
<p class="stat-value">812 GiB</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">188 GiB used</span><span class="stat-note-pill">1 TiB app cap</span><span class="stat-note-pill">local backend</span></p>
|
||||
<span class="meter-track" aria-hidden="true"><span class="meter-bar" style="--meter: 18.8%"></span></span>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning" id="alertsCard">
|
||||
<p class="stat-label">Alerts</p>
|
||||
<p class="stat-value"><span id="alertCountValue">15</span></p>
|
||||
<p class="stat-note" id="alertStatNote"><span class="stat-note-pill">2 high</span><span class="stat-note-pill">5 medium</span><span class="stat-note-pill">8 low</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok" id="usersCard">
|
||||
<p class="stat-label">Users</p>
|
||||
<p class="stat-value">19</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">15 active</span><span class="stat-note-pill">4 disabled</span><span class="stat-note-pill">admin-only</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Main Grid: Alerts, Boxes, Activity -->
|
||||
<section class="dashboard-main-grid" aria-label="Dashboard panels">
|
||||
<!-- Alerts -->
|
||||
<article id="alerts" class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">!</span>
|
||||
<h2>Alerts Inbox</h2>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<a class="titlebar-link-button" href="/admin/alerts">Show all</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel alerts-scroll" aria-label="Scrollable alerts inbox">
|
||||
<div class="alert-list">
|
||||
<div class="alert-row" data-severity="high" data-alert-title="Storage backend is almost full" data-alert-code="421" data-alert-meta='{"backend":"local","used_bytes":1009317314560,"available_bytes":45097156608,"configured_cap_bytes":1099511627776,"recommended_action":"run cleanup dry run or raise app cap"}'>
|
||||
<span class="alert-severity">high</span>
|
||||
<div><p class="alert-title">Storage backend is almost full</p><p class="alert-desc">The active local storage backend has less than 5% free capacity under the configured app cap.</p><p class="alert-trace">code 421, trace storage.local.capacity.high</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="high" data-alert-title="Disabled user has active sessions" data-alert-code="181" data-alert-meta='{"user":"old-operator","active_sessions":2,"recommended_action":"revoke sessions"}'>
|
||||
<span class="alert-severity">high</span>
|
||||
<div><p class="alert-title">Disabled user has active sessions</p><p class="alert-desc">A disabled account still has active sessions that should be revoked.</p><p class="alert-trace">code 181, trace auth.sessions.disabled_user_active</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="medium" data-alert-title="Expired boxes waiting cleanup" data-alert-code="301" data-alert-meta='{"expired_boxes":17,"oldest_expired_at":"2026-04-29T22:18:00+03:00","recommended_action":"run cleanup"}'>
|
||||
<span class="alert-severity">medium</span>
|
||||
<div><p class="alert-title">Expired boxes waiting cleanup</p><p class="alert-desc">Expired boxes are still present on disk and are eligible for cleanup.</p><p class="alert-trace">code 301, trace boxes.expiry.cleanup_pending</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="medium" data-alert-title="API key UI enabled but key backend missing" data-alert-code="711" data-alert-meta='{"ui_surface":"upload.api_key_input","backend_model":"missing","recommended_action":"hide UI or implement API keys"}'>
|
||||
<span class="alert-severity">medium</span>
|
||||
<div><p class="alert-title">API key UI enabled but key backend missing</p><p class="alert-desc">The frontend advertises API key usage while server-side API key validation is not connected yet.</p><p class="alert-trace">code 711, trace api_keys.ui.backend_missing</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="medium" data-alert-title="Thumbnail queue is behind" data-alert-code="602" data-alert-meta='{"pending_thumbnails":44,"worker_interval_seconds":30,"recommended_action":"increase batch size or queue rebuild"}'>
|
||||
<span class="alert-severity">medium</span>
|
||||
<div><p class="alert-title">Thumbnail queue is behind</p><p class="alert-desc">The thumbnail worker has accumulated more pending previews than expected.</p><p class="alert-trace">code 602, trace thumbnails.worker.queue_lag</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="medium" data-alert-title="Large ZIP download failed" data-alert-code="502" data-alert-meta='{"box":"BX-7D20","zip_bytes":897300992,"attempt":1,"recommended_action":"retry manually or inspect files"}'>
|
||||
<span class="alert-severity">medium</span>
|
||||
<div><p class="alert-title">Large ZIP download failed</p><p class="alert-desc">A ZIP stream failed before the response finished.</p><p class="alert-trace">code 502, trace downloads.zip.stream_failed</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="medium" data-alert-title="Guest quota close to daily cap" data-alert-code="231" data-alert-meta='{"ip":"192.0.2.44","used_today_bytes":1795162112,"daily_cap_bytes":2147483648,"recommended_action":"none"}'>
|
||||
<span class="alert-severity">medium</span>
|
||||
<div><p class="alert-title">Guest quota close to daily cap</p><p class="alert-desc">A guest IP is close to its configured daily upload cap.</p><p class="alert-trace">code 231, trace quotas.guest.daily.near_cap</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Thumbnail generation skipped" data-alert-code="601" data-alert-meta='{"box":"BX-9F31","file":"mockup.webp","reason":"unsupported decoder","recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Thumbnail generation skipped</p><p class="alert-desc">A preview could not be generated for one image file.</p><p class="alert-trace">code 601, trace thumbnails.generate.skipped</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="One-time box downloaded" data-alert-code="511" data-alert-meta='{"box":"BX-440C","delete_after_success":true,"recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">One-time box downloaded</p><p class="alert-desc">A one-time ZIP handoff completed and the box was queued for deletion.</p><p class="alert-trace">code 511, trace downloads.one_time.completed</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Settings override changed" data-alert-code="801" data-alert-meta='{"setting":"box_poll_interval_ms","source":"admin_override","recommended_action":"audit when audit log exists"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Settings override changed</p><p class="alert-desc">A runtime setting was changed through the settings UI.</p><p class="alert-trace">code 801, trace settings.override.changed</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Password protected box created" data-alert-code="121" data-alert-meta='{"box":"BX-C2A8","owner":"maya","recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Password protected box created</p><p class="alert-desc">A user created a password protected upload box.</p><p class="alert-trace">code 121, trace boxes.create.passworded</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Upload completed slowly" data-alert-code="222" data-alert-meta='{"box":"BX-88B4","duration_seconds":731,"recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Upload completed slowly</p><p class="alert-desc">An upload completed but exceeded the expected duration threshold.</p><p class="alert-trace">code 222, trace uploads.performance.slow_complete</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Session refreshed" data-alert-code="182" data-alert-meta='{"user":"admin","reason":"activity_refresh","recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Session refreshed</p><p class="alert-desc">The current local session was refreshed after account activity.</p><p class="alert-trace">code 182, trace auth.session.refreshed</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Box visited from share URL" data-alert-code="401" data-alert-meta='{"box":"BX-39C1","viewer":"guest","recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Box visited from share URL</p><p class="alert-desc">A public box was opened through its normal shared page.</p><p class="alert-trace">code 401, trace boxes.share.opened</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
<div class="alert-row" data-severity="low" data-alert-title="Support summary generated" data-alert-code="901" data-alert-meta='{"requested_by":"admin","included_sections":["config","storage","alerts"],"recommended_action":"none"}'>
|
||||
<span class="alert-severity">low</span>
|
||||
<div><p class="alert-title">Support summary generated</p><p class="alert-desc">A local support summary was generated from the toolbar.</p><p class="alert-trace">code 901, trace support.summary.generated</p></div>
|
||||
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<article id="recent-activity" class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">T</span>
|
||||
<h2>Recent Activity</h2>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<a class="titlebar-link-button" href="/admin/dashboard#recent-activity">Show all</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel activity-scroll" aria-label="Scrollable recent activity list">
|
||||
<div class="activity-list">
|
||||
<div class="activity-row"><span class="activity-time">10:12</span><div><p class="activity-title">Box BX-9F31 completed upload</p><p class="activity-meta">4 files, password protected</p></div><span class="tag ok">box</span></div>
|
||||
<div class="activity-row"><span class="activity-time">10:08</span><div><p class="activity-title">Alert 421 created</p><p class="activity-meta">storage.local.capacity.high</p></div><span class="tag danger">alert</span></div>
|
||||
<div class="activity-row"><span class="activity-time">10:04</span><div><p class="activity-title">Guest created box BX-A71D</p><p class="activity-meta">retention 6 hours</p></div><span class="tag ok">upload</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:58</span><div><p class="activity-title">Thumbnail worker skipped one image</p><p class="activity-meta">decoder unavailable for webp preview</p></div><span class="tag warn">thumbs</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:51</span><div><p class="activity-title">Cleanup dry run opened</p><p class="activity-meta">17 expired boxes detected</p></div><span class="tag info">tools</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:44</span><div><p class="activity-title">Large ZIP download completed</p><p class="activity-meta">BX-7D20, 12 files</p></div><span class="tag info">zip</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:33</span><div><p class="activity-title">Settings snapshot requested</p><p class="activity-meta">admin opened config snapshot from toolbar</p></div><span class="tag info">settings</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:21</span><div><p class="activity-title">Temporary cleanup skipped</p><p class="activity-meta">BX-1AA2 still had an active file handle</p></div><span class="tag warn">cleanup</span></div>
|
||||
<div class="activity-row"><span class="activity-time">09:09</span><div><p class="activity-title">User maya uploaded 6 files</p><p class="activity-meta">91.9 MiB total</p></div><span class="tag ok">user</span></div>
|
||||
<div class="activity-row"><span class="activity-time">08:55</span><div><p class="activity-title">Box BX-55E0 expired</p><p class="activity-meta">eligible for cleanup</p></div><span class="tag danger">expired</span></div>
|
||||
<div class="activity-row"><span class="activity-time">08:42</span><div><p class="activity-title">One-time box created</p><p class="activity-meta">BX-440C, admin owner</p></div><span class="tag info">one-time</span></div>
|
||||
<div class="activity-row"><span class="activity-time">08:31</span><div><p class="activity-title">User ana uploaded archive set</p><p class="activity-meta">7 files, 520.8 MiB</p></div><span class="tag ok">upload</span></div>
|
||||
<div class="activity-row"><span class="activity-time">08:20</span><div><p class="activity-title">Guest accessed public box</p><p class="activity-meta">BX-39C1 viewed from share link</p></div><span class="tag info">access</span></div>
|
||||
<div class="activity-row"><span class="activity-time">08:07</span><div><p class="activity-title">User mihai created box BX-F02A</p><p class="activity-meta">standard plan quota applied</p></div><span class="tag ok">quota</span></div>
|
||||
<div class="activity-row"><span class="activity-time">07:54</span><div><p class="activity-title">Failed login attempt recorded</p><p class="activity-meta">admin account, single attempt</p></div><span class="tag warn">auth</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Recent Boxes (full width) -->
|
||||
<article id="recent-boxes" class="win98-window section-window dashboard-span-2">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">B</span>
|
||||
<h2>Recent Boxes</h2>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<a class="titlebar-link-button" href="/admin/dashboard#recent-boxes">Show all</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel boxes-scroll" aria-label="Scrollable recent boxes table">
|
||||
<table class="box-table">
|
||||
<thead><tr><th>Box</th><th>Owner</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Flags</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>BX-9F31</td><td>maya</td><td>4</td><td>91.9 MiB</td><td>10:12</td><td>5h 41m</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-9F31">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-9F31">Manage</a></div></td></tr>
|
||||
<tr><td>BX-A71D</td><td>guest</td><td>12</td><td>1.8 GiB</td><td>10:04</td><td>6h 00m</td><td><span class="tag warn">large</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-A71D">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-A71D">Manage</a></div></td></tr>
|
||||
<tr><td>BX-20BD</td><td>operator</td><td>2</td><td>8.4 MiB</td><td>09:58</td><td>1d 12h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-20BD">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-20BD">Manage</a></div></td></tr>
|
||||
<tr><td>BX-7D20</td><td>admin</td><td>12</td><td>856.3 MiB</td><td>09:44</td><td>23h 11m</td><td><span class="tag danger">zip failed</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-7D20">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-7D20">Manage</a></div></td></tr>
|
||||
<tr><td>BX-1AA2</td><td>guest</td><td>1</td><td>4.7 GiB</td><td>09:21</td><td>expired</td><td><span class="tag danger">locked</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-1AA2">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-1AA2">Manage</a></div></td></tr>
|
||||
<tr><td>BX-C2A8</td><td>maya</td><td>6</td><td>24.8 MiB</td><td>09:09</td><td>2d 03h</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-C2A8">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-C2A8">Manage</a></div></td></tr>
|
||||
<tr><td>BX-55E0</td><td>guest</td><td>1</td><td>4.2 MiB</td><td>08:55</td><td>expired</td><td><span class="tag danger">expired</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-55E0">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-55E0">Manage</a></div></td></tr>
|
||||
<tr><td>BX-440C</td><td>admin</td><td>3</td><td>63.0 MiB</td><td>08:42</td><td>2d 00h</td><td><span class="tag ok">complete</span> <span class="tag info">one-time</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-440C">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-440C">Manage</a></div></td></tr>
|
||||
<tr><td>BX-88B4</td><td>ana</td><td>7</td><td>520.8 MiB</td><td>08:31</td><td>5d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-88B4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-88B4">Manage</a></div></td></tr>
|
||||
<tr><td>BX-39C1</td><td>guest</td><td>2</td><td>23.1 MiB</td><td>08:20</td><td>16h 00m</td><td><span class="tag info">public</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-39C1">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-39C1">Manage</a></div></td></tr>
|
||||
<tr><td>BX-F02A</td><td>mihai</td><td>5</td><td>108.6 MiB</td><td>08:07</td><td>4d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-F02A">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-F02A">Manage</a></div></td></tr>
|
||||
<tr><td>BX-ABC4</td><td>guest</td><td>1</td><td>755 KiB</td><td>07:54</td><td>3h 00m</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-ABC4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-ABC4">Manage</a></div></td></tr>
|
||||
<tr><td>BX-74E9</td><td>operator</td><td>10</td><td>987.3 MiB</td><td>07:41</td><td>7d 00h</td><td><span class="tag info">bulk</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-74E9">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-74E9">Manage</a></div></td></tr>
|
||||
<tr><td>BX-218B</td><td>daniel</td><td>3</td><td>44.0 MiB</td><td>07:28</td><td>1d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-218B">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-218B">Manage</a></div></td></tr>
|
||||
<tr><td>BX-00FE</td><td>guest</td><td>2</td><td>13.7 MiB</td><td>07:12</td><td>2h 00m</td><td><span class="tag warn">soon</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-00FE">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-00FE">Manage</a></div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Statusbar -->
|
||||
<div class="win98-statusbar admin-dashboard-statusbar">
|
||||
<span id="statusText">Ready</span>
|
||||
<span>WarpBox mock v5</span>
|
||||
<span>Single-window dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
<div class="modal-backdrop" data-modal-backdrop></div>
|
||||
|
||||
<!-- Alert metadata popup -->
|
||||
<aside class="popup-window win98-window" data-alert-modal aria-label="Alert metadata" aria-hidden="true">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">!</span>
|
||||
<h2 id="modalTitle">Alert Metadata</h2>
|
||||
</div>
|
||||
<button class="win98-control" type="button" data-close-modal>x</button>
|
||||
</div>
|
||||
<div class="popup-body sunken-panel">
|
||||
<pre class="metadata-pre" id="modalMeta">{}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
67
templates/admin/login.html
Normal file
67
templates/admin/login.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{{ define "admin/login.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WarpBox Admin Login</title>
|
||||
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/css/window.css">
|
||||
<link rel="stylesheet" href="/static/css/login.css">
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<section class="win98-window login-window" aria-labelledby="login-window-title">
|
||||
<header class="win98-titlebar login-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||
<h1 id="login-window-title">WarpBox Administration</h1>
|
||||
</div>
|
||||
<div class="win98-window-controls" aria-hidden="true">
|
||||
<span class="win98-control">_</span>
|
||||
<span class="win98-control">□</span>
|
||||
<span class="win98-control">×</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="login-form" action="/admin/login" method="post">
|
||||
<div class="win98-panel login-panel">
|
||||
<div class="login-alert" role="alert">
|
||||
<img src="/static/img/icons/Windows Icons - PNG/shell32.dll_210_21001.png" alt="" aria-hidden="true">
|
||||
<p>Enter the administrator username and password to access the control panel.</p>
|
||||
</div>
|
||||
|
||||
<label class="login-row" for="admin-username">
|
||||
<span>User name</span>
|
||||
<input id="admin-username" class="login-input" type="text" name="username" autocomplete="username" autofocus>
|
||||
</label>
|
||||
|
||||
<label class="login-row" for="admin-password">
|
||||
<span>Password</span>
|
||||
<input id="admin-password" class="login-input" type="password" name="password" autocomplete="current-password">
|
||||
</label>
|
||||
|
||||
{{ if .ErrorMessage }}
|
||||
<p class="login-error">{{ .ErrorMessage }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<footer class="login-actions">
|
||||
<button class="win98-button" type="submit">OK</button>
|
||||
<a class="win98-button" href="/">Cancel</a>
|
||||
</footer>
|
||||
|
||||
<div class="win98-statusbar login-statusbar">
|
||||
<span>Administrator authentication</span>
|
||||
<span>WarpBox</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
18
templates/admin/partials/header.html
Normal file
18
templates/admin/partials/header.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{ define "admin/header.html" }}
|
||||
<header class="admin-taskbar" aria-label="Admin navigation">
|
||||
<a class="admin-start-button" href="/admin/dashboard">
|
||||
<span class="admin-start-logo">W</span>
|
||||
<span>WarpBox</span>
|
||||
</a>
|
||||
<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{{ 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>
|
||||
<span class="admin-session-chip">signed in: {{ .AdminUsername }}</span>
|
||||
</div>
|
||||
</header>
|
||||
{{ end }}
|
||||
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