feat: add Asciinema terminal recording server stack

Added new asciinema stack for self-hosted terminal recording and sharing
platform with custom "Pivoine" theme inspired by pivoine.art aesthetic.

New Services:
- **asciinema**: Terminal recording server at asciinema.pivoine.art
  - PostgreSQL backend for recording persistence
  - Email authentication via IONOS SMTP magic links
  - Public/private recording visibility controls
  - Embed recordings on any website
  - Custom rose/magenta themed UI

Custom Theme (asciinema/theme/custom.css):
- Primary color: RGB(206, 39, 91) - Deep rose/magenta
- Dark charcoal backgrounds: HSL(0, 0%, 17.5%)
- High contrast design with bold color accents
- Styled components: navigation, cards, forms, buttons, terminal player
- Smooth animations and hover effects
- Responsive design with mobile breakpoints
- Custom scrollbars, selection colors, loading states

Infrastructure Updates:
- PostgreSQL: Added `asciinema` database to init script
- arty.yml: Added ASCIINEMA_* environment variables
- compose.yaml: Included asciinema stack in root composition
- CLAUDE.md: Comprehensive documentation with CLI setup guide
- Backup: Added asciinema-backup plan (11 AM daily, 7d/4w/6m/2y retention)

Configuration:
- URL: https://asciinema.pivoine.art
- Database: PostgreSQL `asciinema` database
- SMTP: Email auth via IONOS SMTP
- Unclaimed TTL: 30 days (auto-cleanup)
- Secret: Generated 64-char hex key in .env

Features:
- Record terminal sessions with asciinema CLI
- Web player with play/pause controls and speed adjustment
- User profiles with personal recording collections
- Embed recordings via iframe or direct links
- Privacy controls (public/private recordings)
- Automatic cleanup of unclaimed recordings

Integration Points:
- Documentation: Embed terminal demos
- Blog posts: Share command-line tutorials
- GitHub: Link recordings in README files
- Tutorials: Interactive terminal walkthroughs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 02:00:20 +01:00
parent cdb8d2ef34
commit c0611cb04f
6 changed files with 553 additions and 2 deletions

View File

