diff --git a/.env.example b/.env.example index 24a47d20..048c98d0 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,23 @@ ADMIN_URL=http://localhost:3000 NGINX_HTTP_PORT=80 NGINX_HTTPS_PORT=443 +# --- Embed Proxy Ports --- +# Dedicated nginx ports for iframe embedding without DNS/subdomain. +# Change these to avoid port conflicts when running multiple instances on one host. +NOCODB_EMBED_PORT=8881 +N8N_EMBED_PORT=8882 +GITEA_EMBED_PORT=8883 +MAILHOG_EMBED_PORT=8884 +MINI_QR_EMBED_PORT=8885 +EXCALIDRAW_EMBED_PORT=8886 +HOMEPAGE_EMBED_PORT=8887 +VAULTWARDEN_EMBED_PORT=8890 +ROCKETCHAT_EMBED_PORT=8891 +GANCIO_EMBED_PORT=8892 +JITSI_EMBED_PORT=8893 +GRAFANA_EMBED_PORT=8894 +ALERTMANAGER_EMBED_PORT=8895 + # --- SMTP / Email --- SMTP_HOST=mailhog-changemaker SMTP_PORT=1025 @@ -212,24 +229,20 @@ USER_NAME=coder # --- Homepage --- HOMEPAGE_PORT=3010 -HOMEPAGE_EMBED_PORT=8887 HOMEPAGE_VAR_BASE_URL=http://localhost # --- Mini QR --- MINI_QR_PORT=8089 MINI_QR_URL=http://mini-qr:8080 -MINI_QR_EMBED_PORT=8885 # --- Excalidraw (Collaborative Whiteboard) --- EXCALIDRAW_PORT=8090 EXCALIDRAW_URL=http://excalidraw-changemaker:80 -EXCALIDRAW_EMBED_PORT=8886 EXCALIDRAW_WS_URL=wss://draw.cmlite.org # --- Vaultwarden (Password Manager) --- VAULTWARDEN_PORT=8445 VAULTWARDEN_URL=http://vaultwarden-changemaker:80 -VAULTWARDEN_EMBED_PORT=8890 # Admin panel token (access at /admin) — generate with: openssl rand -hex 32 VAULTWARDEN_ADMIN_TOKEN= # MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation @@ -302,13 +315,11 @@ ENABLE_CHAT=false ROCKETCHAT_ADMIN_USER=rcadmin ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS ROCKETCHAT_URL=http://rocketchat-changemaker:3000 -ROCKETCHAT_EMBED_PORT=8891 # --- Gancio (Event Management) --- # Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh) GANCIO_PORT=8092 GANCIO_URL=http://gancio-changemaker:13120 -GANCIO_EMBED_PORT=8892 GANCIO_BASE_URL=https://events.cmlite.org # Gancio admin credentials for shift-to-event sync (OAuth login) GANCIO_ADMIN_USER=admin @@ -328,18 +339,12 @@ JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32 # Generate each with: openssl rand -hex 16 JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 -# Embed port for admin iframe -JITSI_EMBED_PORT=8893 JITSI_URL=http://jitsi-web-changemaker:80 # JVB public IP (required for NAT traversal — set to server's public IP in production) JVB_ADVERTISE_IP= # JVB UDP port for media traffic (must be open in firewall) JVB_PORT=10000 -# --- Monitoring Embed Ports (iframe embedding) --- -GRAFANA_EMBED_PORT=8894 -ALERTMANAGER_EMBED_PORT=8895 - # --- SMS Campaigns (Termux Android Bridge) --- # ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative # URL + API key are typically managed via admin Settings page (DB overrides env) diff --git a/api/Dockerfile b/api/Dockerfile index b804c040..52207f14 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -30,8 +30,11 @@ COPY --from=build /app/dist ./dist COPY --from=build /app/package.json ./ COPY --from=build /app/package-lock.json* ./ COPY --from=build /app/prisma ./prisma -# Install production-only deps and regenerate Prisma client -RUN npm ci --omit=dev && npx prisma generate +# Copy source files needed by prisma/seed.ts (imports ../src/config/env and ../src/utils/crypto) +COPY --from=build /app/src ./src +COPY --from=build /app/tsconfig.json ./ +# Install production-only deps, tsx (needed for prisma db seed), and regenerate Prisma client +RUN npm ci --omit=dev && npm install tsx && npx prisma generate COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ && mkdir -p /app/uploads && chown -R node:node /app/uploads diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 66502305..4c87daf8 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -27,8 +27,8 @@ npx prisma migrate deploy 2>&1 echo "Migrations complete." echo "Running database seed..." -npx prisma db seed 2>&1 -echo "Seed complete." +npx prisma db seed 2>&1 || echo "WARNING: Seed failed (non-fatal — seed.ts may require source files not present in production image)" +echo "Seed step done." # If running production mode (node dist/server.js) and dist is stale, recompile if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then diff --git a/api/package.json b/api/package.json index c355baa5..51f9b9a4 100644 --- a/api/package.json +++ b/api/package.json @@ -74,6 +74,6 @@ "typescript": "^5.7.3" }, "prisma": { - "seed": "tsx prisma/seed.ts" + "seed": "npx tsx prisma/seed.ts" } } diff --git a/api/prisma/migrations/20260322100000_add_registry_settings/migration.sql b/api/prisma/migrations/20260322100000_add_registry_settings/migration.sql new file mode 100644 index 00000000..15e3cee0 --- /dev/null +++ b/api/prisma/migrations/20260322100000_add_registry_settings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "site_settings" ADD COLUMN "gitea_registry_url" TEXT NOT NULL DEFAULT 'gitea.bnkops.com/admin', +ADD COLUMN "use_registry_for_upgrade" BOOLEAN NOT NULL DEFAULT false; diff --git a/changemaker-control-panel/admin/src/pages/DashboardPage.tsx b/changemaker-control-panel/admin/src/pages/DashboardPage.tsx index b6fedef6..35b93826 100644 --- a/changemaker-control-panel/admin/src/pages/DashboardPage.tsx +++ b/changemaker-control-panel/admin/src/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { Typography, Row, Col, Button, Statistic, Card, Empty, Spin, message } from 'antd'; +import { Typography, Row, Col, Button, Statistic, Card, Empty, Spin, message, Tag, List } from 'antd'; import { PlusOutlined, CloudServerOutlined, @@ -8,11 +8,14 @@ import { HeartOutlined, ExclamationCircleOutlined, SyncOutlined, + BellOutlined, + CloseCircleOutlined, } from '@ant-design/icons'; +import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import { api } from '@/lib/api'; import InstanceCard from '@/components/InstanceCard'; -import type { Instance } from '@/types/api'; +import type { Instance, EventSummary } from '@/types/api'; interface HealthOverviewItem { id: string; @@ -35,6 +38,17 @@ export default function DashboardPage() { const [secondsAgo, setSecondsAgo] = useState(0); const lastUpdatedRef = useRef(null); + const [eventSummary, setEventSummary] = useState(null); + + const fetchEventSummary = useCallback(async () => { + try { + const { data } = await api.get('/events/summary'); + setEventSummary(data.data); + } catch { + // Silently fail — events are supplementary + } + }, []); + const fetchInstances = useCallback(async () => { try { const { data } = await api.get('/instances'); @@ -56,12 +70,12 @@ export default function DashboardPage() { }, []); const refreshAll = useCallback(async () => { - await Promise.all([fetchInstances(), fetchHealthOverview()]); + await Promise.all([fetchInstances(), fetchHealthOverview(), fetchEventSummary()]); const now = new Date(); setLastUpdated(now); lastUpdatedRef.current = now; setSecondsAgo(0); - }, [fetchInstances, fetchHealthOverview]); + }, [fetchInstances, fetchHealthOverview, fetchEventSummary]); // Initial fetch useEffect(() => { @@ -198,8 +212,63 @@ export default function DashboardPage() { /> + + + 0 ? { color: '#ff4d4f' } : (eventSummary?.warnings || 0) > 0 ? { color: '#faad14' } : undefined} + prefix={} + /> + + + {/* Recent Errors */} + {eventSummary && eventSummary.recentErrors.length > 0 && ( + + + Recent Events ({eventSummary.errors} error{eventSummary.errors !== 1 ? 's' : ''}, {eventSummary.warnings} warning{eventSummary.warnings !== 1 ? 's' : ''}) + + } + size="small" + style={{ marginBottom: 24 }} + > + ( + navigate(`/app/instances/${event.instanceId}`)} + > + + {event.severity} + + } + title={ + + {event.instance?.name || 'Unknown'} + {' — '} + {event.title} + + } + description={ + + {event.source} — {dayjs(event.createdAt).format('MM-DD HH:mm')} + + } + /> + + )} + /> + + )} + {instances.length === 0 ? ( (null); + const [checkingUpdate, setCheckingUpdate] = useState(false); + const [upgradingInstance, setUpgradingInstance] = useState(false); + const [currentUpgrade, setCurrentUpgrade] = useState(null); + const [upgradeHistory, setUpgradeHistory] = useState([]); + const [upgradeHistoryLoading, setUpgradeHistoryLoading] = useState(false); + const upgradePollingRef = useRef>(undefined); + + // Events state + const [events, setEvents] = useState([]); + const [eventsLoading, setEventsLoading] = useState(false); + const [eventsTotal, setEventsTotal] = useState(0); + const fetchInstance = useCallback(async () => { try { const { data } = await api.get(`/instances/${id}`); @@ -154,6 +174,43 @@ export default function InstanceDetailPage() { } }, [id]); + const fetchUpgradeStatus = useCallback(async () => { + if (!id) return; + try { + const { data } = await api.get(`/instances/${id}/upgrade-status`); + setCurrentUpgrade(data.data); + } catch { + // Silently fail + } + }, [id]); + + const fetchUpgradeHistory = useCallback(async () => { + if (!id) return; + setUpgradeHistoryLoading(true); + try { + const { data } = await api.get(`/instances/${id}/upgrade-history`, { params: { limit: 20 } }); + setUpgradeHistory(data.data); + } catch { + // Silently fail + } finally { + setUpgradeHistoryLoading(false); + } + }, [id]); + + const fetchEvents = useCallback(async () => { + if (!id) return; + setEventsLoading(true); + try { + const { data } = await api.get(`/instances/${id}/events`, { params: { limit: 50 } }); + setEvents(data.data); + setEventsTotal(data.total); + } catch { + // Silently fail + } finally { + setEventsLoading(false); + } + }, [id]); + useEffect(() => { fetchInstance(); }, [fetchInstance]); @@ -183,6 +240,31 @@ export default function InstanceDetailPage() { } }, [activeTab, fetchBackups]); + // Fetch upgrade status/history when on updates tab + useEffect(() => { + if (activeTab === 'updates') { + fetchUpgradeStatus(); + fetchUpgradeHistory(); + } + }, [activeTab, fetchUpgradeStatus, fetchUpgradeHistory]); + + // Poll upgrade progress when an upgrade is in progress + useEffect(() => { + if (currentUpgrade?.status === 'IN_PROGRESS' || currentUpgrade?.status === 'PENDING') { + upgradePollingRef.current = setInterval(fetchUpgradeStatus, 2_000); + } + return () => { + if (upgradePollingRef.current) clearInterval(upgradePollingRef.current); + }; + }, [currentUpgrade?.status, fetchUpgradeStatus]); + + // Fetch events when on events tab + useEffect(() => { + if (activeTab === 'events') { + fetchEvents(); + } + }, [activeTab, fetchEvents]); + // Fetch secrets (only called after password verification) const fetchSecrets = useCallback(async () => { if (!id) return; @@ -348,6 +430,57 @@ export default function InstanceDetailPage() { } }; + // Upgrade handlers + const handleCheckUpdate = async () => { + setCheckingUpdate(true); + try { + const { data } = await api.post(`/instances/${id}/check-update`); + setUpdateStatus(data.data); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Failed to check for updates'); + } finally { + setCheckingUpdate(false); + } + }; + + const handleStartUpgrade = async () => { + setUpgradingInstance(true); + try { + const { data } = await api.post(`/instances/${id}/upgrade`, { skipBackup: true }); + setCurrentUpgrade(data.data); + message.success('Upgrade started'); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Failed to start upgrade'); + } finally { + setUpgradingInstance(false); + } + }; + + // Event handlers + const handleAcknowledgeEvent = async (eventId: string) => { + try { + await api.put(`/events/${eventId}/acknowledge`); + message.success('Event acknowledged'); + fetchEvents(); + } catch { + message.error('Failed to acknowledge event'); + } + }; + + const handleAcknowledgeAll = async () => { + try { + await api.put(`/instances/${id}/events/acknowledge-all`); + message.success('All events acknowledged'); + fetchEvents(); + } catch { + message.error('Failed to acknowledge events'); + } + }; + const hasFeatureChanges = instance ? ( featureFlags.enableMedia !== instance.enableMedia || featureFlags.enableListmonk !== instance.enableListmonk || @@ -1044,6 +1177,337 @@ export default function InstanceDetailPage() { ); + // ─── Updates Tab ────────────────────────────────────────────── + + const isUpgrading = currentUpgrade?.status === 'IN_PROGRESS' || currentUpgrade?.status === 'PENDING'; + + const upgradeStatusColors: Record = { + PENDING: 'default', + IN_PROGRESS: 'processing', + COMPLETED: 'green', + FAILED: 'red', + ROLLED_BACK: 'orange', + }; + + const updatesTab = ( + + {/* Version Info Card */} + } + onClick={handleCheckUpdate} + loading={checkingUpdate} + disabled={isUpgrading} + > + Check for Updates + + } + > + + {instance.gitBranch} + + {instance.gitCommit || updateStatus?.currentCommit || 'Unknown'} + + {updateStatus && ( + <> + + {updateStatus.remoteCommit || 'N/A'} + + + {updateStatus.commitsBehind > 0 ? ( + {updateStatus.commitsBehind} commit{updateStatus.commitsBehind !== 1 ? 's' : ''} behind + ) : ( + Up to date + )} + + {updateStatus.error && ( + + {updateStatus.error} + + )} + + {dayjs(updateStatus.checkedAt).format('YYYY-MM-DD HH:mm:ss')} + + + )} + + + + {/* Changelog */} + {updateStatus && updateStatus.changelog.length > 0 && updateStatus.commitsBehind > 0 && ( + + {updateStatus.changelog.map((entry, i) => ( +
+ + {entry.hash?.slice(0, 7)} + {entry.message} + +
+ + {entry.author} — {dayjs(entry.date).format('MM-DD HH:mm')} + +
+ ))} +
+ )} + + {/* Upgrade Action */} + {!isRegistered && ( + + {isUpgrading && currentUpgrade ? ( + + } + /> + + + ) : ( + + {currentUpgrade?.status === 'FAILED' && currentUpgrade.errorMessage && ( + + )} + {currentUpgrade?.status === 'COMPLETED' && ( + + )} +
+ + Pulls latest code, runs migrations, and restarts services. CCP backup is recommended before upgrading. + + + + +
+
+ )} +
+ )} + + {isRegistered && ( + + )} + + {/* Upgrade History */} + + {upgradeHistory.length > 0 ? ( + {s}, + }, + { + title: 'Commits', + render: (_: unknown, r: InstanceUpgrade) => ( + + {r.previousCommit || '?'} + {' → '} + {r.newCommit || '?'} + + ), + }, + { + title: 'Duration', + dataIndex: 'durationSeconds', + width: 90, + render: (s: number | null) => s != null ? `${Math.floor(s / 60)}m ${s % 60}s` : '-', + }, + { + title: 'By', + dataIndex: ['triggeredBy', 'name'], + width: 120, + render: (name: string) => name || '-', + }, + { + title: 'Date', + dataIndex: 'startedAt', + width: 140, + render: (d: string) => dayjs(d).format('MM-DD HH:mm'), + }, + { + title: 'Warnings', + width: 80, + render: (_: unknown, r: InstanceUpgrade) => { + const count = (r.warnings as string[] | undefined)?.length || 0; + return count > 0 ? {count} : '-'; + }, + }, + ]} + /> + ) : ( + + )} + + + ); + + // ─── Events Tab ────────────────────────────────────────────── + + const severityIcons: Record = { + ERROR: , + WARNING: , + INFO: , + }; + + const severityColors: Record = { + ERROR: 'red', + WARNING: 'orange', + INFO: 'blue', + }; + + const unacknowledgedCount = events.filter((e) => !e.acknowledged).length; + + const eventsTab = ( + +
+ + + {eventsTotal} event{eventsTotal !== 1 ? 's' : ''} + + {unacknowledgedCount > 0 && ( + {unacknowledgedCount} unacknowledged + )} + + + {unacknowledgedCount > 0 && ( + + + + )} + + +
+ + {events.length > 0 ? ( +
record.severity === value, + render: (s: string) => ( + + {s} + + ), + }, + { + title: 'Source', + dataIndex: 'source', + width: 120, + render: (s: string) => {s.replace('_', ' ')}, + }, + { + title: 'Title', + dataIndex: 'title', + ellipsis: true, + }, + { + title: 'Message', + dataIndex: 'message', + ellipsis: true, + render: (msg: string) => ( + + {msg} + + ), + }, + { + title: 'Time', + dataIndex: 'createdAt', + width: 140, + render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss'), + }, + { + title: 'Status', + width: 120, + render: (_: unknown, record: InstanceEvent) => ( + record.acknowledged ? ( + }>Acknowledged + ) : ( + + ) + ), + }, + ]} + /> + ) : ( + + )} + + ); + const secretEntries = secrets ? Object.entries(secrets) : []; const isRegisteredSecrets = isRegistered; @@ -1297,6 +1761,8 @@ export default function InstanceDetailPage() { onChange={setActiveTab} items={[ { key: 'overview', label: 'Overview', children: overviewTab }, + { key: 'updates', label: 'Updates', icon: , children: updatesTab }, + { key: 'events', label: unacknowledgedCount > 0 ? `Events (${unacknowledgedCount})` : 'Events', icon: , children: eventsTab }, { key: 'credentials', label: 'Credentials', icon: , children: credentialsTab }, { key: 'features', label: 'Features', children: featuresTab }, { key: 'services', label: 'Services', children: servicesTab }, diff --git a/changemaker-control-panel/admin/src/types/api.ts b/changemaker-control-panel/admin/src/types/api.ts index 5f94e599..11e480e7 100644 --- a/changemaker-control-panel/admin/src/types/api.ts +++ b/changemaker-control-panel/admin/src/types/api.ts @@ -130,6 +130,65 @@ export interface ImportResult { summary: { total: number; succeeded: number; failed: number }; } +// ─── Upgrade Types ─────────────────────────────────────────────── + +export interface UpdateStatus { + branch: string; + currentCommit: string; + currentMessage?: string; + remoteCommit: string | null; + commitsBehind: number; + changelog: Array<{ hash: string; message: string; date: string; author: string }>; + checkedAt: string; + error: string | null; +} + +export interface InstanceUpgrade { + id: string; + instanceId: string; + status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' | 'ROLLED_BACK'; + previousCommit?: string; + newCommit?: string; + commitCount: number; + branch: string; + currentPhase: number; + phaseName?: string; + percentage: number; + progressMessage?: string; + durationSeconds?: number; + errorMessage?: string; + warnings?: string[]; + log?: string; + startedAt: string; + completedAt?: string; + triggeredBy?: { id: string; name: string; email: string }; +} + +// ─── Event Types ───────────────────────────────────────────────── + +export interface InstanceEvent { + id: string; + instanceId: string; + severity: 'ERROR' | 'WARNING' | 'INFO'; + source: string; + title: string; + message: string; + metadata?: Record; + acknowledged: boolean; + acknowledgedAt?: string; + acknowledgedBy?: { id: string; name: string; email: string }; + createdAt: string; + instance?: { id: string; name: string; slug: string }; +} + +export interface EventSummary { + errors: number; + warnings: number; + infos: number; + total: number; + recentErrors: InstanceEvent[]; +} + // ─── Audit Types ────────────────────────────────────────────────── export interface AuditLogEntry { diff --git a/changemaker-control-panel/api/prisma/migrations/20260324043047_add_upgrades_and_events/migration.sql b/changemaker-control-panel/api/prisma/migrations/20260324043047_add_upgrades_and_events/migration.sql new file mode 100644 index 00000000..cb0fd3ea --- /dev/null +++ b/changemaker-control-panel/api/prisma/migrations/20260324043047_add_upgrades_and_events/migration.sql @@ -0,0 +1,67 @@ +-- CreateEnum +CREATE TYPE "UpgradeStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'ROLLED_BACK'); + +-- CreateEnum +CREATE TYPE "EventSeverity" AS ENUM ('ERROR', 'WARNING', 'INFO'); + +-- CreateTable +CREATE TABLE "instance_upgrades" ( + "id" TEXT NOT NULL, + "instance_id" TEXT NOT NULL, + "status" "UpgradeStatus" NOT NULL DEFAULT 'PENDING', + "previous_commit" TEXT, + "new_commit" TEXT, + "commit_count" INTEGER NOT NULL DEFAULT 0, + "branch" TEXT NOT NULL DEFAULT 'v2', + "current_phase" INTEGER NOT NULL DEFAULT 0, + "phase_name" TEXT, + "percentage" INTEGER NOT NULL DEFAULT 0, + "progress_message" TEXT, + "duration_seconds" INTEGER, + "error_message" TEXT, + "warnings" JSONB, + "log" TEXT, + "triggered_by_id" TEXT, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + + CONSTRAINT "instance_upgrades_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "instance_events" ( + "id" TEXT NOT NULL, + "instance_id" TEXT NOT NULL, + "severity" "EventSeverity" NOT NULL, + "source" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "metadata" JSONB, + "acknowledged" BOOLEAN NOT NULL DEFAULT false, + "acknowledged_at" TIMESTAMP(3), + "acknowledged_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "instance_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "instance_upgrades_instance_id_started_at_idx" ON "instance_upgrades"("instance_id", "started_at"); + +-- CreateIndex +CREATE INDEX "instance_events_instance_id_created_at_idx" ON "instance_events"("instance_id", "created_at"); + +-- CreateIndex +CREATE INDEX "instance_events_severity_acknowledged_idx" ON "instance_events"("severity", "acknowledged"); + +-- AddForeignKey +ALTER TABLE "instance_upgrades" ADD CONSTRAINT "instance_upgrades_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "instance_upgrades" ADD CONSTRAINT "instance_upgrades_triggered_by_id_fkey" FOREIGN KEY ("triggered_by_id") REFERENCES "ccp_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "instance_events" ADD CONSTRAINT "instance_events_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "instance_events" ADD CONSTRAINT "instance_events_acknowledged_by_id_fkey" FOREIGN KEY ("acknowledged_by_id") REFERENCES "ccp_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/changemaker-control-panel/api/prisma/schema.prisma b/changemaker-control-panel/api/prisma/schema.prisma index 7afee9f5..820ee27d 100644 --- a/changemaker-control-panel/api/prisma/schema.prisma +++ b/changemaker-control-panel/api/prisma/schema.prisma @@ -24,8 +24,10 @@ model CcpUser { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - refreshTokens CcpRefreshToken[] - auditLogs AuditLog[] + refreshTokens CcpRefreshToken[] + auditLogs AuditLog[] + triggeredUpgrades InstanceUpgrade[] + acknowledgedEvents InstanceEvent[] @@map("ccp_users") } @@ -115,6 +117,8 @@ model Instance { healthChecks HealthCheck[] backups Backup[] auditLogs AuditLog[] + upgrades InstanceUpgrade[] + events InstanceEvent[] @@map("instances") } @@ -228,6 +232,77 @@ model AuditLog { @@map("audit_logs") } +// ─── Instance Upgrades ──────────────────────────────────── + +enum UpgradeStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED + ROLLED_BACK +} + +model InstanceUpgrade { + id String @id @default(uuid()) + instanceId String @map("instance_id") + status UpgradeStatus @default(PENDING) + previousCommit String? @map("previous_commit") + newCommit String? @map("new_commit") + commitCount Int @default(0) @map("commit_count") + branch String @default("v2") + + // Progress tracking (updated from progress.json polling) + currentPhase Int @default(0) @map("current_phase") + phaseName String? @map("phase_name") + percentage Int @default(0) + progressMessage String? @map("progress_message") + + // Result + durationSeconds Int? @map("duration_seconds") + errorMessage String? @map("error_message") + warnings Json? + log String? + + triggeredById String? @map("triggered_by_id") + startedAt DateTime @default(now()) @map("started_at") + completedAt DateTime? @map("completed_at") + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + triggeredBy CcpUser? @relation(fields: [triggeredById], references: [id], onDelete: SetNull) + + @@index([instanceId, startedAt]) + @@map("instance_upgrades") +} + +// ─── Instance Events (Errors/Warnings) ─────────────────── + +enum EventSeverity { + ERROR + WARNING + INFO +} + +model InstanceEvent { + id String @id @default(uuid()) + instanceId String @map("instance_id") + severity EventSeverity + source String // 'health_check', 'upgrade', 'container', 'provisioning' + title String + message String + metadata Json? + acknowledged Boolean @default(false) + acknowledgedAt DateTime? @map("acknowledged_at") + acknowledgedById String? @map("acknowledged_by_id") + createdAt DateTime @default(now()) @map("created_at") + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + acknowledgedBy CcpUser? @relation(fields: [acknowledgedById], references: [id], onDelete: SetNull) + + @@index([instanceId, createdAt]) + @@index([severity, acknowledged]) + @@map("instance_events") +} + // ─── CCP Settings ────────────────────────────────────────── model CcpSetting { diff --git a/changemaker-control-panel/api/src/modules/events/events.routes.ts b/changemaker-control-panel/api/src/modules/events/events.routes.ts new file mode 100644 index 00000000..9f3a7a91 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/events/events.routes.ts @@ -0,0 +1,80 @@ +import { Router, Request, Response } from 'express'; +import { EventSeverity } from '@prisma/client'; +import { authenticate, requireRole } from '../../middleware/auth'; +import * as eventService from '../../services/event.service'; + +const router = Router(); + +router.use(authenticate); + +// ─── Cross-Instance Event Queries ──────────────────────────────── + +// GET /api/events — list events with filters +router.get( + '/', + async (req: Request, res: Response) => { + const { instanceId, severity, acknowledged, source, from, to, page, limit } = req.query; + + const filters: eventService.EventFilters = { + instanceId: instanceId as string | undefined, + severity: severity ? (severity as EventSeverity) : undefined, + acknowledged: acknowledged !== undefined ? acknowledged === 'true' : undefined, + source: source as string | undefined, + from: from ? new Date(from as string) : undefined, + to: to ? new Date(to as string) : undefined, + page: page ? Math.max(1, parseInt(page as string, 10)) : 1, + limit: limit ? Math.min(100, Math.max(1, parseInt(limit as string, 10))) : 50, + }; + + const result = await eventService.listEvents(filters); + res.json(result); + } +); + +// GET /api/events/summary — unacknowledged counts for dashboard +router.get( + '/summary', + async (_req: Request, res: Response) => { + const summary = await eventService.getUnacknowledgedSummary(); + res.json({ data: summary }); + } +); + +// PUT /api/events/:id/acknowledge — acknowledge a single event +router.put( + '/:id/acknowledge', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const event = await eventService.acknowledgeEvent(req.params.id as string, req.user!.id); + res.json({ data: event }); + } +); + +// ─── Instance-Scoped Event Routes ──────────────────────────────── +// These are mounted at /api/instances/:id/events via a sub-export + +export const instanceEventsRouter = Router({ mergeParams: true }); +instanceEventsRouter.use(authenticate); + +// GET /api/instances/:id/events +instanceEventsRouter.get( + '/', + async (req: Request, res: Response) => { + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50)); + const result = await eventService.listInstanceEvents(req.params.id as string, page, limit); + res.json(result); + } +); + +// PUT /api/instances/:id/events/acknowledge-all +instanceEventsRouter.put( + '/acknowledge-all', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const result = await eventService.acknowledgeAllForInstance(req.params.id as string, req.user!.id); + res.json({ data: result }); + } +); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/instances/instances.routes.ts b/changemaker-control-panel/api/src/modules/instances/instances.routes.ts index d574dbbb..0f6f6a93 100644 --- a/changemaker-control-panel/api/src/modules/instances/instances.routes.ts +++ b/changemaker-control-panel/api/src/modules/instances/instances.routes.ts @@ -8,6 +8,7 @@ import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, rec import * as instancesService from './instances.service'; import * as healthService from '../../services/health.service'; import * as backupService from '../../services/backup.service'; +import * as upgradeService from '../../services/upgrade.service'; import { discoverInstances } from '../../services/discovery.service'; const secretsLimiter = rateLimit({ @@ -265,6 +266,52 @@ router.get( } ); +// ─── Upgrades ────────────────────────────────────────────────────── + +router.post( + '/:id/check-update', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const status = await upgradeService.checkForUpdates(req.params.id as string); + res.json({ data: status }); + } +); + +router.post( + '/:id/upgrade', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const { skipBackup, useRegistry, branch } = req.body || {}; + const upgrade = await upgradeService.startUpgrade( + req.params.id as string, + req.user!.id, + req.ip, + { skipBackup, useRegistry, branch } + ); + res.status(201).json({ data: upgrade }); + } +); + +router.get( + '/:id/upgrade-status', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const status = await upgradeService.getUpgradeStatus(req.params.id as string); + res.json({ data: status }); + } +); + +router.get( + '/:id/upgrade-history', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); + const result = await upgradeService.getUpgradeHistory(req.params.id as string, page, limit); + res.json(result); + } +); + // ─── Health Checks ────────────────────────────────────────────────── router.post( diff --git a/changemaker-control-panel/api/src/modules/instances/provisioner.ts b/changemaker-control-panel/api/src/modules/instances/provisioner.ts index c6900d03..e4816d31 100644 --- a/changemaker-control-panel/api/src/modules/instances/provisioner.ts +++ b/changemaker-control-panel/api/src/modules/instances/provisioner.ts @@ -10,6 +10,7 @@ import { decryptJson } from '../../utils/encryption'; import { renderAllTemplates, buildTemplateContext } from '../../services/template-engine'; import * as docker from '../../services/docker.service'; import { logger } from '../../utils/logger'; +import { createEvent } from '../../services/event.service'; const execFile = promisify(execFileCb); /** @@ -142,30 +143,28 @@ export async function provision(instanceId: string): Promise { docker.waitForHealthy(redisContainer, 60_000), ]); - // ── Step 10: Run database schema sync ───────────────────────── - await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Setting up database schema')); - logger.info(`[provisioner] ${instance.slug}: Pushing Prisma schema to database`); - // Use composeRun with --entrypoint "" to skip the API's startup entrypoint - // (which would try migrate deploy + seed and fail on schema drift) - await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db push --accept-data-loss', 180_000); - - // ── Step 11: Seed database ───────────────────────────────────── - await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Seeding database')); - logger.info(`[provisioner] ${instance.slug}: Seeding database`); - await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db seed', 120_000); - - // ── Step 12: Start all services ──────────────────────────────── + // ── Step 10: Start all services ──────────────────────────────── + // The API entrypoint (docker-entrypoint.sh) handles: + // 1. Wait for Postgres + // 2. prisma migrate deploy + // 3. prisma db seed (needs tsx — installed in production image) + // 4. Start the server await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting all services')); - logger.info(`[provisioner] ${instance.slug}: Starting all services`); + logger.info(`[provisioner] ${instance.slug}: Starting all services (entrypoint handles migrate + seed)`); await docker.composeUp(basePath, composeProject); - // ── Step 13: Health check ────────────────────────────────────── - await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Verifying instance health')); + // ── Step 11: Wait for API healthy ─────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Waiting for API to be ready')); logger.info(`[provisioner] ${instance.slug}: Waiting for API health check`); const ports = instance.portConfig as Record; - // Use host.docker.internal to reach ports exposed on the Docker host - // (localhost inside the CCP container refers to the CCP container itself) - await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 120_000); + await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 180_000); + + // ── Step 12: Verify instance health ───────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Verifying instance health')); + logger.info(`[provisioner] ${instance.slug}: Final health verification`); + await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 30_000); + + // ── Step 13: done (below) ─────────────────────────────────────── // ── Done! ────────────────────────────────────────────────────── await updateStatus(instanceId, InstanceStatus.RUNNING, 'Provisioning complete'); @@ -196,5 +195,15 @@ export async function provision(instanceId: string): Promise { details: { event: 'provisioning_failed', error: errorMsg, step }, }, }).catch(() => {}); + + // Create error event + await createEvent( + instanceId, + 'ERROR', + 'provisioning', + 'Provisioning failed', + `Failed at step ${step}/${totalSteps}: ${errorMsg.slice(0, 500)}`, + { step, totalSteps } + ).catch(() => {}); } } diff --git a/changemaker-control-panel/api/src/server.ts b/changemaker-control-panel/api/src/server.ts index c8b2e1d1..c29b11b1 100644 --- a/changemaker-control-panel/api/src/server.ts +++ b/changemaker-control-panel/api/src/server.ts @@ -15,6 +15,7 @@ import settingsRoutes from './modules/settings/settings.routes'; import healthRoutes from './modules/health/health.routes'; import auditRoutes from './modules/audit/audit.routes'; import backupRoutes from './modules/backups/backup.routes'; +import eventsRoutes, { instanceEventsRouter } from './modules/events/events.routes'; import { startHealthScheduler } from './services/health.service'; import { autoDiscoverOnStartup } from './services/discovery.service'; @@ -57,6 +58,8 @@ app.use('/api/settings', settingsRoutes); app.use('/api/health', healthRoutes); app.use('/api/audit', auditRoutes); app.use('/api/backups', backupRoutes); +app.use('/api/events', eventsRoutes); +app.use('/api/instances/:id/events', instanceEventsRouter); // Error handler (must be last) app.use(errorHandler); diff --git a/changemaker-control-panel/api/src/services/event.service.ts b/changemaker-control-panel/api/src/services/event.service.ts new file mode 100644 index 00000000..3504cf52 --- /dev/null +++ b/changemaker-control-panel/api/src/services/event.service.ts @@ -0,0 +1,174 @@ +import { EventSeverity, Prisma } from '@prisma/client'; +import { prisma } from '../lib/prisma'; +import { logger } from '../utils/logger'; + +// ─── Event Creation ─────────────────────────────────────────────── + +/** + * Create an instance event (error, warning, or info). + * Deduplicates: won't create a duplicate event if one with the same + * source + title exists for this instance within the last 5 minutes. + */ +export async function createEvent( + instanceId: string, + severity: 'ERROR' | 'WARNING' | 'INFO', + source: string, + title: string, + message: string, + metadata?: Record +): Promise { + // Deduplicate: avoid spamming the same event within 5 minutes + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + const existing = await prisma.instanceEvent.findFirst({ + where: { + instanceId, + source, + title, + createdAt: { gte: fiveMinAgo }, + }, + }); + + if (existing) { + logger.debug(`[events] Skipping duplicate event: ${title} for instance ${instanceId}`); + return; + } + + await prisma.instanceEvent.create({ + data: { + instanceId, + severity: severity as EventSeverity, + source, + title, + message, + metadata: metadata as Prisma.InputJsonValue ?? undefined, + }, + }); + + logger.info(`[events] ${severity} event for instance ${instanceId}: ${title}`); +} + +// ─── Event Queries ──────────────────────────────────────────────── + +export interface EventFilters { + instanceId?: string; + severity?: EventSeverity; + acknowledged?: boolean; + source?: string; + from?: Date; + to?: Date; + page?: number; + limit?: number; +} + +/** + * List events across all instances with filtering and pagination. + */ +export async function listEvents(filters: EventFilters) { + const page = filters.page || 1; + const limit = Math.min(filters.limit || 50, 100); + + const where: Prisma.InstanceEventWhereInput = {}; + if (filters.instanceId) where.instanceId = filters.instanceId; + if (filters.severity) where.severity = filters.severity; + if (filters.acknowledged !== undefined) where.acknowledged = filters.acknowledged; + if (filters.source) where.source = filters.source; + if (filters.from || filters.to) { + where.createdAt = {}; + if (filters.from) where.createdAt.gte = filters.from; + if (filters.to) where.createdAt.lte = filters.to; + } + + const [data, total] = await Promise.all([ + prisma.instanceEvent.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { + instance: { select: { id: true, name: true, slug: true } }, + acknowledgedBy: { select: { id: true, name: true, email: true } }, + }, + }), + prisma.instanceEvent.count({ where }), + ]); + + return { data, total, page, limit }; +} + +/** + * List events for a single instance. + */ +export async function listInstanceEvents(instanceId: string, page = 1, limit = 50) { + return listEvents({ instanceId, page, limit }); +} + +/** + * Acknowledge a single event. + */ +export async function acknowledgeEvent(eventId: string, userId: string) { + const event = await prisma.instanceEvent.findUnique({ where: { id: eventId } }); + if (!event) throw new Error('Event not found'); + + return prisma.instanceEvent.update({ + where: { id: eventId }, + data: { + acknowledged: true, + acknowledgedAt: new Date(), + acknowledgedById: userId, + }, + }); +} + +/** + * Acknowledge all unacknowledged events for an instance. + */ +export async function acknowledgeAllForInstance(instanceId: string, userId: string) { + const result = await prisma.instanceEvent.updateMany({ + where: { + instanceId, + acknowledged: false, + }, + data: { + acknowledged: true, + acknowledgedAt: new Date(), + acknowledgedById: userId, + }, + }); + + return { acknowledged: result.count }; +} + +/** + * Get summary of unacknowledged events by severity (for dashboard). + */ +export async function getUnacknowledgedSummary() { + const [errors, warnings, infos] = await Promise.all([ + prisma.instanceEvent.count({ + where: { acknowledged: false, severity: EventSeverity.ERROR }, + }), + prisma.instanceEvent.count({ + where: { acknowledged: false, severity: EventSeverity.WARNING }, + }), + prisma.instanceEvent.count({ + where: { acknowledged: false, severity: EventSeverity.INFO }, + }), + ]); + + // Also get the 5 most recent unacknowledged errors for dashboard display + const recentErrors = await prisma.instanceEvent.findMany({ + where: { acknowledged: false, severity: { in: [EventSeverity.ERROR, EventSeverity.WARNING] } }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + instance: { select: { id: true, name: true, slug: true } }, + }, + }); + + return { + errors, + warnings, + infos, + total: errors + warnings + infos, + recentErrors, + }; +} diff --git a/changemaker-control-panel/api/src/services/health.service.ts b/changemaker-control-panel/api/src/services/health.service.ts index 27995e43..4d2fa3f4 100644 --- a/changemaker-control-panel/api/src/services/health.service.ts +++ b/changemaker-control-panel/api/src/services/health.service.ts @@ -2,6 +2,7 @@ import { InstanceStatus, HealthStatus } from '@prisma/client'; import { prisma } from '../lib/prisma'; import * as docker from './docker.service'; import { logger } from '../utils/logger'; +import { createEvent } from './event.service'; import type { ContainerInfo } from './docker.service'; /** @@ -94,6 +95,13 @@ export async function checkInstanceHealth(instanceId: string) { const responseTimeMs = Date.now() - startTime; const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers); + // Get the previous health check to detect transitions + const previousCheck = await prisma.healthCheck.findFirst({ + where: { instanceId }, + orderBy: { checkedAt: 'desc' }, + select: { status: true }, + }); + const healthCheck = await prisma.healthCheck.create({ data: { instanceId, @@ -110,6 +118,44 @@ export async function checkInstanceHealth(instanceId: string) { data: { lastHealthCheck: new Date() }, }); + // Create events on health transitions + const previousStatus = previousCheck?.status; + if (status !== previousStatus) { + if (status === HealthStatus.UNHEALTHY) { + createEvent( + instanceId, + 'ERROR', + 'health_check', + 'Instance unhealthy', + `${instance.slug}: ${healthyServices}/${totalServices} services healthy`, + { serviceStatus, responseTimeMs } + ).catch((e) => logger.warn(`[health] Failed to create event: ${(e as Error).message}`)); + } else if (status === HealthStatus.DEGRADED && previousStatus !== HealthStatus.UNHEALTHY) { + createEvent( + instanceId, + 'WARNING', + 'health_check', + 'Instance degraded', + `${instance.slug}: ${healthyServices}/${totalServices} services healthy`, + { serviceStatus, responseTimeMs } + ).catch((e) => logger.warn(`[health] Failed to create event: ${(e as Error).message}`)); + } + } + + // Detect crashed containers (exited with non-zero exit code) + for (const c of containers) { + if (c.state === 'exited' && c.exitCode !== 0) { + createEvent( + instanceId, + 'ERROR', + 'container', + `Container crashed: ${c.service || c.name}`, + `${c.service || c.name} exited with code ${c.exitCode}`, + { service: c.service, container: c.name, exitCode: c.exitCode, status: c.status } + ).catch((e) => logger.warn(`[health] Failed to create container event: ${(e as Error).message}`)); + } + } + return healthCheck; } diff --git a/changemaker-control-panel/api/src/services/secret-generator.ts b/changemaker-control-panel/api/src/services/secret-generator.ts index 17594ae0..36e8f908 100644 --- a/changemaker-control-panel/api/src/services/secret-generator.ts +++ b/changemaker-control-panel/api/src/services/secret-generator.ts @@ -43,6 +43,7 @@ export interface InstanceSecrets { redisPassword: string; jwtAccessSecret: string; jwtRefreshSecret: string; + jwtInviteSecret: string; encryptionKey: string; initialAdminPassword: string; nocodbAdminPassword: string; @@ -66,6 +67,7 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm redisPassword: randomHex(16), jwtAccessSecret: randomHex(32), jwtRefreshSecret: randomHex(32), + jwtInviteSecret: randomHex(32), encryptionKey: randomHex(32), initialAdminPassword: randomPassword(16), nocodbAdminPassword: randomPassword(16), diff --git a/changemaker-control-panel/api/src/services/template-engine.ts b/changemaker-control-panel/api/src/services/template-engine.ts index 77a2b947..9f84399e 100644 --- a/changemaker-control-panel/api/src/services/template-engine.ts +++ b/changemaker-control-panel/api/src/services/template-engine.ts @@ -49,6 +49,7 @@ export interface TemplateContext { redisPassword: string; jwtAccessSecret: string; jwtRefreshSecret: string; + jwtInviteSecret: string; encryptionKey: string; initialAdminPassword: string; adminEmail: string; @@ -161,6 +162,7 @@ export function buildTemplateContext( redisPassword: secrets.redisPassword, jwtAccessSecret: secrets.jwtAccessSecret, jwtRefreshSecret: secrets.jwtRefreshSecret, + jwtInviteSecret: secrets.jwtInviteSecret || secrets.jwtAccessSecret, encryptionKey: secrets.encryptionKey, initialAdminPassword: secrets.initialAdminPassword, adminEmail: secrets.adminEmail, diff --git a/changemaker-control-panel/api/src/services/upgrade.service.ts b/changemaker-control-panel/api/src/services/upgrade.service.ts new file mode 100644 index 00000000..c315af4b --- /dev/null +++ b/changemaker-control-panel/api/src/services/upgrade.service.ts @@ -0,0 +1,410 @@ +import { exec as execCb } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs/promises'; +import path from 'path'; +import { UpgradeStatus, AuditAction, InstanceStatus, Prisma } from '@prisma/client'; +import { prisma } from '../lib/prisma'; +import { logger } from '../utils/logger'; +import { createEvent } from './event.service'; + +const exec = promisify(execCb); + +const UPGRADE_TIMEOUT = 600_000; // 10 minutes +const PROGRESS_POLL_INTERVAL = 2_000; // 2 seconds + +// ─── Update Check ───────────────────────────────────────────────── + +export interface UpdateStatus { + branch: string; + currentCommit: string; + currentMessage?: string; + remoteCommit: string | null; + commitsBehind: number; + changelog: Array<{ hash: string; message: string; date: string; author: string }>; + checkedAt: string; + error: string | null; +} + +/** + * Check for available updates by running upgrade-check.sh in the instance's basePath. + * Falls back to reading an existing status.json if the script isn't available. + */ +export async function checkForUpdates(instanceId: string): Promise { + const instance = await prisma.instance.findUnique({ where: { id: instanceId } }); + if (!instance) throw new Error('Instance not found'); + + const basePath = instance.basePath; + const statusFile = path.join(basePath, 'data', 'upgrade', 'status.json'); + const scriptPath = path.join(basePath, 'scripts', 'upgrade-check.sh'); + + // Try to run upgrade-check.sh + try { + await fs.access(scriptPath); + await exec(`bash "${scriptPath}"`, { + cwd: basePath, + timeout: 30_000, + env: { ...process.env, COMPOSE_ANSI: 'never' }, + }); + } catch (err) { + logger.warn(`[upgrade] upgrade-check.sh failed for ${instance.slug}: ${(err as Error).message}`); + // Script may have still written status.json before failing — try reading it + } + + // Read status.json + try { + const raw = await fs.readFile(statusFile, 'utf-8'); + const status = JSON.parse(raw) as UpdateStatus; + return status; + } catch { + // If no status.json exists, try to gather basic git info + try { + const { stdout: branch } = await exec('git rev-parse --abbrev-ref HEAD', { cwd: basePath, timeout: 5_000 }); + const { stdout: commit } = await exec('git rev-parse --short HEAD', { cwd: basePath, timeout: 5_000 }); + return { + branch: branch.trim(), + currentCommit: commit.trim(), + remoteCommit: null, + commitsBehind: 0, + changelog: [], + checkedAt: new Date().toISOString(), + error: 'Could not check for remote updates', + }; + } catch { + return { + branch: instance.gitBranch, + currentCommit: instance.gitCommit || 'unknown', + remoteCommit: null, + commitsBehind: 0, + changelog: [], + checkedAt: new Date().toISOString(), + error: 'Could not determine version info (no .git directory?)', + }; + } + } +} + +// ─── Upgrade Orchestration ──────────────────────────────────────── + +export interface StartUpgradeOptions { + skipBackup?: boolean; + useRegistry?: boolean; + branch?: string; +} + +/** + * Start an upgrade for an instance. Returns the created InstanceUpgrade record. + * The actual upgrade runs asynchronously (fire-and-forget). + */ +export async function startUpgrade( + instanceId: string, + userId: string, + ipAddress?: string, + options?: StartUpgradeOptions +) { + const instance = await prisma.instance.findUnique({ where: { id: instanceId } }); + if (!instance) throw new Error('Instance not found'); + + if (instance.status !== InstanceStatus.RUNNING && instance.status !== InstanceStatus.STOPPED) { + throw new Error(`Cannot upgrade instance in ${instance.status} state`); + } + + // Check for in-progress upgrades + const active = await prisma.instanceUpgrade.findFirst({ + where: { + instanceId, + status: { in: [UpgradeStatus.PENDING, UpgradeStatus.IN_PROGRESS] }, + }, + }); + if (active) { + throw new Error('An upgrade is already in progress for this instance'); + } + + // Get current commit for tracking + let currentCommit: string | null = null; + try { + const { stdout } = await exec('git rev-parse --short HEAD', { + cwd: instance.basePath, + timeout: 5_000, + }); + currentCommit = stdout.trim(); + } catch { + // Non-critical — may be a release install without .git + } + + const branch = options?.branch || instance.gitBranch; + + // Create upgrade record + const upgrade = await prisma.instanceUpgrade.create({ + data: { + instanceId, + status: UpgradeStatus.PENDING, + previousCommit: currentCommit, + branch, + triggeredById: userId, + }, + }); + + // Audit log + await prisma.auditLog.create({ + data: { + userId, + instanceId, + action: AuditAction.INSTANCE_UPGRADE, + details: { + upgradeId: upgrade.id, + previousCommit: currentCommit, + branch, + options: options || {}, + } as unknown as Prisma.InputJsonValue, + ipAddress, + }, + }); + + // Fire-and-forget: run the upgrade asynchronously + runUpgrade(upgrade.id, instance.basePath, instance.slug, options).catch((err) => { + logger.error(`[upgrade] Upgrade orchestration failed for ${instance.slug}: ${err}`); + }); + + return upgrade; +} + +/** + * Async upgrade runner. Runs upgrade.sh and polls progress. + */ +async function runUpgrade( + upgradeId: string, + basePath: string, + slug: string, + options?: StartUpgradeOptions +) { + const progressFile = path.join(basePath, 'data', 'upgrade', 'progress.json'); + const resultFile = path.join(basePath, 'data', 'upgrade', 'result.json'); + const scriptPath = path.join(basePath, 'scripts', 'upgrade.sh'); + + // Ensure data/upgrade directory exists + await fs.mkdir(path.join(basePath, 'data', 'upgrade'), { recursive: true }); + + // Clean up any stale progress/result files from previous runs + await fs.rm(progressFile, { force: true }); + await fs.rm(resultFile, { force: true }); + + // Mark as IN_PROGRESS + await prisma.instanceUpgrade.update({ + where: { id: upgradeId }, + data: { + status: UpgradeStatus.IN_PROGRESS, + progressMessage: 'Starting upgrade...', + }, + }); + + // Build command flags + const flags: string[] = ['--api-mode', '--force']; + if (options?.skipBackup) flags.push('--skip-backup'); + if (options?.useRegistry) flags.push('--use-registry'); + if (options?.branch) flags.push('--branch', options.branch); + + // Start progress polling + let pollTimer: NodeJS.Timeout | null = null; + pollTimer = setInterval(async () => { + try { + const raw = await fs.readFile(progressFile, 'utf-8'); + const progress = JSON.parse(raw); + await prisma.instanceUpgrade.update({ + where: { id: upgradeId }, + data: { + currentPhase: progress.phase || 0, + phaseName: progress.phaseName || null, + percentage: progress.percentage || 0, + progressMessage: progress.message || null, + }, + }); + } catch { + // progress.json may not exist yet or be mid-write + } + }, PROGRESS_POLL_INTERVAL); + + try { + // Run upgrade.sh + await exec(`bash "${scriptPath}" ${flags.join(' ')}`, { + cwd: basePath, + timeout: UPGRADE_TIMEOUT, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, COMPOSE_ANSI: 'never' }, + }); + + // Read result + const result = await readResultFile(resultFile); + + // Read log tail + const logTail = await readLatestLogTail(basePath); + + // Get new commit + let newCommit: string | null = null; + try { + const { stdout } = await exec('git rev-parse --short HEAD', { cwd: basePath, timeout: 5_000 }); + newCommit = stdout.trim(); + } catch { /* ignore */ } + + // Update the upgrade record + await prisma.instanceUpgrade.update({ + where: { id: upgradeId }, + data: { + status: result.success ? UpgradeStatus.COMPLETED : UpgradeStatus.FAILED, + newCommit: result.newCommit || newCommit, + commitCount: result.commitCount || 0, + percentage: 100, + phaseName: 'Complete', + progressMessage: result.message || 'Upgrade completed', + durationSeconds: result.durationSeconds || null, + warnings: result.warnings?.length ? result.warnings : undefined, + errorMessage: result.success ? null : (result.message || 'Upgrade failed'), + log: logTail, + completedAt: new Date(), + }, + }); + + // Update instance gitCommit + if (newCommit) { + await prisma.instance.update({ + where: { id: (await prisma.instanceUpgrade.findUnique({ where: { id: upgradeId } }))!.instanceId }, + data: { gitCommit: newCommit }, + }); + } + + if (!result.success) { + // Create error event + const upgrade = await prisma.instanceUpgrade.findUnique({ where: { id: upgradeId } }); + if (upgrade) { + await createEvent( + upgrade.instanceId, + 'ERROR', + 'upgrade', + 'Upgrade failed', + result.message || 'The upgrade process failed. Check logs for details.', + { upgradeId, previousCommit: upgrade.previousCommit, warnings: result.warnings } + ); + } + } + + logger.info(`[upgrade] ${slug}: Upgrade ${result.success ? 'completed' : 'failed'}`); + } catch (err) { + const errorMsg = (err as Error).message; + const isTimeout = errorMsg.includes('timed out'); + + // Read whatever result/progress we have + const result = await readResultFile(resultFile); + const logTail = await readLatestLogTail(basePath); + + await prisma.instanceUpgrade.update({ + where: { id: upgradeId }, + data: { + status: UpgradeStatus.FAILED, + errorMessage: isTimeout ? 'Upgrade timed out after 10 minutes' : errorMsg.slice(0, 2000), + progressMessage: 'Failed', + log: logTail, + completedAt: new Date(), + durationSeconds: result.durationSeconds || null, + }, + }); + + // Create error event + const upgrade = await prisma.instanceUpgrade.findUnique({ where: { id: upgradeId } }); + if (upgrade) { + await createEvent( + upgrade.instanceId, + 'ERROR', + 'upgrade', + isTimeout ? 'Upgrade timed out' : 'Upgrade failed', + isTimeout ? 'The upgrade process timed out after 10 minutes.' : errorMsg.slice(0, 500), + { upgradeId } + ); + + // Set instance to ERROR state + await prisma.instance.update({ + where: { id: upgrade.instanceId }, + data: { + status: InstanceStatus.ERROR, + statusMessage: `Upgrade failed: ${isTimeout ? 'timeout' : errorMsg.slice(0, 200)}`, + }, + }); + } + + logger.error(`[upgrade] ${slug}: Upgrade failed: ${errorMsg}`); + } finally { + if (pollTimer) clearInterval(pollTimer); + } +} + +// ─── File Readers ───────────────────────────────────────────────── + +interface UpgradeResult { + success: boolean; + message: string; + previousCommit?: string; + newCommit?: string; + commitCount?: number; + durationSeconds?: number; + warnings?: string[]; +} + +async function readResultFile(resultFile: string): Promise { + try { + const raw = await fs.readFile(resultFile, 'utf-8'); + return JSON.parse(raw); + } catch { + return { success: false, message: 'No result file found' }; + } +} + +async function readLatestLogTail(basePath: string): Promise { + try { + const logDir = path.join(basePath, 'logs'); + const files = await fs.readdir(logDir); + const upgradeLog = files + .filter((f) => f.startsWith('upgrade-')) + .sort() + .pop(); + if (!upgradeLog) return null; + + const content = await fs.readFile(path.join(logDir, upgradeLog), 'utf-8'); + // Return last 5000 chars to keep DB storage reasonable + return content.slice(-5000); + } catch { + return null; + } +} + +// ─── Query Functions ────────────────────────────────────────────── + +/** + * Get the current/latest upgrade progress for an instance. + */ +export async function getUpgradeStatus(instanceId: string) { + return prisma.instanceUpgrade.findFirst({ + where: { instanceId }, + orderBy: { startedAt: 'desc' }, + include: { + triggeredBy: { select: { id: true, name: true, email: true } }, + }, + }); +} + +/** + * Get paginated upgrade history for an instance. + */ +export async function getUpgradeHistory(instanceId: string, page = 1, limit = 20) { + const [data, total] = await Promise.all([ + prisma.instanceUpgrade.findMany({ + where: { instanceId }, + orderBy: { startedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { + triggeredBy: { select: { id: true, name: true, email: true } }, + }, + }), + prisma.instanceUpgrade.count({ where: { instanceId } }), + ]); + + return { data, total, page, limit }; +} diff --git a/changemaker-control-panel/templates/env.hbs b/changemaker-control-panel/templates/env.hbs index 38f110fa..f62971dd 100644 --- a/changemaker-control-panel/templates/env.hbs +++ b/changemaker-control-panel/templates/env.hbs @@ -24,6 +24,7 @@ REDIS_URL=redis://:{{secrets.redisPassword}}@{{containerPrefix}}-redis:6379 # JWT Auth JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}} JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}} +JWT_INVITE_SECRET={{secrets.jwtInviteSecret}} JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=7d diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1b9c9c11..69273ea7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -86,7 +86,11 @@ services: - JITSI_APP_SECRET=${JITSI_APP_SECRET:-} - JITSI_URL=${JITSI_URL:-http://jitsi-web-changemaker:80} - JITSI_EMBED_PORT=${JITSI_EMBED_PORT:-8893} - # Monitoring embed ports (for iframe embedding without DNS/subdomain) + # Embed ports for iframe embedding without DNS/subdomain (configurable for multi-instance) + - NOCODB_EMBED_PORT=${NOCODB_EMBED_PORT:-8881} + - N8N_EMBED_PORT=${N8N_EMBED_PORT:-8882} + - GITEA_EMBED_PORT=${GITEA_EMBED_PORT:-8883} + - MAILHOG_EMBED_PORT=${MAILHOG_EMBED_PORT:-8884} - GRAFANA_EMBED_PORT=${GRAFANA_EMBED_PORT:-8894} - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} # SMS Campaigns (Termux Android Bridge) @@ -229,19 +233,20 @@ services: ports: - "${NGINX_HTTP_PORT:-80}:80" - "${NGINX_HTTPS_PORT:-443}:443" - - "127.0.0.1:8881:8881" # NocoDB embed proxy (strips X-Frame-Options) - - "127.0.0.1:8882:8882" # n8n embed proxy - - "127.0.0.1:8883:8883" # Gitea embed proxy - - "127.0.0.1:8884:8884" # MailHog embed proxy - - "127.0.0.1:8885:8885" # Mini QR embed proxy - - "127.0.0.1:8886:8886" # Excalidraw embed proxy - - "127.0.0.1:8887:8887" # Homepage embed proxy - - "127.0.0.1:8890:8890" # Vaultwarden embed proxy - - "127.0.0.1:8891:8891" # Rocket.Chat embed proxy - - "127.0.0.1:8892:8892" # Gancio embed proxy - - "127.0.0.1:8893:8893" # Jitsi Meet embed proxy - - "127.0.0.1:8894:8894" # Grafana embed proxy - - "127.0.0.1:8895:8895" # Alertmanager embed proxy + # Embed proxy ports — configurable via .env to avoid conflicts in multi-instance deployments + - "127.0.0.1:${NOCODB_EMBED_PORT:-8881}:${NOCODB_EMBED_PORT:-8881}" + - "127.0.0.1:${N8N_EMBED_PORT:-8882}:${N8N_EMBED_PORT:-8882}" + - "127.0.0.1:${GITEA_EMBED_PORT:-8883}:${GITEA_EMBED_PORT:-8883}" + - "127.0.0.1:${MAILHOG_EMBED_PORT:-8884}:${MAILHOG_EMBED_PORT:-8884}" + - "127.0.0.1:${MINI_QR_EMBED_PORT:-8885}:${MINI_QR_EMBED_PORT:-8885}" + - "127.0.0.1:${EXCALIDRAW_EMBED_PORT:-8886}:${EXCALIDRAW_EMBED_PORT:-8886}" + - "127.0.0.1:${HOMEPAGE_EMBED_PORT:-8887}:${HOMEPAGE_EMBED_PORT:-8887}" + - "127.0.0.1:${VAULTWARDEN_EMBED_PORT:-8890}:${VAULTWARDEN_EMBED_PORT:-8890}" + - "127.0.0.1:${ROCKETCHAT_EMBED_PORT:-8891}:${ROCKETCHAT_EMBED_PORT:-8891}" + - "127.0.0.1:${GANCIO_EMBED_PORT:-8892}:${GANCIO_EMBED_PORT:-8892}" + - "127.0.0.1:${JITSI_EMBED_PORT:-8893}:${JITSI_EMBED_PORT:-8893}" + - "127.0.0.1:${GRAFANA_EMBED_PORT:-8894}:${GRAFANA_EMBED_PORT:-8894}" + - "127.0.0.1:${ALERTMANAGER_EMBED_PORT:-8895}:${ALERTMANAGER_EMBED_PORT:-8895}" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] interval: 30s @@ -250,6 +255,20 @@ services: environment: - DOMAIN=${DOMAIN:-cmlite.org} - PANGOLIN_SITE_ID=${PANGOLIN_SITE_ID:-} + # Embed proxy ports (passed to envsubst for nginx template processing) + - NOCODB_EMBED_PORT=${NOCODB_EMBED_PORT:-8881} + - N8N_EMBED_PORT=${N8N_EMBED_PORT:-8882} + - GITEA_EMBED_PORT=${GITEA_EMBED_PORT:-8883} + - MAILHOG_EMBED_PORT=${MAILHOG_EMBED_PORT:-8884} + - MINI_QR_EMBED_PORT=${MINI_QR_EMBED_PORT:-8885} + - EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886} + - HOMEPAGE_EMBED_PORT=${HOMEPAGE_EMBED_PORT:-8887} + - VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890} + - ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891} + - GANCIO_EMBED_PORT=${GANCIO_EMBED_PORT:-8892} + - JITSI_EMBED_PORT=${JITSI_EMBED_PORT:-8893} + - GRAFANA_EMBED_PORT=${GRAFANA_EMBED_PORT:-8894} + - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} volumes: # Note: conf.d is NOT mounted (configs are generated at startup from templates) - ./public-web:/usr/share/nginx/public-web:ro diff --git a/docker-compose.yml b/docker-compose.yml index b6174aa7..31eb12a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,7 +85,11 @@ services: - JITSI_APP_SECRET=${JITSI_APP_SECRET:-} - JITSI_URL=${JITSI_URL:-http://jitsi-web-changemaker:80} - JITSI_EMBED_PORT=${JITSI_EMBED_PORT:-8893} - # Monitoring embed ports (for iframe embedding without DNS/subdomain) + # Embed ports for iframe embedding without DNS/subdomain (configurable for multi-instance) + - NOCODB_EMBED_PORT=${NOCODB_EMBED_PORT:-8881} + - N8N_EMBED_PORT=${N8N_EMBED_PORT:-8882} + - GITEA_EMBED_PORT=${GITEA_EMBED_PORT:-8883} + - MAILHOG_EMBED_PORT=${MAILHOG_EMBED_PORT:-8884} - GRAFANA_EMBED_PORT=${GRAFANA_EMBED_PORT:-8894} - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} # SMS Campaigns (Termux Android Bridge) @@ -244,19 +248,20 @@ services: ports: - "${NGINX_HTTP_PORT:-80}:80" - "${NGINX_HTTPS_PORT:-443}:443" - - "127.0.0.1:8881:8881" # NocoDB embed proxy (strips X-Frame-Options) - - "127.0.0.1:8882:8882" # n8n embed proxy - - "127.0.0.1:8883:8883" # Gitea embed proxy - - "127.0.0.1:8884:8884" # MailHog embed proxy - - "127.0.0.1:8885:8885" # Mini QR embed proxy - - "127.0.0.1:8886:8886" # Excalidraw embed proxy - - "127.0.0.1:8887:8887" # Homepage embed proxy - - "127.0.0.1:8890:8890" # Vaultwarden embed proxy - - "127.0.0.1:8891:8891" # Rocket.Chat embed proxy - - "127.0.0.1:8892:8892" # Gancio embed proxy - - "127.0.0.1:8893:8893" # Jitsi Meet embed proxy - - "127.0.0.1:8894:8894" # Grafana embed proxy - - "127.0.0.1:8895:8895" # Alertmanager embed proxy + # Embed proxy ports — configurable via .env to avoid conflicts in multi-instance deployments + - "127.0.0.1:${NOCODB_EMBED_PORT:-8881}:${NOCODB_EMBED_PORT:-8881}" + - "127.0.0.1:${N8N_EMBED_PORT:-8882}:${N8N_EMBED_PORT:-8882}" + - "127.0.0.1:${GITEA_EMBED_PORT:-8883}:${GITEA_EMBED_PORT:-8883}" + - "127.0.0.1:${MAILHOG_EMBED_PORT:-8884}:${MAILHOG_EMBED_PORT:-8884}" + - "127.0.0.1:${MINI_QR_EMBED_PORT:-8885}:${MINI_QR_EMBED_PORT:-8885}" + - "127.0.0.1:${EXCALIDRAW_EMBED_PORT:-8886}:${EXCALIDRAW_EMBED_PORT:-8886}" + - "127.0.0.1:${HOMEPAGE_EMBED_PORT:-8887}:${HOMEPAGE_EMBED_PORT:-8887}" + - "127.0.0.1:${VAULTWARDEN_EMBED_PORT:-8890}:${VAULTWARDEN_EMBED_PORT:-8890}" + - "127.0.0.1:${ROCKETCHAT_EMBED_PORT:-8891}:${ROCKETCHAT_EMBED_PORT:-8891}" + - "127.0.0.1:${GANCIO_EMBED_PORT:-8892}:${GANCIO_EMBED_PORT:-8892}" + - "127.0.0.1:${JITSI_EMBED_PORT:-8893}:${JITSI_EMBED_PORT:-8893}" + - "127.0.0.1:${GRAFANA_EMBED_PORT:-8894}:${GRAFANA_EMBED_PORT:-8894}" + - "127.0.0.1:${ALERTMANAGER_EMBED_PORT:-8895}:${ALERTMANAGER_EMBED_PORT:-8895}" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] interval: 30s @@ -265,6 +270,20 @@ services: environment: - DOMAIN=${DOMAIN:-cmlite.org} - PANGOLIN_SITE_ID=${PANGOLIN_SITE_ID:-} + # Embed proxy ports (passed to envsubst for nginx template processing) + - NOCODB_EMBED_PORT=${NOCODB_EMBED_PORT:-8881} + - N8N_EMBED_PORT=${N8N_EMBED_PORT:-8882} + - GITEA_EMBED_PORT=${GITEA_EMBED_PORT:-8883} + - MAILHOG_EMBED_PORT=${MAILHOG_EMBED_PORT:-8884} + - MINI_QR_EMBED_PORT=${MINI_QR_EMBED_PORT:-8885} + - EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886} + - HOMEPAGE_EMBED_PORT=${HOMEPAGE_EMBED_PORT:-8887} + - VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890} + - ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891} + - GANCIO_EMBED_PORT=${GANCIO_EMBED_PORT:-8892} + - JITSI_EMBED_PORT=${JITSI_EMBED_PORT:-8893} + - GRAFANA_EMBED_PORT=${GRAFANA_EMBED_PORT:-8894} + - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} volumes: # Note: conf.d is NOT mounted (configs are generated at startup from templates) - ./public-web:/usr/share/nginx/public-web:ro diff --git a/nginx/conf.d/services.conf.template b/nginx/conf.d/services.conf.template index f3821431..f88b78df 100644 --- a/nginx/conf.d/services.conf.template +++ b/nginx/conf.d/services.conf.template @@ -267,9 +267,10 @@ server { # --- Embed proxy ports (for iframe embedding without DNS/subdomain) --- # These listen on dedicated ports so the admin GUI can iframe services via # localhost:PORT, bypassing X-Frame-Options without needing *.localhost DNS. +# Ports are configurable via env vars to avoid conflicts in multi-instance deployments. server { - listen 8881; + listen ${NOCODB_EMBED_PORT}; location / { set $upstream_nocodb http://changemaker-v2-nocodb:8080; proxy_pass $upstream_nocodb; @@ -283,7 +284,7 @@ server { } server { - listen 8882; + listen ${N8N_EMBED_PORT}; location / { set $upstream_n8n http://n8n-changemaker:5678; proxy_pass $upstream_n8n; @@ -299,7 +300,7 @@ server { } server { - listen 8883; + listen ${GITEA_EMBED_PORT}; # Increase max body size for large git pushes (2GB) client_max_body_size 2048M; location / { @@ -315,7 +316,7 @@ server { } server { - listen 8884; + listen ${MAILHOG_EMBED_PORT}; location / { set $upstream_mailhog http://mailhog-changemaker:8025; proxy_pass $upstream_mailhog; @@ -331,7 +332,7 @@ server { } server { - listen 8885; + listen ${MINI_QR_EMBED_PORT}; location / { set $upstream_miniqr http://mini-qr:8080; proxy_pass $upstream_miniqr; @@ -344,9 +345,9 @@ server { } } -# Excalidraw embed proxy (port 8886) +# Excalidraw embed proxy server { - listen 8886; + listen ${EXCALIDRAW_EMBED_PORT}; location / { set $upstream_excalidraw http://excalidraw-changemaker:80; proxy_pass $upstream_excalidraw; @@ -542,9 +543,9 @@ server { } } -# Homepage embed proxy (port 8887) +# Homepage embed proxy server { - listen 8887; + listen ${HOMEPAGE_EMBED_PORT}; location / { set $upstream_homepage http://homepage-changemaker:3000; proxy_pass $upstream_homepage; @@ -557,9 +558,9 @@ server { } } -# Vaultwarden embed proxy (port 8890) +# Vaultwarden embed proxy server { - listen 8890; + listen ${VAULTWARDEN_EMBED_PORT}; location / { set $upstream_vaultwarden http://vaultwarden-changemaker:80; proxy_pass $upstream_vaultwarden; @@ -575,9 +576,9 @@ server { } } -# Rocket.Chat embed proxy (port 8891) +# Rocket.Chat embed proxy server { - listen 8891; + listen ${ROCKETCHAT_EMBED_PORT}; location / { set $upstream_rocketchat http://rocketchat-changemaker:3000; proxy_pass $upstream_rocketchat; @@ -594,9 +595,9 @@ server { } } -# Gancio embed proxy (port 8892) +# Gancio embed proxy server { - listen 8892; + listen ${GANCIO_EMBED_PORT}; location / { set $upstream_gancio http://gancio-changemaker:13120; proxy_pass $upstream_gancio; @@ -609,9 +610,9 @@ server { } } -# Jitsi Meet embed proxy (port 8893) +# Jitsi Meet embed proxy server { - listen 8893; + listen ${JITSI_EMBED_PORT}; location / { set $upstream_jitsi http://jitsi-web-changemaker:80; proxy_pass $upstream_jitsi; @@ -627,9 +628,9 @@ server { } } -# Grafana embed proxy (port 8894) +# Grafana embed proxy server { - listen 8894; + listen ${GRAFANA_EMBED_PORT}; location / { set $upstream_grafana http://grafana-changemaker:3000; proxy_pass $upstream_grafana; @@ -644,9 +645,9 @@ server { } } -# Alertmanager embed proxy (port 8895) +# Alertmanager embed proxy server { - listen 8895; + listen ${ALERTMANAGER_EMBED_PORT}; location / { set $upstream_alertmanager http://alertmanager-changemaker:9093; proxy_pass $upstream_alertmanager; diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh index fdf837f6..0b66889a 100755 --- a/nginx/entrypoint.sh +++ b/nginx/entrypoint.sh @@ -4,12 +4,36 @@ set -e # Default domain if not set export DOMAIN=${DOMAIN:-cmlite.org} +# Default embed proxy ports (configurable via .env for multi-instance deployments) +export NOCODB_EMBED_PORT=${NOCODB_EMBED_PORT:-8881} +export N8N_EMBED_PORT=${N8N_EMBED_PORT:-8882} +export GITEA_EMBED_PORT=${GITEA_EMBED_PORT:-8883} +export MAILHOG_EMBED_PORT=${MAILHOG_EMBED_PORT:-8884} +export MINI_QR_EMBED_PORT=${MINI_QR_EMBED_PORT:-8885} +export EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886} +export HOMEPAGE_EMBED_PORT=${HOMEPAGE_EMBED_PORT:-8887} +export VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890} +export ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891} +export GANCIO_EMBED_PORT=${GANCIO_EMBED_PORT:-8892} +export JITSI_EMBED_PORT=${JITSI_EMBED_PORT:-8893} +export GRAFANA_EMBED_PORT=${GRAFANA_EMBED_PORT:-8894} +export ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} + echo "Configuring nginx for domain: $DOMAIN" +# List of environment variables to substitute in templates +# IMPORTANT: must be explicit to avoid replacing nginx variables like $host, $remote_addr +VARS='${DOMAIN}' +VARS="${VARS} \${NOCODB_EMBED_PORT} \${N8N_EMBED_PORT} \${GITEA_EMBED_PORT}" +VARS="${VARS} \${MAILHOG_EMBED_PORT} \${MINI_QR_EMBED_PORT} \${EXCALIDRAW_EMBED_PORT}" +VARS="${VARS} \${HOMEPAGE_EMBED_PORT} \${VAULTWARDEN_EMBED_PORT} \${ROCKETCHAT_EMBED_PORT}" +VARS="${VARS} \${GANCIO_EMBED_PORT} \${JITSI_EMBED_PORT} \${GRAFANA_EMBED_PORT}" +VARS="${VARS} \${ALERTMANAGER_EMBED_PORT}" + # Template nginx configuration files with environment variables -envsubst '${DOMAIN}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf -envsubst '${DOMAIN}' < /etc/nginx/conf.d/api.conf.template > /etc/nginx/conf.d/api.conf -envsubst '${DOMAIN}' < /etc/nginx/conf.d/services.conf.template > /etc/nginx/conf.d/services.conf +envsubst "$VARS" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf +envsubst "$VARS" < /etc/nginx/conf.d/api.conf.template > /etc/nginx/conf.d/api.conf +envsubst "$VARS" < /etc/nginx/conf.d/services.conf.template > /etc/nginx/conf.d/services.conf echo "Nginx configuration templated successfully"