10 Commits

Author SHA1 Message Date
dd8dd7cdc2 feat(versioning): Implemented APP_VERSION from build tags
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m35s
2026-05-01 03:45:15 +03:00
fc54f7bb86 fix(ci/cd): Naming fix
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
2026-05-01 03:41:32 +03:00
42030003d3 feat(ci/cd): Implemented simple package publishing to registry
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 1m1s
2026-05-01 03:39:33 +03:00
25bc095412 feat(docker): add healthcheck and wget dependency
Adds a healthcheck endpoint and updates Dockerfile/README
to include wget and define healthcheck logic.
2026-05-01 03:31:01 +03:00
54bb68642f chore(docker): update build path and volume comment 2026-05-01 02:58:16 +03:00
9b57b2a535 feat(routing): add user admin panel support
Adds the user administration route, associated server handlers, and frontend assets for managing user accounts.
2026-05-01 02:34:47 +03:00
1cf38d126d refactor(storage): standardize size limits to use GB units 2026-05-01 02:14:05 +03:00
d0aa86205f feat(setting): Implemented the settings administrative menu 2026-05-01 01:51:06 +03:00
36d49a970e feat(admin): add full alerts dashboard functionality 2026-05-01 00:46:10 +03:00
3844473eb3 feat(admin): implement full admin dashboard structure 2026-05-01 00:29:06 +03:00
43 changed files with 7737 additions and 96 deletions

View File

@@ -0,0 +1,46 @@
name: Build and Publish Docker Image
run-name: Publishing ${{ gitea.ref_name }}
on:
push:
tags:
- "v*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Run Tests
run: go test ./...
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Build Docker Image
run: |
docker build \
--build-arg APP_VERSION=${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/warpbox:latest \
.
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: tea.chunkbyte.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Push Docker Image
run: |
docker push tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }}
docker push tea.chunkbyte.com/kato/warpbox:latest

View File

@@ -1,6 +1,8 @@
# Stage 1: Build
FROM golang:1.23-alpine AS builder
ARG APP_VERSION=""
RUN apk add --no-cache git ca-certificates
WORKDIR /build
@@ -16,12 +18,19 @@ COPY static/ static/
COPY templates/ templates/
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
ARG APP_VERSION=""
ENV APP_VERSION=${APP_VERSION}
RUN apk add \
--no-cache \
ca-certificates \
tzdata \
wget
# Create non-root user
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
@@ -50,8 +59,8 @@ ENV WARPBOX_DATA_DIR=/app/data \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
WARPBOX_ADMIN_ENABLED=true \
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \
WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
@@ -60,6 +69,9 @@ ENV WARPBOX_DATA_DIR=/app/data \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
VOLUME ["/app/data"]
CMD ["./warpbox", "run", "--addr", ":8080"]

View File

@@ -86,8 +86,8 @@ go run ./cmd run --addr :3000
## Configuration
WarpBox loads defaults, applies environment variables at startup, then applies
safe admin settings overrides from BadgerDB. Hard storage and global limit
settings remain environment controlled.
safe admin settings overrides from BadgerDB. Storage path settings remain
environment controlled.
| Variable | Default | What it does |
| --- | ---: | --- |
@@ -108,16 +108,16 @@ settings remain environment controlled.
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. |
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. |
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. |
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. |
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. |
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
Size limits also accept `_MB` variants for the same settings.
Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
Example:
@@ -189,3 +189,25 @@ keeps most behavior easy to follow from the Go handlers and the small browser
scripts.
For a short implementation overview, see [docs/tech.md](docs/tech.md).
## Docker / Podman
If you are using Podman, please pay attention in the [docker-compose.yml](./docker-compose.example.yml) example
file that has been provided, there's comments in regards to differences between the two.
When it comes to building the image, please make sure that you basically set the `--format docker` in the podman
build command, otherwise it won't have HealthChecks and other issues might arise.
Tip: Put the following in `~/.config/containers/containers.conf`
```toml
[engine]
image_default_format = "docker"
```
For just running the docker-compose.yml with docker image format:
```bash
BUILDAH_FORMAT=docker podman compose up --build
```

View File

