Compare commits
6
Commits
1.0.4
..
2c06265d11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c06265d11 | ||
|
|
930cdfdff9 | ||
|
|
ab3fab3e68 | ||
|
|
dd5789fcd9 | ||
|
|
fca4286d87 | ||
|
|
7cfddfc7ef |
No files matched your search
File renamed without changes.
@@ -2,6 +2,34 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006).
|
||||
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section).
|
||||
- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
||||
- Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts.
|
||||
- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions.
|
||||
- Added a dedicated plain TypeScript admin module for tab navigation, media CRUD actions, schedule/system analytics refresh, and moderation workflows without Alpine dependencies.
|
||||
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||
|
||||
### Changed
|
||||
|
||||
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback.
|
||||
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload.
|
||||
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
|
||||
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||
- Code-split `hls.js` via lazy loading in the viewer bootstrap to shrink the main frontend bundle and isolate stream playback logic into a dedicated chunk.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup.
|
||||
- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage.
|
||||
- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting.
|
||||
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
|
||||
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
||||
|
||||
## [1.0.3] - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,7 +9,7 @@ RUN go mod tidy
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk --no-cache add ca-certificates curl curl
|
||||
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||
|
||||
|
||||
+869
-37
File diff suppressed because it is too large.
Load diff
@@ -18,14 +18,15 @@ RUN apk add --no-cache \
|
||||
bash
|
||||
# Set working directory for playout
|
||||
WORKDIR /app
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/hls /app/media /app/playlists
|
||||
# Copy the playout start script and the built playlist manager binary
|
||||
COPY start-playout.sh /app/
|
||||
# Copy demo data for out-of-the-box playout
|
||||
COPY demodata/ /app/
|
||||
# Copy the built playlist manager binary from the builder stage
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/hls
|
||||
# Set execute permissions for the start script and playlist manager binary
|
||||
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
||||
# Expose HLS directory (will be served by nginx)
|
||||
VOLUME ["/app/hls"]
|
||||
|
||||
+193
-2
@@ -315,11 +315,96 @@ All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s
|
||||
|
||||
### Monitoring & Resilience
|
||||
|
||||
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook.
|
||||
Linear Void uses a two-layer monitoring model:
|
||||
|
||||
1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
|
||||
2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
|
||||
|
||||
#### Prometheus Baseline
|
||||
|
||||
- Config file: `monitoring/prometheus/prometheus.yml`
|
||||
- Default scrape interval: `15s`
|
||||
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
|
||||
|
||||
Recommended next-step scrape targets (if/when exporters are introduced):
|
||||
|
||||
- `nginx` (via nginx-exporter)
|
||||
- `redis` (via redis-exporter)
|
||||
- `postgres` (via postgres-exporter)
|
||||
- `node` (via node-exporter)
|
||||
|
||||
#### Grafana Dashboards
|
||||
|
||||
- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
|
||||
- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
|
||||
|
||||
Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
|
||||
|
||||
#### Alerting Rules (Prometheus Rule Groups)
|
||||
|
||||
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
|
||||
|
||||
Minimum recommended alert set:
|
||||
|
||||
- **Availability**
|
||||
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
|
||||
- **Chat service health**
|
||||
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
|
||||
- **Playout continuity**
|
||||
No HLS segment progression for `30-60s` (from playout-specific metric when available).
|
||||
- **Prometheus capacity and ingestion health**
|
||||
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
|
||||
- **Latency and user-impact**
|
||||
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
|
||||
|
||||
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
|
||||
|
||||
Operational resilience baseline:
|
||||
|
||||
- Emergency slate fallback for playout failure.
|
||||
- Auto-restart policies for playout/chat/nginx containers.
|
||||
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
|
||||
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
|
||||
|
||||
### Security & Moderation
|
||||
|
||||
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default.
|
||||
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
||||
|
||||
#### Transport and Edge Protection
|
||||
|
||||
- Public traffic uses HTTPS/WSS at the edge.
|
||||
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
||||
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||
|
||||
#### Reverse Proxy Enforcement (nginx)
|
||||
|
||||
- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
|
||||
- Unauthorized admin UI requests are redirected to `/admin/login/`.
|
||||
- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
|
||||
- WebSocket upgrade headers are explicitly forwarded for `/ws`.
|
||||
|
||||
#### Admin Authentication and Session Security
|
||||
|
||||
- Admin authentication is cookie-session based (`linearvoid_admin_session`).
|
||||
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
||||
- Session expiration is enforced server-side.
|
||||
|
||||
#### Rate Limiting and Abuse Controls
|
||||
|
||||
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
||||
- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
|
||||
- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
|
||||
|
||||
#### CORS and Cross-Origin Behavior
|
||||
|
||||
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
|
||||
- Admin/authenticated routes are expected to run same-origin behind nginx auth gates.
|
||||
|
||||
#### Moderation and Privacy
|
||||
|
||||
- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
|
||||
- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
|
||||
- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
|
||||
|
||||
### Chat
|
||||
|
||||
@@ -354,6 +439,112 @@ These ADRs together form the canonical decision log.
|
||||
|
||||
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
||||
|
||||
### Performance Benchmarks and Capacity Planning
|
||||
|
||||
#### Baseline SLO Targets (v1)
|
||||
|
||||
- Playback startup: target < 4s after first manifest request on warm cache.
|
||||
- Steady-state playback: no segment starvation during normal operation.
|
||||
- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
|
||||
- Chat delivery latency: p95 < 500ms inside same region under normal load.
|
||||
- Admin page responsiveness: p95 API response < 1s for common read operations.
|
||||
|
||||
#### Workload Assumptions
|
||||
|
||||
- Primary audience in US; origin in US-East.
|
||||
- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
|
||||
- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
|
||||
|
||||
#### Initial Capacity Model
|
||||
|
||||
- Video bitrate envelopes (from ADR-001):
|
||||
- 720p30: ~2.6-4.2 Mbps total with audio.
|
||||
- 1080p30: ~5.1-8.2 Mbps total with audio.
|
||||
- Storage planning for 48h rolling library:
|
||||
- 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
|
||||
- 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
|
||||
- Cost behavior:
|
||||
- Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
|
||||
|
||||
#### Benchmark Plan
|
||||
|
||||
- Playback path:
|
||||
- Measure time-to-first-frame and rebuffer events from multiple US regions.
|
||||
- Track manifest freshness and segment availability at edge.
|
||||
- Chat path:
|
||||
- Load-test concurrent websocket connections and message fan-out.
|
||||
- Measure p50/p95 send-to-receive latency and Redis fallback behavior.
|
||||
- Admin/API path:
|
||||
- Benchmark playlist/system endpoints for p95 latency and error rate.
|
||||
- Platform path:
|
||||
- Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
|
||||
|
||||
#### Capacity Triggers and Actions
|
||||
|
||||
- Trigger: sustained viewer growth with rising cache-miss ratio.
|
||||
- Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
|
||||
- Trigger: chat p95 latency drift or websocket error growth.
|
||||
- Action: scale chat service, tune Redis, separate noisy moderation workloads.
|
||||
- Trigger: Prometheus WAL/compaction failures or query saturation.
|
||||
- Action: tune retention/cardinality, increase resources, split dashboards/queries.
|
||||
- Trigger: origin CPU/memory pressure on playout host.
|
||||
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
|
||||
|
||||
### Disaster Recovery and Backup Procedures
|
||||
|
||||
#### Recovery Targets
|
||||
|
||||
- RPO target: <= 24h for chat/moderation/admin metadata.
|
||||
- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
|
||||
|
||||
#### Backup Scope
|
||||
|
||||
- Persistent service volumes:
|
||||
- Postgres data (`postgres-data` volume).
|
||||
- Redis append-only persistence (`redis-data` volume) where enabled.
|
||||
- Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
|
||||
- Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
|
||||
- Host-mounted state directories:
|
||||
- `data/chat`
|
||||
- `data/playlist`
|
||||
- `data/media` (authoritative source may be R2/object storage)
|
||||
- `data/hls` (optional; can be regenerated by playout)
|
||||
- Configuration and infra source of truth:
|
||||
- Compose files, nginx config, monitoring configs, and environment template.
|
||||
|
||||
#### Backup Cadence
|
||||
|
||||
- Daily logical Postgres backups (plus weekly full snapshot).
|
||||
- Daily sync of chat/playlist state directories.
|
||||
- Weekly integrity check by restoring backups into an isolated environment.
|
||||
- Retention policy baseline:
|
||||
- Daily backups: 14 days
|
||||
- Weekly backups: 8 weeks
|
||||
- Monthly backups: 6 months
|
||||
|
||||
#### Recovery Runbook (Minimum)
|
||||
|
||||
1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
|
||||
2. Restore Postgres and Redis persisted state.
|
||||
3. Restore `data/playlist` and chat persistence directories.
|
||||
4. Rehydrate media from authoritative object storage if local media is missing.
|
||||
5. Start stack and validate in order:
|
||||
- `playout` health endpoint
|
||||
- `chat` health endpoint
|
||||
- nginx `/health`
|
||||
- HLS playlist availability
|
||||
- websocket chat connectivity
|
||||
6. Verify admin authentication and moderation audit visibility.
|
||||
7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
|
||||
|
||||
#### Failure Modes and Strategy
|
||||
|
||||
- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
|
||||
- Storage corruption: restore last known-good snapshot and replay latest logical backups.
|
||||
- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
|
||||
|
||||
Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
|
||||
|
||||
## Risks and Technical Debts
|
||||
|
||||
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-08-02
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-08-04
|
||||
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
||||
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
||||
|
||||
@@ -12,89 +12,78 @@ The system needs persistent storage for:
|
||||
- Chat messages and history (public rooms and private DMs).
|
||||
- User accounts, roles, and ban lists.
|
||||
- Moderation audit log (immutable record of admin and moderator actions).
|
||||
- Playlist metadata and schedule overrides.
|
||||
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
|
||||
- Playlist and media metadata used by admin flows.
|
||||
- System configuration visibility in admin APIs.
|
||||
|
||||
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration.
|
||||
The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
|
||||
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
|
||||
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
|
||||
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
|
||||
5. **Auditability** – Moderation log must be immutable and queryable.
|
||||
6. **Cost** – Avoid managed database costs during early stages.
|
||||
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
|
||||
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
|
||||
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
|
||||
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
|
||||
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
|
||||
|
||||
## Decision
|
||||
|
||||
**Primary storage for v1: SQLite** (embedded in the chat and admin services).
|
||||
**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
|
||||
|
||||
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500).
|
||||
**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
|
||||
|
||||
**Admin Interface Capabilities** (extension of ADR-005):
|
||||
**Current Backend Behavior**:
|
||||
|
||||
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status.
|
||||
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
|
||||
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
|
||||
- Audit log of all configuration changes.
|
||||
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
|
||||
- `postgres://` or `postgresql://` selects PostgreSQL driver.
|
||||
- `sqlite://` selects SQLite driver.
|
||||
- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
|
||||
- GORM AutoMigrate is used for chat/admin data models.
|
||||
|
||||
**Technical Details**:
|
||||
**Media Model Decision (Admin UI Feature Track)**:
|
||||
|
||||
- Use SQLite with WAL mode for better concurrency.
|
||||
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
|
||||
- Store large chat history in a separate table with retention policy (e.g., 30 days).
|
||||
- Moderation log as an append-only table with triggers for immutability.
|
||||
|
||||
**Folder Placement** (per ADR-002):
|
||||
|
||||
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
|
||||
- Admin UI pages for storage management in `frontend/admin/system/`.
|
||||
- Media metadata is persisted in the same database as messages and moderation logs.
|
||||
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
|
||||
- The database is the source of truth for media library metadata and future runtime analytics.
|
||||
|
||||
## Rationale
|
||||
|
||||
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
|
||||
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
|
||||
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
|
||||
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
|
||||
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
|
||||
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
|
||||
- This aligns architecture documentation with implemented behavior and avoids drift.
|
||||
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**:
|
||||
|
||||
- Zero-config database for quick local development and small-scale production.
|
||||
- Simple backups (just copy the `.db` file).
|
||||
- Admin UI can directly manage and monitor storage without external tools.
|
||||
- Better write concurrency and query flexibility for chat + admin workloads.
|
||||
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
|
||||
- Easier extension for admin analytics and media metadata features.
|
||||
|
||||
**Negative / Risks**:
|
||||
|
||||
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
|
||||
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
|
||||
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
|
||||
- Additional service to operate versus embedded-only SQLite.
|
||||
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
|
||||
- Requires backup/restore procedures for PostgreSQL volume in production runbooks.
|
||||
|
||||
**Alternatives Considered**:
|
||||
## Alternatives Considered
|
||||
|
||||
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost).
|
||||
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
|
||||
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
|
||||
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
|
||||
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
|
||||
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
|
||||
|
||||
## Implementation Plan
|
||||
## Implementation Status
|
||||
|
||||
1. Create this ADR-006 (done).
|
||||
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
|
||||
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
|
||||
4. Add configuration reload capability without full restart where possible.
|
||||
5. Write migration script from SQLite to PostgreSQL.
|
||||
6. Update `docker-compose.yml` to optionally include PostgreSQL.
|
||||
7. Add tests for data access patterns and admin configuration changes.
|
||||
|
||||
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
|
||||
1. ADR updated to match implemented state. Done.
|
||||
2. Compose includes PostgreSQL service and persistent volume. Done.
|
||||
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
|
||||
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
|
||||
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
||||
- ADR-003: Preference for operational simplicity and Go backend.
|
||||
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities.
|
||||
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
|
||||
|
||||
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
|
||||
- ADR-005: Admin interface for user management, moderation, and analytics.
|
||||
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
|
||||
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
|
||||
+300
-760
File diff suppressed because it is too large.
Load diff
@@ -5,30 +5,27 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin Login</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="../../src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
|
||||
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
|
||||
<div class="mb-8">
|
||||
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
|
||||
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
|
||||
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
|
||||
</div>
|
||||
<body class="auth-body">
|
||||
<main class="auth-shell" aria-label="Admin authentication">
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<p class="auth-kicker">Linear Void</p>
|
||||
<h1 id="auth-title">Admin Login</h1>
|
||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||
|
||||
<form id="login-form" class="space-y-4">
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Username</span>
|
||||
<input id="username" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Password</span>
|
||||
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
|
||||
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
|
||||
</form>
|
||||
<form id="login-form" class="form-stack" novalidate>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||
|
||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
<p id="status" class="status-line" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -42,7 +39,7 @@
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
status.textContent = 'Signing in...';
|
||||
status.className = 'text-sm text-slate-400 min-h-6';
|
||||
status.className = 'status-line';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
@@ -59,7 +56,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to sign in', error);
|
||||
status.textContent = 'Login failed. Check your credentials.';
|
||||
status.className = 'text-sm text-rose-400 min-h-6';
|
||||
status.className = 'status-line status-error';
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
|
||||
+43
-50
@@ -5,76 +5,69 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<div class="flex h-screen">
|
||||
<!-- Video Player -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<section class="viewer-main" aria-label="Video stream panel">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Linear Void</h1>
|
||||
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
<div class="now-playing-pill" aria-live="polite">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span id="now-playing">Loading schedule...</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center p-8 bg-black relative">
|
||||
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-emerald-400">● LIVE</div>
|
||||
<div id="overlay-title" class="font-medium"></div>
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite">
|
||||
<div class="now-playing-overlay-inner">
|
||||
<span class="live-label">LIVE</span>
|
||||
<span id="overlay-title" class="overlay-title"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
|
||||
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
|
||||
<div class="flex gap-6">
|
||||
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
|
||||
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>
|
||||
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="viewer-footer">
|
||||
<div>Latency: ~25s • Synced</div>
|
||||
<nav class="room-links" aria-label="Quick room switch">
|
||||
<a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
|
||||
<a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
|
||||
<a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- Chat Sidebar -->
|
||||
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar">
|
||||
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">●</span>
|
||||
<aside class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<!-- Messages -->
|
||||
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
|
||||
<!-- Messages populated by JS -->
|
||||
</div>
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<div class="flex gap-2">
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input flex-1 px-4 py-3 rounded-2xl text-sm focus:outline-none" onkeydown="if(event.key === 'Enter') sendMessage()">
|
||||
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button>
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-2 text-center">
|
||||
<p class="chat-note">
|
||||
Synced with video • Moderation active
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
Generated
+5
-209
@@ -8,11 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"hls.js": "^1.6.16",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.3"
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~6.0.2",
|
||||
@@ -375,131 +371,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.12",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
||||
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.10",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
|
||||
"integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -510,21 +381,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.399",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
|
||||
"integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -543,19 +399,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -854,6 +697,7 @@
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -868,19 +712,11 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -900,6 +736,7 @@
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -924,12 +761,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
|
||||
@@ -968,17 +799,12 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1018,36 +844,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
"vite": "^8.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"hls.js": "^1.6.16",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.3"
|
||||
"hls.js": "^1.6.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
import "./style.css";
|
||||
|
||||
type ServiceHealth = {
|
||||
name: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
checked: string;
|
||||
};
|
||||
|
||||
type MediaEntry = {
|
||||
id?: number;
|
||||
name: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
size?: number;
|
||||
runtime?: string;
|
||||
modifiedAt?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const bodyDataset = document.body.dataset;
|
||||
const moderationLogLimit = Number.parseInt(
|
||||
bodyDataset.adminModerationLogLimit || "",
|
||||
10,
|
||||
);
|
||||
|
||||
const adminConfig = {
|
||||
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
|
||||
defaultRoom: bodyDataset.adminDefaultRoom || "main",
|
||||
moderationLogLimit:
|
||||
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
|
||||
? moderationLogLimit
|
||||
: 50,
|
||||
};
|
||||
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||
|
||||
const state = {
|
||||
mediaUploading: false,
|
||||
moderationSubmitting: false,
|
||||
playlistSaving: false,
|
||||
};
|
||||
|
||||
const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
|
||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||
const wrap = uploadProgressWrap();
|
||||
const progress = uploadProgressBar();
|
||||
const text = uploadProgressText();
|
||||
if (!wrap || !progress || !text) return;
|
||||
|
||||
wrap.hidden = false;
|
||||
progress.max = total;
|
||||
progress.value = value;
|
||||
|
||||
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
|
||||
text.textContent = `${label} (${pct}%)`;
|
||||
};
|
||||
|
||||
const hideUploadProgress = () => {
|
||||
const wrap = uploadProgressWrap();
|
||||
if (wrap) wrap.hidden = true;
|
||||
};
|
||||
|
||||
const getFileFingerprint = (file: File) =>
|
||||
`${file.name}:${file.size}:${file.lastModified}`;
|
||||
|
||||
const getResumeStorageKey = (fingerprint: string) =>
|
||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
|
||||
const getStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
return window.localStorage.getItem(getResumeStorageKey(fingerprint)) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const setStoredUploadId = (fingerprint: string, uploadId: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(getResumeStorageKey(fingerprint), uploadId);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
window.localStorage.removeItem(getResumeStorageKey(fingerprint));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const readErrorMessage = async (response: Response) => {
|
||||
const fallback = `HTTP ${response.status}`;
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
return text || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (id: string, text: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
};
|
||||
|
||||
const setStatus = (id: string, text: string, isError = false) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle("status-error", isError);
|
||||
};
|
||||
|
||||
const fmtSize = (value?: number) => {
|
||||
if (!value || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
function switchTab(tabName: string) {
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
|
||||
const panels = document.querySelectorAll<HTMLElement>(".tab-panel");
|
||||
|
||||
triggers.forEach((trigger) => {
|
||||
const active = trigger.dataset.tab === tabName;
|
||||
trigger.classList.toggle("is-active", active);
|
||||
trigger.setAttribute("aria-selected", String(active));
|
||||
});
|
||||
|
||||
panels.forEach((panel) => {
|
||||
const active = panel.id === `tab-${tabName}`;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("is-active", active);
|
||||
});
|
||||
|
||||
if (tabName === "upload") refreshMediaLibrary();
|
||||
if (tabName === "playlist") refreshPlaylist();
|
||||
if (tabName === "schedule") refreshSchedule();
|
||||
if (tabName === "moderation") refreshModerationLogs();
|
||||
if (tabName === "users") refreshUsers();
|
||||
if (tabName === "analytics") refreshAnalytics();
|
||||
if (tabName === "system") refreshSystemSummary();
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
const response = await fetch("/api/admin/overview");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
setText("stat-viewers", String(payload.viewers ?? 0));
|
||||
setText("stat-stored-messages", String(payload.storedMessages ?? 0));
|
||||
setText("stat-moderation-actions", String(payload.moderationActions ?? 0));
|
||||
setText("stat-media-runtime", payload.mediaTotalRuntime || "00:00:00");
|
||||
setText("stat-media-count", `${payload.mediaCount || 0} files`);
|
||||
|
||||
setText("system-stored-messages", String(payload.storedMessages ?? 0));
|
||||
setText("system-redis-clients", String(payload.redisClients ?? 0));
|
||||
setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no");
|
||||
setText("system-redis-memory", payload.redisMemoryUsed || "unavailable");
|
||||
setText("system-db-driver", payload.databaseDriver || "unavailable");
|
||||
setText("system-generated-at", payload.generatedAt || "unavailable");
|
||||
|
||||
setStatus("overview-status", `Live data loaded at ${payload.generatedAt}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin overview", error);
|
||||
setStatus("overview-status", "Failed to load live admin data.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
setStatus("analytics-status", "Loading analytics...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/analytics");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
|
||||
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
|
||||
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
|
||||
setText(
|
||||
"analytics-moderation-actions",
|
||||
String(payload.moderationActions ?? 0),
|
||||
);
|
||||
setText("analytics-generated-at", payload.generatedAt || "unavailable");
|
||||
|
||||
const tbody = document.getElementById("analytics-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const rows = (payload.topRooms || []) as Array<{
|
||||
room: string;
|
||||
messageCount: number;
|
||||
}>;
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="2">No room analytics available yet.</td></tr>`;
|
||||
} else {
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${row.room}</td><td>${row.messageCount}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("analytics-status", "Analytics loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load analytics", error);
|
||||
setStatus("analytics-status", "Failed to load analytics.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlaylist() {
|
||||
setStatus("playlist-status", "Loading playlist...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/playlist");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = payload.content || "";
|
||||
setStatus("playlist-status", "Playlist loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load playlist", error);
|
||||
setStatus("playlist-status", "Failed to load playlist.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
setStatus("playlist-status", "Saving playlist...");
|
||||
|
||||
try {
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const content = textarea?.value || "";
|
||||
|
||||
const response = await fetch("/api/admin/playlist", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
setStatus("playlist-status", "Playlist saved.");
|
||||
} catch (error) {
|
||||
console.error("Failed to save playlist", error);
|
||||
setStatus("playlist-status", "Failed to save playlist.", true);
|
||||
} finally {
|
||||
state.playlistSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSchedule() {
|
||||
setStatus("schedule-status", "Loading schedule...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/schedule");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
setText("schedule-total-entries", String(payload.totalEntries ?? 0));
|
||||
setText("schedule-matched-entries", String(payload.matchedEntries ?? 0));
|
||||
setText("schedule-total-runtime", payload.totalRuntime || "00:00:00");
|
||||
|
||||
const tbody = document.getElementById("schedule-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const entries = payload.entries || [];
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">No schedule entries found.</td></tr>`;
|
||||
} else {
|
||||
entries.forEach((entry: any) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${entry.index}</td><td>${entry.label}</td><td>${entry.runtime || "00:00:00"}</td><td>${entry.status || "missing"}</td><td>${entry.path}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"schedule-status",
|
||||
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load schedule", error);
|
||||
setStatus("schedule-status", "Failed to load schedule.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
setStatus("system-status", "Loading system summary...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/system");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
setText("system-db-driver", payload.databaseDriver || "unavailable");
|
||||
setText("system-playlist-path", payload.playlistPath || "unavailable");
|
||||
setText("system-playlist-present", payload.playlistExists ? "yes" : "no");
|
||||
setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no");
|
||||
setText("system-redis-memory", payload.redisMemoryUsed || "unavailable");
|
||||
setText("system-generated-at", payload.generatedAt || "unavailable");
|
||||
|
||||
const servicesEl = document.getElementById("system-services");
|
||||
if (servicesEl) {
|
||||
servicesEl.innerHTML = "";
|
||||
const services = (payload.services || []) as ServiceHealth[];
|
||||
services.forEach((service) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "service-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<h3>${service.name}</h3>
|
||||
<p class="small-muted">${service.checked}</p>
|
||||
</div>
|
||||
<div class="service-row-right">
|
||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||
<p>${service.detail}</p>
|
||||
</div>
|
||||
`;
|
||||
servicesEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("system-status", "System summary loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load system summary", error);
|
||||
setStatus("system-status", "Failed to load system summary.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMediaRows(files: MediaEntry[]) {
|
||||
const tbody = document.getElementById("media-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (files.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7">No media files available.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach((file) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${file.name}</td>
|
||||
<td>${file.title || "-"}</td>
|
||||
<td>${file.runtime || "00:00:00"}</td>
|
||||
<td>${file.status || "unknown"}</td>
|
||||
<td>${fmtSize(file.size)}</td>
|
||||
<td>${file.modifiedAt || "-"}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
|
||||
editMedia(file),
|
||||
);
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
|
||||
deleteMedia(file),
|
||||
);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshMediaLibrary() {
|
||||
setStatus("media-status", "Loading media library...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/media");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
renderMediaRows(payload.files || []);
|
||||
setText("media-generated-at", payload.generatedAt || "unavailable");
|
||||
setStatus("media-status", "Media library loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load media library", error);
|
||||
setStatus("media-status", "Failed to load media library.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadVideo(file: File) {
|
||||
if (state.mediaUploading) return;
|
||||
|
||||
if (file.size <= 0) {
|
||||
setStatus("media-status", "Cannot upload an empty file.", true);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
setStatus("media-status", "File exceeds 2GB upload limit.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
state.mediaUploading = true;
|
||||
setStatus("media-status", `Preparing upload for ${file.name}...`);
|
||||
|
||||
try {
|
||||
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_BYTES);
|
||||
const fingerprint = getFileFingerprint(file);
|
||||
|
||||
const initResponse = await fetch("/api/admin/media/uploads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
uploadId: getStoredUploadId(fingerprint),
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
chunkSize: UPLOAD_CHUNK_BYTES,
|
||||
totalChunks,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(await readErrorMessage(initResponse));
|
||||
}
|
||||
|
||||
const initPayload = await initResponse.json();
|
||||
const uploadId = String(initPayload.uploadId || "");
|
||||
if (!uploadId) {
|
||||
throw new Error("Upload session could not be initialized.");
|
||||
}
|
||||
|
||||
setStoredUploadId(fingerprint, uploadId);
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const chunkResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/chunk?index=${chunkIndex}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: chunk,
|
||||
},
|
||||
);
|
||||
|
||||
if (!chunkResponse.ok) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(effectiveTotalChunks, effectiveTotalChunks, "Finalizing");
|
||||
|
||||
const payload = await completeResponse.json();
|
||||
renderMediaRows(payload.files || []);
|
||||
setText("media-generated-at", payload.generatedAt || "unavailable");
|
||||
setStatus("media-status", `${file.name} uploaded.`);
|
||||
clearStoredUploadId(fingerprint);
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
state.mediaUploading = false;
|
||||
setTimeout(() => hideUploadProgress(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
async function editMedia(file: MediaEntry) {
|
||||
if (!file.id) {
|
||||
setStatus("media-status", "Cannot edit media item without id.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = window.prompt("Title", file.title || file.name || "");
|
||||
if (title === null) return;
|
||||
const status = window.prompt("Status", file.status || "ready");
|
||||
if (status === null) return;
|
||||
const tags = window.prompt("Tags (comma-separated)", file.tags || "");
|
||||
if (tags === null) return;
|
||||
|
||||
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, status, tags }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
setStatus("media-status", "Media metadata updated.");
|
||||
await refreshMediaLibrary();
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to update media metadata", error);
|
||||
setStatus("media-status", "Failed to update media metadata.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMedia(file: MediaEntry) {
|
||||
if (!file.id) {
|
||||
setStatus("media-status", "Cannot delete media item without id.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("media-status", `Deleting ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
setStatus("media-status", `${file.name} deleted.`);
|
||||
await refreshMediaLibrary();
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media", error);
|
||||
setStatus("media-status", "Failed to delete media file.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModerationLogs() {
|
||||
setStatus("moderation-status", "Loading moderation logs...");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
const tbody = document.getElementById("moderation-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const rows = payload.logs || [];
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">No moderation actions logged yet.</td></tr>`;
|
||||
} else {
|
||||
rows.forEach((entry: any) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${entry.timestamp}</td><td>${entry.action}</td><td>${entry.target}</td><td>${entry.moderator}</td><td>${entry.reason}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("moderation-status", "Moderation logs loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load moderation logs", error);
|
||||
setStatus("moderation-status", "Failed to load moderation logs.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitModerationAction(event: Event) {
|
||||
event.preventDefault();
|
||||
if (state.moderationSubmitting) return;
|
||||
|
||||
state.moderationSubmitting = true;
|
||||
setStatus("moderation-status", "Submitting moderation action...");
|
||||
|
||||
const payload = {
|
||||
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
|
||||
target: (document.getElementById("mod-target") as HTMLInputElement).value,
|
||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
|
||||
.value,
|
||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
|
||||
.value,
|
||||
room: (document.getElementById("mod-room") as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/moderation/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
setStatus("moderation-status", "Moderation action recorded.");
|
||||
(document.getElementById("mod-target") as HTMLInputElement).value = "";
|
||||
(document.getElementById("mod-reason") as HTMLTextAreaElement).value = "";
|
||||
await refreshModerationLogs();
|
||||
await refreshOverview();
|
||||
} catch (error) {
|
||||
console.error("Failed to submit moderation action", error);
|
||||
setStatus("moderation-status", "Failed to submit moderation action.", true);
|
||||
} finally {
|
||||
state.moderationSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
setStatus("users-status", "Loading active users...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
const tbody = document.getElementById("users-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const users = payload.users || [];
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="3">No active users reported.</td></tr>`;
|
||||
} else {
|
||||
users.forEach((user: any) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"users-status",
|
||||
payload.redisEnabled
|
||||
? "Active users loaded."
|
||||
: "Redis disabled; no live presence data available.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users", error);
|
||||
setStatus("users-status", "Failed to load active users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||
.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const tab = button.dataset.tab;
|
||||
if (tab) switchTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch("/api/admin/logout", { method: "POST" });
|
||||
} finally {
|
||||
window.location.href = "/admin/login/";
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("reload-playlist-btn")
|
||||
?.addEventListener("click", refreshPlaylist);
|
||||
document
|
||||
.getElementById("save-playlist-btn")
|
||||
?.addEventListener("click", savePlaylist);
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("reload-analytics-btn")
|
||||
?.addEventListener("click", refreshAnalytics);
|
||||
document
|
||||
.getElementById("reload-users-btn")
|
||||
?.addEventListener("click", refreshUsers);
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragleave", () => {
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await uploadVideo(droppedFiles[0]);
|
||||
});
|
||||
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
await uploadVideo(target.files[0]);
|
||||
target.value = "";
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
await Promise.all([
|
||||
refreshOverview(),
|
||||
refreshAnalytics(),
|
||||
refreshMediaLibrary(),
|
||||
refreshPlaylist(),
|
||||
refreshSchedule(),
|
||||
refreshModerationLogs(),
|
||||
refreshSystemSummary(),
|
||||
refreshUsers(),
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init().catch((error) => console.error("Admin initialization failed", error));
|
||||
});
|
||||
+58
-66
@@ -1,17 +1,16 @@
|
||||
import Hls from "hls.js";
|
||||
import "./style.css";
|
||||
|
||||
// Main application state with Alpine.js
|
||||
type HlsClass = typeof import("hls.js")["default"];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
app: () => any;
|
||||
switchRoom: (room: string) => void;
|
||||
sendMessage: () => void;
|
||||
togglePrivateDM: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
let hls: Hls | null = null;
|
||||
let hls: InstanceType<HlsClass> | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
@@ -89,17 +88,21 @@ function addMessage(
|
||||
) {
|
||||
const messages = document.getElementById("messages")!;
|
||||
const div = document.createElement("div");
|
||||
div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`;
|
||||
div.className = "message-row";
|
||||
|
||||
if (type === "system") {
|
||||
div.innerHTML = `<div class="text-xs text-slate-400 bg-slate-800 px-3 py-1 rounded-full">${text}</div>`;
|
||||
div.classList.add("message-system-row");
|
||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||
} else {
|
||||
div.classList.add(
|
||||
sender === username ? "message-self-row" : "message-other-row",
|
||||
);
|
||||
div.innerHTML = `
|
||||
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]">
|
||||
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""}
|
||||
<div>${text}</div>
|
||||
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
</div>
|
||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||
<div class="message-text">${text}</div>
|
||||
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -107,6 +110,49 @@ function addMessage(
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
} else {
|
||||
try {
|
||||
const { default: Hls } = await import("hls.js");
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load HLS player", error);
|
||||
addMessage("system", "Unable to initialize video player.");
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"Synthwave Nights",
|
||||
"Classic Cinema",
|
||||
"Ambient Void",
|
||||
];
|
||||
const title = titles[Math.floor(Math.random() * titles.length)];
|
||||
document.getElementById("now-playing")!.textContent = title;
|
||||
const overlay = document.getElementById("now-playing-overlay")!;
|
||||
overlay.classList.remove("hidden");
|
||||
document.getElementById("overlay-title")!.textContent = title;
|
||||
}, 15000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
const text = input.value.trim();
|
||||
@@ -141,64 +187,10 @@ function togglePrivateDM() {
|
||||
}
|
||||
}
|
||||
|
||||
// Alpine.js app
|
||||
window.app = () => {
|
||||
return {
|
||||
init() {
|
||||
// Initialize Tailwind
|
||||
// @ts-ignore
|
||||
tailwind.config = {
|
||||
content: [],
|
||||
theme: { extend: {} },
|
||||
};
|
||||
|
||||
// Initialize HLS Player
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
}
|
||||
|
||||
// Connect to default chat room
|
||||
connectWebSocket("main");
|
||||
|
||||
// Simulate now playing
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"Synthwave Nights",
|
||||
"Classic Cinema",
|
||||
"Ambient Void",
|
||||
];
|
||||
document.getElementById("now-playing")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
const overlay = document.getElementById("now-playing-overlay")!;
|
||||
overlay.classList.remove("hidden");
|
||||
document.getElementById("overlay-title")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
}, 15000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Make functions global for onclick handlers
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
|
||||
// Auto initialize when loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// @ts-ignore
|
||||
window.app().init();
|
||||
void initPlayerAndChat();
|
||||
});
|
||||
+830
-289
File diff suppressed because it is too large.
Load diff
@@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
envDir: "..",
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: "index.html",
|
||||
|
||||
Reference in new issue
Block a user