@@ -26,6 +26,7 @@ Root `compose.yaml` uses Docker Compose's `include` directive to orchestrate mul
- **jelly**: Jellyfin media server with hardware transcoding
- **drop**: PairDrop peer-to-peer file sharing
- **ai**: AI infrastructure with Open WebUI, Crawl4AI, and pgvector (PostgreSQL)
- **asciinema**: Terminal recording and sharing platform (PostgreSQL)
- **restic**: Backrest backup system with restic backend
- **netdata**: Real-time infrastructure monitoring
- **sablier**: Dynamic scaling plugin for Traefik
@@ -65,6 +66,7 @@ Services expose themselves via Docker labels:
- Creates `n8n` database for workflow automation
- Creates `linkwarden` database for Links bookmark manager
- Creates `joplin` database for Joplin Server
- Creates `asciinema` database for Asciinema terminal recording server
- Grants privileges to `$DB_USER`
## Common Commands
@@ -488,6 +490,71 @@ AI infrastructure with Open WebUI, Crawl4AI, and dedicated PostgreSQL with pgvec
**Note**: All AI volumes are backed up daily at 3 AM via Restic with 7 daily, 4 weekly, 6 monthly, and 2 yearly retention.
### Asciinema (asciinema/compose.yaml)
Terminal recording and sharing platform:
- **asciinema**: Asciinema server exposed at `asciinema.pivoine.art:4000`
- Self-hosted terminal recording platform
- Record, share, and embed terminal sessions
- User authentication via email magic links
- Public and private recording visibility
- Embed recordings on any website
- PostgreSQL backend for recording persistence
- Custom "Pivoine" theme with rose/magenta aesthetic
- Data persisted in `asciinema_data` volume
**Features**:
- **Terminal Recording**: Record terminal sessions with asciinema CLI
- **Web Player**: Embedded player with play/pause controls and speed adjustment
- **User Profiles**: Personal recording collections and user pages
- **Embedding**: Share recordings via iframe or direct links
- **Privacy Controls**: Mark recordings as public or private
- **Automatic Cleanup**: Unclaimed recordings deleted after 30 days
**Configuration**:
- **URL**: `https://asciinema.pivoine.art`
- **Database**: PostgreSQL `asciinema` database
- **SMTP**: Email authentication via IONOS SMTP
- **Unclaimed TTL**: 30 days (configurable via `ASCIINEMA_UNCLAIMED_TTL`)
**Custom Theme**:
The server uses a custom CSS theme inspired by pivoine.art:
- **Primary Color**: RGB(206, 39, 91) - Deep rose/magenta
- **Dark Background**: Charcoal HSL(0, 0%, 17.5%)
- **High Contrast**: Bold color accents on dark backgrounds
- **Animations**: Smooth transitions and hover effects
- **Custom Styling**: Cards, buttons, forms, terminal player, and navigation
**CLI Setup**:
```bash
# Install asciinema CLI
pip install asciinema
# Configure CLI to use self-hosted server
export ASCIINEMA_SERVER_URL=https://asciinema.pivoine.art
# Record a session
asciinema rec
# Upload to server
asciinema upload session.cast
```
**Usage**:
1. Access https://asciinema.pivoine.art
2. Click "Sign In" and enter your email
3. Check email for magic login link
4. Configure asciinema CLI with server URL
5. Record and upload terminal sessions
6. Share recordings via public links or embeds
**Integration Points**:
- **Documentation**: Embed terminal demos in docs
- **Blog Posts**: Share command-line tutorials
- **GitHub**: Link recordings in README files
- **Tutorials**: Interactive terminal walkthroughs
**Note**: Asciinema data is backed up daily via Restic with 7 daily, 4 weekly, 6 monthly, and 2 yearly retention.
### Netdata (netdata/compose.yaml)
Real-time infrastructure monitoring and alerting:
- **netdata**: Netdata monitoring agent exposed at `netdata.pivoine.art:19999`
@@ -530,7 +597,7 @@ Backrest backup system with restic backend:
- Prune: Weekly (Sundays at 2 AM) - removes old snapshots per retention policy
- Check: Weekly (Sundays at 3 AM) - verifies repository integrity
**Backup Plans** (16 automated daily backups):
**Backup Plans** (17 automated daily backups):
1. **postgres-backup** (2 AM daily)
- Path: `/volumes/core_postgres_data`
- Retention: 7 daily, 4 weekly, 6 monthly, 2 yearly
@@ -595,6 +662,10 @@ Backrest backup system with restic backend:
- Paths: `/volumes/ai_postgres_data`, `/volumes/ai_webui_data`, `/volumes/ai_crawl4ai_data`
- Retention: 7 daily, 4 weekly, 6 monthly, 2 yearly
17. **asciinema-backup** (11 AM daily)
- Path: `/volumes/asciinema_data`
- Retention: 7 daily, 4 weekly, 6 monthly, 2 yearly
**Volume Mounting**:
All Docker volumes are mounted read-only to `/volumes/` with prefixed names (e.g., `backup_core_postgres_data`) to avoid naming conflicts with other compose stacks.

View File

@@ -180,6 +180,15 @@ envs:
AI_VECTOR_DB: pgvector
AI_CRAWL4AI_PORT: 11235
AI_OPENAI_API_BASE_URLS: https://api.anthropic.com/v1
# Asciinema
ASCIINEMA_TRAEFIK_ENABLED: true
ASCIINEMA_COMPOSE_PROJECT_NAME: asciinema
ASCIINEMA_IMAGE: ghcr.io/asciinema/asciinema-server:latest
ASCIINEMA_TRAEFIK_HOST: asciinema.pivoine.art
ASCIINEMA_DB_NAME: asciinema
ASCIINEMA_UNCLAIMED_TTL: 30
ASCIINEMA_MAIL_FROM: noreply@pivoine.art
ASCIINEMA_MAIL_REPLY_TO: valknar@pivoine.art
# Watchtower
WATCHTOWER_POLL_INTERVAL: 300
WATCHTOWER_LABEL_ENABLE: true

50
asciinema/compose.yaml Normal file
View File