@@ -175,22 +175,6 @@ func buildExtraEnvRows(includeHidden bool) []envRow {
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
}
sizePairs := []struct {
bytesEnv string
mbEnv string
label string
}{
{"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "Global max file size"},
{"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "Global max box size"},
{"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "Default user max file size"},
{"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "Default user max box size"},
}
for _, pair := range sizePairs {
extra = append(extra, envRow{EnvName: pair.bytesEnv, Key: pair.bytesEnv, Label: pair.label + " (bytes)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
extra = append(extra, envRow{EnvName: pair.mbEnv, Key: pair.mbEnv, Label: pair.label + " (MB)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
}
return extra
}

View File

@@ -5,6 +5,8 @@ services:
ports:
- "8080:8080"
volumes:
# For podman please use :Z
# - ./data:/app/data:Z
- ./data:/app/data
env_file:
- .env

View File

@@ -150,16 +150,16 @@ Primary environment variables:
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES`
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
- `WARPBOX_SESSION_TTL_SECONDS`
- `WARPBOX_BOX_POLL_INTERVAL_MS`
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED`
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
accepts `auto`, `true`, or `false`.
The HTTP listen address is configured through the CLI flag:

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -35,7 +35,7 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
t.Setenv("WARPBOX_API_ENABLED", "false")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "0.5")
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
@@ -51,7 +51,7 @@ func TestEnvironmentOverrides(t *testing.T) {
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
t.Fatal("expected boolean environment overrides to be applied")
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.BoxPollIntervalMS != 2000 {
@@ -70,25 +70,25 @@ func TestEnvironmentOverrides(t *testing.T) {
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
}
}
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
cfg, err := Load()
@@ -96,7 +96,7 @@ func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
}
@@ -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, "0.5"); err != nil {
t.Fatalf("expected global max file size override to succeed, got %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
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")
}
}
@@ -169,12 +175,16 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",

View File

@@ -12,10 +12,10 @@ 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: 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: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
@@ -50,6 +50,7 @@ func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
}
func Definition(key string) (SettingDefinition, bool) {
key = NormalizeLegacySettingKey(key)
for _, def := range Definitions {
if def.Key == key {
return def, true
@@ -58,6 +59,35 @@ func Definition(key string) (SettingDefinition, bool) {
return SettingDefinition{}, false
}
func NormalizeLegacySettingKey(key string) string {
switch key {
case "global_max_file_size_bytes":
return SettingGlobalMaxFileSizeBytes
case "global_max_box_size_bytes":
return SettingGlobalMaxBoxSizeBytes
case "default_user_max_file_size_bytes":
return SettingDefaultUserMaxFileBytes
case "default_user_max_box_size_bytes":
return SettingDefaultUserMaxBoxBytes
default:
return key
}
}
func NormalizeOverrideInput(key string, value string) (string, string, error) {
normalizedKey := NormalizeLegacySettingKey(key)
switch key {
case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes":
parsed, err := parseInt64(value, 0)
if err != nil {
return normalizedKey, "", err
}
return normalizedKey, formatGigabytesFromBytes(parsed), nil
default:
return normalizedKey, value, nil
}
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
@@ -29,6 +28,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.
@@ -98,17 +98,18 @@ func Load() (*Config, error) {
}
sizeEnvVars := []struct {
key string
gbName string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
@@ -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, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
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 {
@@ -217,14 +225,23 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
parsed, err := parseGigabytes(rawGB, float64(min))
if err != nil {
return fmt.Errorf("%s: %w", gbName, err)
}
*target = parsed
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
@@ -236,12 +253,9 @@ func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
parsedBytes := parsedMB * 1000 * 1000
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
return nil
}

View File

@@ -27,10 +27,10 @@ const (
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
@@ -45,6 +45,7 @@ const (
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
SettingTypeSizeGB SettingType = "size_gb"
)
type SettingDefinition struct {
@@ -97,4 +98,5 @@ type Config struct {
sources map[string]Source
values map[string]string
defaults map[string]string
}

View File

@@ -0,0 +1,76 @@
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
}
normalized := make(map[string]string, len(payload.Overrides))
for key, value := range payload.Overrides {
nextKey, nextValue, err := NormalizeOverrideInput(key, value)
if err != nil {
return nil, err
}
normalized[nextKey] = nextValue
}
return normalized, 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)
}

View File

@@ -39,6 +39,12 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeSizeGB:
parsed, err := parseGigabytes(value, float64(def.Minimum))
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum)
if err != nil {
@@ -76,6 +82,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:
@@ -83,6 +93,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
@@ -113,3 +127,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]
}

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"math"
"strconv"
"strings"
)
@@ -39,6 +40,46 @@ func parseInt(value string, min int) (int, error) {
return int(parsed64), nil
}
const bytesPerGigabyte = 1024 * 1024 * 1024
func parseGigabytes(value string, min float64) (int64, error) {
raw := strings.TrimSpace(value)
lower := strings.ToLower(raw)
if strings.HasSuffix(lower, "gb") {
raw = strings.TrimSpace(raw[:len(raw)-2])
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be a number of GB")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min))
}
bytes := parsed * bytesPerGigabyte
if bytes > math.MaxInt64 {
return 0, fmt.Errorf("is too large")
}
return int64(math.Round(bytes)), nil
}
func formatGigabytesFromBytes(bytes int64) string {
if bytes <= 0 {
return "0"
}
value := float64(bytes) / bytesPerGigabyte
return trimTrailingZeros(value)
}
func trimTrailingZeros(value float64) string {
text := strconv.FormatFloat(value, 'f', 3, 64)
text = strings.TrimRight(text, "0")
text = strings.TrimRight(text, ".")
if text == "" {
return "0"
}
return text
}
func formatBool(value bool) string {
if value {
return "true"

View File

@@ -3,6 +3,7 @@ package routing
import "github.com/gin-gonic/gin"
type Handlers struct {
Health gin.HandlerFunc
Index gin.HandlerFunc
ShowBox gin.HandlerFunc
BoxLogin gin.HandlerFunc
@@ -16,9 +17,25 @@ 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
AdminUsers 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) {
router.GET("/health", handlers.Health)
router.GET("/", handlers.Index)
router.GET("/box/:id", handlers.ShowBox)
@@ -36,4 +53,21 @@ 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("/users", handlers.AdminUsers)
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
View 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
View 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()
}
}

View File

@@ -0,0 +1,471 @@
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
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
if currentOverrides == nil {
currentOverrides = map[string]string{}
}
for key, value := range request.Values {
currentOverrides[key] = value
}
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
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)
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
return
}
if overrideSet == nil {
overrideSet = map[string]string{}
}
targetKeys := map[string]bool{}
for _, key := range request.Keys {
targetKeys[config.NormalizeLegacySettingKey(key)] = true
}
if len(targetKeys) == 0 {
overrideSet = map[string]string{}
} else {
for key := range targetKeys {
delete(overrideSet, 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": "Selected overrides cleared; environment and defaults now apply",
"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 {
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", key, err)
}
key = normalizedKey
value := normalizedValue
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 in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.",
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.",
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]
}

View File

@@ -0,0 +1,271 @@
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","global_max_file_size_gb":"0.5"}}`))
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)
}
if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes)
}
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)
}
if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists {
t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides)
}
if _, exists := overrides[config.SettingSessionTTLSeconds]; exists {
t.Fatalf("expected untouched setting to stay out of overrides, 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 respect environment and restore APIEnabled=false")
}
}
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_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"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, "")
}
}

