diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ee1b90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +apps/*/node_modules +apps/*/dist +pnpm-debug.log +.DS_Store diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml new file mode 100644 index 0000000..e35b998 --- /dev/null +++ b/.github/workflows/ci-image.yml @@ -0,0 +1,79 @@ +name: CI Build + Docker Image + +on: + pull_request: + push: + branches: ["main", "master", "work"] + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build workspace + run: pnpm build + + docker: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and (conditionally) push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c8eafde --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:22-bookworm-slim AS base +WORKDIR /app +RUN corepack enable + +FROM base AS deps +COPY package.json pnpm-workspace.yaml ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +RUN pnpm install --no-frozen-lockfile + +FROM deps AS build +COPY . . +RUN pnpm --filter @devsms/web build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=4000 +ENV WEB_PORT=5153 +RUN corepack enable + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=build /app/apps/server ./apps/server +COPY --from=build /app/apps/web ./apps/web +COPY scripts/start.sh ./scripts/start.sh + +EXPOSE 4000 5153 +CMD ["bash", "./scripts/start.sh"] diff --git a/README.md b/README.md index 65112f0..da66038 100644 --- a/README.md +++ b/README.md @@ -121,3 +121,25 @@ Canonical lifecycle: - `queued -> expired` Provider mapping implemented in `apps/server/src/status-map.js`. + +## Docker + +Build and run locally: + +```bash +docker build -t devsms:local . +docker run --rm -p 4000:4000 -p 5153:5153 devsms:local +``` + +- API: `http://localhost:4000` +- Web (SSR): `http://localhost:5153` + +## CI / Image Publish + +GitHub Actions workflow: `.github/workflows/ci-image.yml` + +- Runs build checks on pull requests. +- On push to `main` / `master` / `work` and tags (`v*`), builds Docker image and publishes to GHCR: + - `ghcr.io//:` + - `ghcr.io//:sha-...` + - `ghcr.io//:latest` (default branch only) diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index b1b368c..ca36811 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -2,32 +2,61 @@ import { useEffect, useMemo, useState } from 'react'; import { fetchMessages, maskRecipient, redactOtp } from './lib.js'; const PROVIDERS = ['all', 'mimsms', 'twilio', 'smpp']; +const PAGE_SIZES = [10, 20, 50]; function statusTone(status) { switch (status) { case 'delivered': - return 'text-emerald-700 bg-emerald-50'; + return 'text-emerald-200 bg-emerald-950/70 border-emerald-800'; case 'failed': - return 'text-red-700 bg-red-50'; + return 'text-rose-200 bg-rose-950/70 border-rose-800'; case 'expired': - return 'text-amber-700 bg-amber-50'; + return 'text-amber-200 bg-amber-950/70 border-amber-800'; case 'sent': - return 'text-sky-700 bg-sky-50'; + return 'text-sky-200 bg-sky-950/70 border-sky-800'; default: - return 'text-zinc-700 bg-zinc-100'; + return 'text-zinc-300 bg-zinc-900/70 border-zinc-700'; } } +function providerLabel(provider) { + if (provider === 'all') return 'All providers'; + return provider.toUpperCase(); +} + +function themeLabel(theme) { + return theme === 'dark' ? 'Dark mode' : 'Light mode'; +} + export function App({ initialMessages = [] }) { const [messages, setMessages] = useState(initialMessages); const [provider, setProvider] = useState('all'); const [maskMobile, setMaskMobile] = useState(true); const [hideOtp, setHideOtp] = useState(false); + const [targetFilter, setTargetFilter] = useState(''); + const [senderFilter, setSenderFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [theme, setTheme] = useState('dark'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + useEffect(() => { + setIsLoading(true); + setError(''); + fetchMessages(provider === 'all' ? '' : provider) .then(setMessages) - .catch((err) => console.error(err)); + .catch((err) => { + console.error(err); + setError('Could not load live messages. Showing latest snapshot.'); + }) + .finally(() => setIsLoading(false)); }, [provider]); useEffect(() => { @@ -40,77 +69,177 @@ export function App({ initialMessages = [] }) { fetchMessages(provider === 'all' ? '' : provider) .then(setMessages) - .catch((err) => console.error(err)); + .catch((err) => { + console.error(err); + setError('Live refresh failed. Please retry in a moment.'); + }); }); + events.onerror = () => { + setError('Live stream disconnected. Reconnecting automatically.'); + }; + return () => events.close(); }, [provider]); - const total = useMemo(() => messages.length, [messages]); + const filteredMessages = useMemo(() => { + const target = targetFilter.trim().toLowerCase(); + const sender = senderFilter.trim().toLowerCase(); + + return messages.filter((message) => { + const matchesTarget = !target || (message.recipient || '').toLowerCase().includes(target); + const matchesSender = !sender || (message.sender || '').toLowerCase().includes(sender); + const matchesStatus = statusFilter === 'all' || message.status === statusFilter; + return matchesTarget && matchesSender && matchesStatus; + }); + }, [messages, targetFilter, senderFilter, statusFilter]); + + const total = filteredMessages.length; + const totalPages = Math.max(Math.ceil(total / pageSize), 1); + + useEffect(() => { + setPage(1); + }, [provider, targetFilter, senderFilter, statusFilter, pageSize]); + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages); + } + }, [page, totalPages]); + + const pagedMessages = useMemo(() => { + const start = (page - 1) * pageSize; + return filteredMessages.slice(start, start + pageSize); + }, [filteredMessages, page, pageSize]); return ( -
-
-
-
-

