From c0611cb04fde84c4a8054f6709d4774beb0f60f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 9 Nov 2025 02:00:20 +0100 Subject: [PATCH] feat: add Asciinema terminal recording server stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 73 ++++- arty.yml | 9 + asciinema/compose.yaml | 50 +++ asciinema/theme/custom.css | 414 ++++++++++++++++++++++++ compose.yaml | 1 + core/postgres/init/01-init-databases.sh | 8 +- 6 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 asciinema/compose.yaml create mode 100644 asciinema/theme/custom.css diff --git a/CLAUDE.md b/CLAUDE.md index 6c05015..9eb37a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/arty.yml b/arty.yml index fd38433..b413371 100644 --- a/arty.yml +++ b/arty.yml @@ -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 diff --git a/asciinema/compose.yaml b/asciinema/compose.yaml new file mode 100644 index 0000000..185dbfa --- /dev/null +++ b/asciinema/compose.yaml @@ -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 diff --git a/asciinema/theme/custom.css b/asciinema/theme/custom.css new file mode 100644 index 0000000..304038b --- /dev/null +++ b/asciinema/theme/custom.css @@ -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; +} diff --git a/compose.yaml b/compose.yaml index 530cd46..6cb236c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/core/postgres/init/01-init-databases.sh b/core/postgres/init/01-init-databases.sh index cc7c8cb..c501b11 100644 --- a/core/postgres/init/01-init-databases.sh +++ b/core/postgres/init/01-init-databases.sh @@ -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 ""