20
lib/server/admin_users.go Normal file
View File

@@ -0,0 +1,20 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "users",
})
}

View File

@@ -1,6 +1,9 @@
package server
import (
"encoding/json"
"html/template"
"path/filepath"
"time"
"github.com/gin-contrib/gzip"
@@ -13,6 +16,7 @@ import (
type App struct {
config *config.Config
settingsOverridesPath string
}
func Run(addr string) error {
@@ -23,15 +27,28 @@ 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{
Health: app.handleHealth,
Index: app.handleIndex,
ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin,
@@ -45,6 +62,21 @@ 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,
AdminUsers: app.handleAdminUsers,
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,7 +87,37 @@ 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)
}
func (app *App) handleHealth(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
})
}

6
run.sh
View File

@@ -15,9 +15,9 @@ export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXP
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
# Storage and expiry limits used by the upload UI and backend validators.
# Use megabytes here; WarpBox converts these to bytes internally.
export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB
# Use decimal gigabytes here. Examples: 2, 4, 0.5
export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours

738
static/css/admin.css Normal file
View 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
View 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
View 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
View 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); }
}

516
static/css/settings.css Normal file
View File

@@ -0,0 +1,516 @@
.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-input-row {
display: flex;
align-items: center;
gap: 6px;
}
.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;
}
}