Canonical Inbox

-

devsms Message Stream

-

{total} messages in unified model

+
+
+
+
+
+

Signal desk · unified stream

+

RETRO SMS COMMANDER

+

A crisp, operator-first inbox for live carrier traffic.

+
+ +
+ + + + + + + + + + setTargetFilter(e.target.value)} + placeholder="Filter target receiver" + className="retro-input" + /> + + setSenderFilter(e.target.value)} + placeholder="Filter sender" + className="retro-input" + /> +
-
+
+ {total} filtered + Page {page} / {totalPages} - -
-
    - {messages.map((message) => ( -
  • -
    -

    {message.provider}

    +
      + {pagedMessages.map((message) => ( +
    • +
      +

      {message.provider}

      {new Date(message.created_at).toLocaleString()}

      -
      -

      +

      +

      {message.sender || 'n/a'} to {maskMobile ? maskRecipient(message.recipient) : message.recipient}

      -

      {redactOtp(message.body, hideOtp)}

      +

      {redactOtp(message.body, hideOtp)}

      -
      - +
      + {message.status}
    • ))} - {messages.length === 0 ? ( -
    • No messages yet.
    • + {!isLoading && pagedMessages.length === 0 ? ( +
    • +

      No messages match current filters

      +

      Try another provider, target receiver, sender, or status.

      +
    • ) : null}
    + +
); diff --git a/apps/web/src/main.css b/apps/web/src/main.css index 01c47a1..2c80163 100644 --- a/apps/web/src/main.css +++ b/apps/web/src/main.css @@ -1,19 +1,110 @@ @import "tailwindcss"; -:root { - --bg: #f3f5f7; - --card: #ffffff; - --ink: #121314; - --muted: #5d6268; - --accent: #045e4f; - --accent-soft: #e6f6f3; - --line: #dde2e8; - --warn: #ab4200; +:root, +:root[data-theme='dark'] { + --bg: #0f1113; + --card: #171a1f; + --card-2: #1d2128; + --ink: #eceff3; + --muted: #9ca3af; + --line: #343945; + --accent: #de5b2d; + --accent-soft: #32231d; + --row-hover: #222733; +} + +:root[data-theme='light'] { + --bg: #eceef1; + --card: #f9fafb; + --card-2: #f0f2f5; + --ink: #17181a; + --muted: #5e6470; + --line: #c9ced6; + --accent: #bf4a21; + --accent-soft: #ffe7dd; + --row-hover: #edf0f5; +} + +* { + box-sizing: border-box; } body { margin: 0; - background: radial-gradient(circle at 20% 0%, #ffffff 0%, var(--bg) 42%); + min-height: 100vh; + background: radial-gradient(circle at 10% -10%, #232832 0%, var(--bg) 45%); color: var(--ink); font-family: "Space Grotesk", "IBM Plex Sans", "Segoe UI", sans-serif; } + +:root[data-theme='light'] body { + background: radial-gradient(circle at 10% -10%, #ffffff 0%, var(--bg) 45%); +} + +.retro-shell { + border-color: var(--line); + background: linear-gradient(180deg, var(--card) 0%, var(--card-2) 100%); +} + +.retro-header, +.retro-footer { + border-color: var(--line); + background: color-mix(in srgb, var(--accent-soft) 45%, var(--card)); +} + +.retro-kicker { + font-size: 0.7rem; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--muted); +} + +.retro-title { + margin-top: 0.2rem; + font-size: clamp(1.4rem, 3vw, 2.1rem); + letter-spacing: 0.06em; + font-weight: 700; +} + +.retro-subtitle { + margin-top: 0.15rem; + color: var(--muted); + font-size: 0.92rem; +} + +.retro-input, +.retro-button { + width: 100%; + border-radius: 0.75rem; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--card) 85%, black); + color: var(--ink); + padding: 0.62rem 0.8rem; + font-size: 0.9rem; + outline: none; + transition: 0.18s ease; +} + +.retro-button:hover, +.retro-input:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); +} + +.retro-button:focus-visible, +.retro-input:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); + border-color: var(--accent); +} + +.retro-chip { + border-radius: 9999px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--card) 86%, black); + padding: 0.3rem 0.65rem; + color: var(--muted); +} + +.retro-chip-warn { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: color-mix(in srgb, var(--accent) 60%, var(--ink)); +} diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..183589c --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +node apps/server/src/index.js & +SERVER_PID=$! + +cleanup() { + kill "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +NODE_ENV=production node apps/web/server.js