@@ -0,0 +1,50 @@
services:
asciinema:
image: ${ASCIINEMA_IMAGE:-ghcr.io/asciinema/asciinema-server:latest}
container_name: ${ASCIINEMA_COMPOSE_PROJECT_NAME}_app
restart: unless-stopped
networks:
- ${NETWORK_NAME}
volumes:
- asciinema_data:/var/opt/asciinema
- ./theme/custom.css:/app/assets/css/custom.css:ro
environment:
SECRET_KEY_BASE: ${ASCIINEMA_SECRET_KEY}
URL_HOST: ${ASCIINEMA_TRAEFIK_HOST}
URL_PORT: 443
URL_SCHEME: https
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@${CORE_DB_HOST}/${ASCIINEMA_DB_NAME}?pool_size=10
SMTP_HOST: ${EMAIL_SMTP_HOST}
SMTP_PORT: ${EMAIL_SMTP_PORT}
SMTP_USERNAME: ${EMAIL_SMTP_USER}
SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
SMTP_SSL: ${SMTP_SSL:-true}
MAIL_FROM_ADDRESS: ${ASCIINEMA_MAIL_FROM}
MAIL_REPLY_TO_ADDRESS: ${ASCIINEMA_MAIL_REPLY_TO}
UNCLAIMED_RECORDING_TTL: ${ASCIINEMA_UNCLAIMED_TTL:-30}
labels:
- traefik.enable=${ASCIINEMA_TRAEFIK_ENABLED:-true}
- traefik.docker.network=${NETWORK_NAME}
- traefik.http.routers.asciinema.rule=Host(`${ASCIINEMA_TRAEFIK_HOST}`)
- traefik.http.routers.asciinema.entrypoints=web-secure
- traefik.http.routers.asciinema.tls=true
- traefik.http.routers.asciinema.tls.certresolver=letsencrypt
- traefik.http.services.asciinema.loadbalancer.server.port=4000
- traefik.http.routers.asciinema.middlewares=compress@file
- com.centurylinklabs.watchtower.enable=true
depends_on:
- postgres
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
asciinema_data:
name: ${ASCIINEMA_COMPOSE_PROJECT_NAME}_data
networks:
${NETWORK_NAME}:
external: true

414
asciinema/theme/custom.css Normal file
View File