309
static/css/users.css Normal file
View File

@@ -0,0 +1,309 @@
.users-page-body {
display: grid;
gap: 10px;
}
.users-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
gap: 10px;
padding: 10px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-hero h2 {
margin: 0 0 6px;
font-size: 24px;
line-height: 24px;
}
.users-hero p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 16px;
}
.users-hero-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.users-stat-card {
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.users-stat-card p {
margin: 0 0 6px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.users-stat-card strong {
font-size: 24px;
line-height: 24px;
}
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
gap: 10px;
min-height: 0;
}
.users-panel {
min-height: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-panel-header {
flex: 0 0 auto;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.users-panel-title span {
font-weight: normal;
color: #444444;
font-size: 12px;
line-height: 12px;
}
.users-panel-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.users-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.users-list-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
overflow: hidden;
}
.users-form-grid {
display: grid;
gap: 8px;
}
.users-row-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.users-field {
display: grid;
gap: 4px;
font-size: 12px;
line-height: 12px;
}
.users-input,
.users-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;
}
.users-check {
min-height: 20px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.users-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.users-action-button,
.users-tool-button,
.users-page-button {
min-width: 70px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.users-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.users-table th,
.users-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; }
.users-col-check { width: 30px; }
.users-col-actions { width: 136px; }
.users-username {
display: grid;
gap: 2px;
}
.users-username strong {
font-size: 13px;
line-height: 13px;
}
.users-muted {
color: #555555;
font-size: 11px;
line-height: 11px;
}
.users-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;
}
.users-pill.active { background: #def2e0; }
.users-pill.pending { background: #fff1c9; }
.users-pill.disabled { background: #ffdcdc; }
.users-row-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.users-row-button {
min-width: 60px;
height: 22px;
padding: 0 6px;
font-size: 12px;
line-height: 12px;
}
.users-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {
grid-template-columns: 1fr;
}
}

View File

@@ -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
View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("'", "&#39;");
}
[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();
})();

View 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();
})();

459
static/js/admin/settings.js Normal file
View File

@@ -0,0 +1,459 @@
(() => {
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 === "size_gb") {
if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false;
else if (Number(value) < row.minimum) valid = false;
} else 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 && isDirty(row)) 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.type === "size_gb"
? "Use GB values. Decimals allowed, for example `0.5`."
: (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() {
const values = draftValues();
if (Object.keys(values).length === 0) {
showToast("No changed settings to save", "info");
return;
}
try {
const payload = await postJSON("/admin/settings/save", { values });
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 resetSingleSetting(row) {
if (row.locked || !row.input) return;
if (isDirty(row)) {
row.input.value = row.element.dataset.original || "";
updateView();
showToast(`${row.label} draft cleared`);
return;
}
try {
const payload = await postJSON("/admin/settings/reset", { keys: [row.key] });
hydrateRows(payload.rows);
showToast(`${row.label} reset`, "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 clears saved admin overrides.</p>
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</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", () => resetSingleSetting(row));
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();
})();

304
static/js/admin/users.js Normal file
View File

@@ -0,0 +1,304 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body");
const search = document.getElementById("users-search");
const status = document.getElementById("users-status");
const role = document.getElementById("users-role-filter");
const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check");
const pageInfo = document.getElementById("users-page-info");
const visiblePill = document.getElementById("visible-pill");
const selectedPill = document.getElementById("users-selected-pill");
const prevBtn = document.getElementById("users-prev");
const nextBtn = document.getElementById("users-next");
const selectVisible = document.getElementById("select-visible");
const form = document.getElementById("users-form");
const modeInput = document.getElementById("users-mode");
const usernameInput = document.getElementById("users-username");
const emailInput = document.getElementById("users-email");
const roleInput = document.getElementById("users-role");
const planInput = document.getElementById("users-plan");
const statusLeft = document.getElementById("users-status-left");
if (!body || !search || !status || !role || !sort || !size) return;
const users = [
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
];
const state = { page: 1, selected: new Set() };
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
}
function filtered() {
const query = search.value.trim().toLowerCase();
const statusFilter = status.value;
const roleFilter = role.value;
const sortBy = sort.value;
const rows = users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
const matchesRole = roleFilter === "all" || user.role === roleFilter;
return matchesQuery && matchesStatus && matchesRole;
});
rows.sort((a, b) => {
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
return a.username.localeCompare(b.username);
});
return rows;
}
function paged(rows) {
const perPage = Number(size.value || 12);
const pages = Math.max(1, Math.ceil(rows.length / perPage));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start };
}
function statusPill(value) {
return `<span class="users-pill ${value}">${value}</span>`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const row = document.createElement("tr");
row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td>
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
<td title="${user.email}">${user.email}</td>
<td>${statusPill(user.status)}</td>
<td>${user.role}</td>
<td>${user.plan}</td>
<td>${user.boxes}</td>
<td>${user.lastSeen}</td>
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
`;
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
syncSelected();
syncMasterCheck();
});
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
toast(`Mock user preview: ${user.username}`);
});
return row;
}
function syncSelected() {
selectedPill.textContent = `${state.selected.size} selected`;
}
function syncMasterCheck() {
const checks = Array.from(body.querySelectorAll(".row-check"));
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
}
function renderStats() {
document.getElementById("stat-total").textContent = String(users.length);
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
}
function render() {
const rows = filtered();
const page = paged(rows);
body.innerHTML = "";
page.rows.forEach((user) => body.appendChild(renderRow(user)));
visiblePill.textContent = `${rows.length} visible`;
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= page.pages;
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
syncSelected();
syncMasterCheck();
}
function clearFilters() {
search.value = "";
status.value = "all";
role.value = "all";
sort.value = "username";
state.page = 1;
render();
}
function applyBulk(nextStatus) {
const selected = users.filter((user) => state.selected.has(user.id));
if (!selected.length) {
toast("Select one or more users first", "warning");
return;
}
selected.forEach((user) => { user.status = nextStatus; });
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
renderStats();
render();
}
function runCommand(command) {
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break;
case "create":
modeInput.value = "create";
toast("Create mode selected");
break;
case "export":
toast("Mock CSV export complete");
break;
case "bulk-disable":
applyBulk("disabled");
break;
case "bulk-enable":
applyBulk("active");
break;
case "bulk-revoke":
toast("Mock session revocation queued");
break;
case "refresh":
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
state.page = 1;
render();
break;
case "clear-filters":
clearFilters();
break;
case "policy-help":
toast("Policy editor will be added in user details later.");
break;
case "mock-note":
toast("Mock-only page: no backend writes yet.");
break;
default:
toast(`Mock action: ${command}`);
break;
}
}
[search, status, role, sort, size].forEach((el) => {
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
render();
});
});
prevBtn.addEventListener("click", () => {
state.page -= 1;
render();
});
nextBtn.addEventListener("click", () => {
state.page += 1;
render();
});
masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (!checkbox) return;
checkbox.checked = masterCheck.checked;
const userID = row.querySelector(".users-muted")?.textContent || "";
if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID);
});
syncSelected();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || "";
if (!checkbox) return;
checkbox.checked = true;
state.selected.add(userID);
});
syncSelected();
syncMasterCheck();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const mode = modeInput.value;
if (!username || !email) {
toast("Username and email are required", "warning");
return;
}
users.unshift({
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
username,
email,
status: mode === "invite" ? "pending" : "active",
role: roleInput.value,
plan: planInput.value,
boxes: 0,
created: new Date().toISOString().slice(0, 10),
lastSeen: "never"
});
form.reset();
modeInput.value = "invite";
renderStats();
render();
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
});
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");
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
event.preventDefault();
runCommand("invite");
}
});
renderStats();
render();
})();

View File

@@ -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
View 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 users 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
View 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 }}

View 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 }}

View 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 }}

View File

@@ -0,0 +1,19 @@
{{ 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 "users" }} is-active{{ end }}" href="/admin/users">Users</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 }}

View File

@@ -0,0 +1,244 @@
{{ 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 }}
<div class="setting-input-row">
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
{{ if eq .Type "size_gb" }}<span class="settings-badge badge-default">GB</span>{{ end }}
</div>
{{ end }}
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if eq .Type "size_gb" }}Use GB values. Decimals allowed, for example `0.5`.{{ 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 }}

195
templates/admin/users.html Normal file
View File

@@ -0,0 +1,195 @@
{{ define "admin/users.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Users</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/users.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 Users</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="Users 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="invite"><span>I</span><span>Invite user</span><span>Ctrl+I</span></button>
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
<div class="menu-separator"></div>
<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">Users</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-enable"><span>U</span><span>Enable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-revoke"><span>R</span><span>Revoke sessions</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="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button>
<button class="menu-action" type="button" data-command="pending-only"><span>P</span><span>Show pending invites</span><span></span></button>
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</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="policy-help"><span>?</span><span>User policy notes</span><span></span></button>
<button class="menu-action" type="button" data-command="mock-note"><span>M</span><span>Mock-only notes</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body users-page-body">
<section class="users-hero">
<div>
<h2>Accounts, invites, and access</h2>
<p>Mock administrative users view for creation, invitation, filtering, and safe bulk actions.</p>
</div>
<div class="users-hero-actions">
<button class="win98-button users-action-button" type="button" data-command="invite">Invite user</button>
<button class="win98-button users-action-button" type="button" data-command="create">Create local user</button>
<button class="win98-button users-action-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button users-action-button" type="button" data-command="policy-help">Policy notes</button>
</div>
</section>
<section class="users-summary-grid">
<article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article>
<article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article>
<article class="users-stat-card is-warning"><p>Pending invites</p><strong id="stat-pending">0</strong></article>
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
</section>
<section class="users-main-grid">
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Create or invite <span>mock only</span></div>
</div>
<div class="users-panel-body">
<form id="users-form" class="users-form-grid">
<label class="users-field">Mode
<select class="users-select" id="users-mode">
<option value="invite">Send invite</option>
<option value="create">Create local user</option>
</select>
</label>
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label>
<div class="users-row-two">
<label class="users-field">Role
<select class="users-select" id="users-role">
<option value="uploader">uploader</option>
<option value="operator">operator</option>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</label>
<label class="users-field">Plan
<select class="users-select" id="users-plan">
<option value="standard">standard</option>
<option value="trusted">trusted</option>
<option value="guest-like">guest-like</option>
<option value="unlimited">unlimited</option>
</select>
</label>
</div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="reset">Clear</button>
<button class="win98-button users-action-button" type="submit">Apply</button>
</div>
</form>
</div>
</section>
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
<div class="users-panel-tools">
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button>
</div>
</div>
<div class="users-panel-body users-list-body">
<div class="users-toolbar-grid">
<input class="users-input" id="users-search" type="search" placeholder="Search username or email">
<select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select>
<select class="users-select" id="users-role-filter"><option value="all">all roles</option><option value="admin">admin</option><option value="operator">operator</option><option value="uploader">uploader</option><option value="viewer">viewer</option></select>
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
</div>
<div class="users-table-wrap">
<table class="users-table">
<thead>
<tr>
<th class="users-col-check"><input type="checkbox" id="users-master-check"></th>
<th>User</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Plan</th>
<th>Boxes</th>
<th>Last seen</th>
<th class="users-col-actions">Actions</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
<div class="users-pagination">
<span id="users-page-info">Page 1</span>
<span id="users-selected-pill">0 selected</span>
<div>
<button class="win98-button users-page-button" type="button" id="users-prev">Prev</button>
<button class="win98-button users-page-button" type="button" id="users-next">Next</button>
</div>
</div>
</div>
</section>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="users-status-left">Ready. Client-side mock data only.</span>
<span>server paging planned</span>
<span>admin only</span>
</footer>
</div>
</div>
</div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script>
</body>
</html>
{{ end }}