@@ -0,0 +1,414 @@
/**
* Asciinema Custom Theme - "Pivoine"
* Inspired by pivoine.art aesthetic
*
* Color Palette:
* - Primary Accent: RGB(206, 39, 91) - Deep rose/magenta
* - Dark Background: HSL(0, 0%, 17.5%) - Charcoal
* - High contrast with bold color pops
*/
:root {
/* Pivoine Color Palette */
--pivoine-rose: rgb(206, 39, 91);
--pivoine-rose-light: rgb(226, 79, 121);
--pivoine-rose-dark: rgb(166, 19, 71);
--pivoine-rose-fade: rgba(206, 39, 91, 0.15);
--pivoine-rose-glow: rgba(206, 39, 91, 0.3);
/* Dark Mode Charcoal */
--pivoine-bg-dark: hsl(0, 0%, 17.5%);
--pivoine-bg-darker: hsl(0, 0%, 12%);
--pivoine-bg-lighter: hsl(0, 0%, 22%);
--pivoine-border: hsl(0, 0%, 25%);
/* Text Colors */
--pivoine-text-primary: hsl(0, 0%, 95%);
--pivoine-text-secondary: hsl(0, 0%, 75%);
--pivoine-text-muted: hsl(0, 0%, 55%);
/* Accent Variants */
--pivoine-success: rgb(16, 185, 129);
--pivoine-warning: rgb(245, 158, 11);
--pivoine-info: rgb(59, 130, 246);
}
/* Global Styling */
body {
background-color: var(--pivoine-bg-dark) !important;
color: var(--pivoine-text-primary) !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif !important;
}
/* Header & Navigation */
header,
.header,
nav {
background-color: var(--pivoine-bg-darker) !important;
border-bottom: 2px solid var(--pivoine-rose) !important;
}
.navbar,
.nav-link {
color: var(--pivoine-text-primary) !important;
}
.nav-link:hover,
.nav-link:focus {
color: var(--pivoine-rose-light) !important;
background-color: var(--pivoine-rose-fade) !important;
border-radius: 4px;
transition: all 0.3s ease;
}
/* Primary Buttons & Links */
a {
color: var(--pivoine-rose-light) !important;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: var(--pivoine-rose) !important;
text-decoration: underline;
}
button,
.btn,
input[type="submit"] {
background-color: var(--pivoine-rose) !important;
color: white !important;
border: none !important;
border-radius: 6px !important;
padding: 8px 16px !important;
font-weight: 600 !important;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
button:hover,
.btn:hover,
input[type="submit"]:hover {
background-color: var(--pivoine-rose-light) !important;
box-shadow: 0 4px 8px var(--pivoine-rose-glow);
transform: translateY(-1px);
}
button:active,
.btn:active,
input[type="submit"]:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* Cards & Containers */
.card,
.panel,
.container,
.recording-item,
article {
background-color: var(--pivoine-bg-lighter) !important;
border: 1px solid var(--pivoine-border) !important;
border-radius: 8px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.card:hover,
.recording-item:hover {
border-color: var(--pivoine-rose) !important;
box-shadow: 0 4px 12px var(--pivoine-rose-glow);
transform: translateY(-2px);
}
/* Forms & Inputs */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
textarea,
select {
background-color: var(--pivoine-bg-darker) !important;
color: var(--pivoine-text-primary) !important;
border: 2px solid var(--pivoine-border) !important;
border-radius: 6px !important;
padding: 10px 14px !important;
transition: all 0.3s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none !important;
border-color: var(--pivoine-rose) !important;
box-shadow: 0 0 0 3px var(--pivoine-rose-fade);
}
/* Terminal Player */
.asciinema-player,
.player-wrapper,
.terminal {
background-color: var(--pivoine-bg-darker) !important;
border: 2px solid var(--pivoine-rose) !important;
border-radius: 8px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.asciinema-player .control-bar {
background-color: var(--pivoine-bg-dark) !important;
border-top: 1px solid var(--pivoine-border) !important;
}
.asciinema-player .play-button,
.asciinema-player button {
color: var(--pivoine-rose) !important;
}
.asciinema-player .play-button:hover,
.asciinema-player button:hover {
color: var(--pivoine-rose-light) !important;
}
.asciinema-player .progressbar {
background-color: var(--pivoine-border) !important;
}
.asciinema-player .progressbar .bar {
background-color: var(--pivoine-rose) !important;
}
/* Recording Metadata */
.recording-meta,
.info,
.metadata {
color: var(--pivoine-text-secondary) !important;
font-size: 0.9em;
padding: 8px 0;
}
.recording-meta strong,
.metadata .label {
color: var(--pivoine-rose-light) !important;
font-weight: 600;
}
/* Tags & Badges */
.tag,
.badge,
.label {
background-color: var(--pivoine-rose-fade) !important;
color: var(--pivoine-rose-light) !important;
border: 1px solid var(--pivoine-rose) !important;
border-radius: 4px !important;
padding: 4px 10px !important;
font-size: 0.85em;
font-weight: 600;
display: inline-block;
margin: 4px 4px 4px 0;
}
/* Tables */
table {
background-color: var(--pivoine-bg-lighter) !important;
border-collapse: collapse;
width: 100%;
}
thead {
background-color: var(--pivoine-bg-darker) !important;
border-bottom: 2px solid var(--pivoine-rose) !important;
}
th {
color: var(--pivoine-rose-light) !important;
font-weight: 700;
padding: 12px !important;
text-align: left;
}
td {
color: var(--pivoine-text-primary) !important;
padding: 10px 12px !important;
border-bottom: 1px solid var(--pivoine-border) !important;
}
tr:hover {
background-color: var(--pivoine-rose-fade) !important;
}
/* Pagination */
.pagination a,
.pagination span {
background-color: var(--pivoine-bg-lighter) !important;
color: var(--pivoine-text-primary) !important;
border: 1px solid var(--pivoine-border) !important;
border-radius: 4px !important;
padding: 6px 12px !important;
margin: 0 4px;
display: inline-block;
}
.pagination a:hover {
background-color: var(--pivoine-rose) !important;
border-color: var(--pivoine-rose) !important;
color: white !important;
}
.pagination .current {
background-color: var(--pivoine-rose) !important;
border-color: var(--pivoine-rose) !important;
color: white !important;
}
/* Footer */
footer {
background-color: var(--pivoine-bg-darker) !important;
border-top: 2px solid var(--pivoine-rose) !important;
color: var(--pivoine-text-secondary) !important;
padding: 24px 0 !important;
margin-top: 48px;
}
/* Code Blocks */
pre,
code {
background-color: var(--pivoine-bg-darker) !important;
color: var(--pivoine-text-primary) !important;
border: 1px solid var(--pivoine-border) !important;
border-radius: 4px !important;
padding: 12px !important;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace !important;
font-size: 0.9em;
}
/* Alerts & Messages */
.alert,
.message,
.notice {
border-radius: 6px !important;
padding: 12px 16px !important;
margin: 16px 0 !important;
border-left: 4px solid;
}
.alert-success,
.message-success {
background-color: rgba(16, 185, 129, 0.15) !important;
border-left-color: var(--pivoine-success) !important;
color: var(--pivoine-success) !important;
}
.alert-warning,
.message-warning {
background-color: rgba(245, 158, 11, 0.15) !important;
border-left-color: var(--pivoine-warning) !important;
color: var(--pivoine-warning) !important;
}
.alert-info,
.message-info {
background-color: rgba(59, 130, 246, 0.15) !important;
border-left-color: var(--pivoine-info) !important;
color: var(--pivoine-info) !important;
}
.alert-error,
.message-error {
background-color: var(--pivoine-rose-fade) !important;
border-left-color: var(--pivoine-rose) !important;
color: var(--pivoine-rose-light) !important;
}
/* User Profile */
.avatar,
.user-avatar {
border: 3px solid var(--pivoine-rose) !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px var(--pivoine-rose-glow);
}
/* Loading States */
.spinner,
.loading {
border-color: var(--pivoine-border) !important;
border-top-color: var(--pivoine-rose) !important;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background-color: var(--pivoine-bg-dark);
}
::-webkit-scrollbar-thumb {
background-color: var(--pivoine-border);
border-radius: 6px;
border: 2px solid var(--pivoine-bg-dark);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--pivoine-rose);
}
/* Selection */
::selection {
background-color: var(--pivoine-rose-glow) !important;
color: white !important;
}
::-moz-selection {
background-color: var(--pivoine-rose-glow) !important;
color: white !important;
}
/* Responsive Design */
@media (max-width: 768px) {
.card,
.panel,
.container {
padding: 12px !important;
}
button,
.btn {
padding: 6px 12px !important;
font-size: 0.9em !important;
}
}
/* Animations */
@keyframes pivoine-pulse {
0%, 100% {
box-shadow: 0 0 0 0 var(--pivoine-rose-glow);
}
50% {
box-shadow: 0 0 0 8px rgba(206, 39, 91, 0);
}
}
.recording-item:hover,
.card:hover {
animation: pivoine-pulse 2s ease-in-out infinite;
}
/* Logo & Branding */
.logo {
filter: brightness(0) saturate(100%) invert(47%) sepia(74%) saturate(1845%) hue-rotate(319deg) brightness(93%) contrast(87%);
}
/* Custom Highlight */
mark,
.highlight {
background-color: var(--pivoine-rose-fade) !important;
color: var(--pivoine-rose-light) !important;
padding: 2px 6px;
border-radius: 3px;
}

View File

@@ -15,6 +15,7 @@ include:
- jelly/compose.yaml
- drop/compose.yaml
- ai/compose.yaml
- asciinema/compose.yaml
- restic/compose.yaml
- netdata/compose.yaml
- umami/compose.yaml

View File

@@ -37,6 +37,10 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
SELECT 'CREATE DATABASE tandoor'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'tandoor')\gexec
-- Asciinema terminal recording server database
SELECT 'CREATE DATABASE asciinema'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'asciinema')\gexec
-- Grant privileges to all databases
GRANT ALL PRIVILEGES ON DATABASE directus TO $POSTGRES_USER;
GRANT ALL PRIVILEGES ON DATABASE umami TO $POSTGRES_USER;
@@ -45,11 +49,12 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
GRANT ALL PRIVILEGES ON DATABASE joplin TO $POSTGRES_USER;
GRANT ALL PRIVILEGES ON DATABASE mattermost TO $POSTGRES_USER;
GRANT ALL PRIVILEGES ON DATABASE tandoor TO $POSTGRES_USER;
GRANT ALL PRIVILEGES ON DATABASE asciinema TO $POSTGRES_USER;
-- Log success
SELECT 'Compose databases initialized:' AS status;
SELECT datname FROM pg_database
WHERE datname IN ('directus', 'umami', 'n8n', 'linkwarden', 'joplin', 'mattermost', 'tandoor')
WHERE datname IN ('directus', 'umami', 'n8n', 'linkwarden', 'joplin', 'mattermost', 'tandoor', 'asciinema')
ORDER BY datname;
EOSQL
@@ -65,4 +70,5 @@ echo " • linkwarden - Bookmark manager database"
echo " • joplin - Note-taking server database"
echo " • mattermost - Chat platform database"
echo " • tandoor - Recipe manager database"
echo " • asciinema - Terminal recording server database"
echo ""