Make embed proxy ports configurable via env vars for multi-instance deployments

All 13 nginx embed proxy ports (8881-8895) are now driven by environment
variables instead of being hardcoded. This prevents port conflicts when
running multiple Changemaker instances on the same host.

Chain: .env → docker-compose port mappings → nginx container env →
entrypoint.sh envsubst → services.conf.template listen directives →
API /services/config endpoint → frontend buildServiceUrl().

Existing deployments are unaffected (all vars default to current values).

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-25 15:25:00 -06:00
parent 63e05adcee
commit abdfd50cb8
24 changed files with 1679 additions and 95 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -74,6 +74,6 @@
"typescript": "^5.7.3"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
"seed": "npx tsx prisma/seed.ts"
}
}

View File

@ -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;

View File

@ -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<Date | null>(null);
const [eventSummary, setEventSummary] = useState<EventSummary | null>(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() {
/>
</Card>
</Col>
<Col xs={12} sm={6} lg={4}>
<Card>
<Statistic
title="Unread Events"
value={eventSummary?.total || 0}
valueStyle={(eventSummary?.errors || 0) > 0 ? { color: '#ff4d4f' } : (eventSummary?.warnings || 0) > 0 ? { color: '#faad14' } : undefined}
prefix={<BellOutlined />}
/>
</Card>
</Col>
</Row>
{/* Recent Errors */}
{eventSummary && eventSummary.recentErrors.length > 0 && (
<Card
title={
<span>
<CloseCircleOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
Recent Events ({eventSummary.errors} error{eventSummary.errors !== 1 ? 's' : ''}, {eventSummary.warnings} warning{eventSummary.warnings !== 1 ? 's' : ''})
</span>
}
size="small"
style={{ marginBottom: 24 }}
>
<List
size="small"
dataSource={eventSummary.recentErrors}
renderItem={(event) => (
<List.Item
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/app/instances/${event.instanceId}`)}
>
<List.Item.Meta
avatar={
<Tag color={event.severity === 'ERROR' ? 'red' : 'orange'}>
{event.severity}
</Tag>
}
title={
<span>
<Typography.Text strong>{event.instance?.name || 'Unknown'}</Typography.Text>
{' — '}
{event.title}
</span>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{event.source} {dayjs(event.createdAt).format('MM-DD HH:mm')}
</Typography.Text>
}
/>
</List.Item>
)}
/>
</Card>
)}
{instances.length === 0 ? (
<Empty
description="No instances yet"

View File

@ -38,11 +38,17 @@ import {
CopyOutlined,
CloudOutlined,
DisconnectOutlined,
UploadOutlined,
BellOutlined,
CheckCircleOutlined,
WarningOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '@/lib/api';
import type { Instance, HealthCheck, Backup } from '@/types/api';
import type { Instance, HealthCheck, Backup, UpdateStatus, InstanceUpgrade, InstanceEvent } from '@/types/api';
import ServiceHealthGrid, { type ServiceInfo } from '@/components/ServiceHealthGrid';
import LogViewer from '@/components/LogViewer';
@ -103,6 +109,20 @@ export default function InstanceDetailPage() {
const [tunnelSaving, setTunnelSaving] = useState(false);
const [tunnelRemoving, setTunnelRemoving] = useState(false);
// Upgrade state
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [upgradingInstance, setUpgradingInstance] = useState(false);
const [currentUpgrade, setCurrentUpgrade] = useState<InstanceUpgrade | null>(null);
const [upgradeHistory, setUpgradeHistory] = useState<InstanceUpgrade[]>([]);
const [upgradeHistoryLoading, setUpgradeHistoryLoading] = useState(false);
const upgradePollingRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Events state
const [events, setEvents] = useState<InstanceEvent[]>([]);
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() {
</Space>
);
// ─── Updates Tab ──────────────────────────────────────────────
const isUpgrading = currentUpgrade?.status === 'IN_PROGRESS' || currentUpgrade?.status === 'PENDING';
const upgradeStatusColors: Record<string, string> = {
PENDING: 'default',
IN_PROGRESS: 'processing',
COMPLETED: 'green',
FAILED: 'red',
ROLLED_BACK: 'orange',
};
const updatesTab = (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Version Info Card */}
<Card
title="Version Information"
size="small"
extra={
<Button
icon={<ReloadOutlined />}
onClick={handleCheckUpdate}
loading={checkingUpdate}
disabled={isUpgrading}
>
Check for Updates
</Button>
}
>
<Descriptions bordered column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="Branch">{instance.gitBranch}</Descriptions.Item>
<Descriptions.Item label="Current Commit">
<Typography.Text code>{instance.gitCommit || updateStatus?.currentCommit || 'Unknown'}</Typography.Text>
</Descriptions.Item>
{updateStatus && (
<>
<Descriptions.Item label="Remote Commit">
<Typography.Text code>{updateStatus.remoteCommit || 'N/A'}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="Updates Available">
{updateStatus.commitsBehind > 0 ? (
<Tag color="blue">{updateStatus.commitsBehind} commit{updateStatus.commitsBehind !== 1 ? 's' : ''} behind</Tag>
) : (
<Tag color="green">Up to date</Tag>
)}
</Descriptions.Item>
{updateStatus.error && (
<Descriptions.Item label="Check Error" span={2}>
<Typography.Text type="danger">{updateStatus.error}</Typography.Text>
</Descriptions.Item>
)}
<Descriptions.Item label="Last Checked" span={2}>
{dayjs(updateStatus.checkedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</>
)}
</Descriptions>
</Card>
{/* Changelog */}
{updateStatus && updateStatus.changelog.length > 0 && updateStatus.commitsBehind > 0 && (
<Card title="Changelog" size="small">
{updateStatus.changelog.map((entry, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<Space>
<Typography.Text code style={{ fontSize: 12 }}>{entry.hash?.slice(0, 7)}</Typography.Text>
<Typography.Text>{entry.message}</Typography.Text>
</Space>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{entry.author} {dayjs(entry.date).format('MM-DD HH:mm')}
</Typography.Text>
</div>
))}
</Card>
)}
{/* Upgrade Action */}
{!isRegistered && (
<Card title="Upgrade" size="small">
{isUpgrading && currentUpgrade ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
message={`Upgrading — Phase ${currentUpgrade.currentPhase}: ${currentUpgrade.phaseName || 'Starting...'}`}
description={currentUpgrade.progressMessage || 'Processing...'}
type="info"
showIcon
icon={<SyncOutlined spin />}
/>
<Progress
percent={currentUpgrade.percentage}
status="active"
strokeColor={{ from: '#108ee9', to: '#87d068' }}
/>
</Space>
) : (
<Space direction="vertical" style={{ width: '100%' }}>
{currentUpgrade?.status === 'FAILED' && currentUpgrade.errorMessage && (
<Alert
message="Last Upgrade Failed"
description={currentUpgrade.errorMessage}
type="error"
showIcon
closable
/>
)}
{currentUpgrade?.status === 'COMPLETED' && (
<Alert
message="Last Upgrade Succeeded"
description={`Updated to ${currentUpgrade.newCommit || 'latest'} (${currentUpgrade.durationSeconds ? `${Math.round(currentUpgrade.durationSeconds / 60)}min ${currentUpgrade.durationSeconds % 60}s` : 'duration unknown'})`}
type="success"
showIcon
closable
/>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
Pulls latest code, runs migrations, and restarts services. CCP backup is recommended before upgrading.
</Typography.Text>
<Popconfirm
title="Start upgrade?"
description="This will pull the latest code, run database migrations, and restart all services. Brief downtime is expected."
onConfirm={handleStartUpgrade}
disabled={instance.status !== 'RUNNING' && instance.status !== 'STOPPED'}
>
<Button
type="primary"
icon={<UploadOutlined />}
loading={upgradingInstance}
disabled={instance.status !== 'RUNNING' && instance.status !== 'STOPPED'}
>
Upgrade Now
</Button>
</Popconfirm>
</div>
</Space>
)}
</Card>
)}
{isRegistered && (
<Alert
message="Upgrades are not managed by CCP for external instances"
description="Run the upgrade script directly on the instance or use its own upgrade mechanism."
type="info"
showIcon
/>
)}
{/* Upgrade History */}
<Card title="Upgrade History" size="small">
{upgradeHistory.length > 0 ? (
<Table
dataSource={upgradeHistory}
rowKey="id"
loading={upgradeHistoryLoading}
size="small"
pagination={false}
columns={[
{
title: 'Status',
dataIndex: 'status',
width: 120,
render: (s: string) => <Tag color={upgradeStatusColors[s]}>{s}</Tag>,
},
{
title: 'Commits',
render: (_: unknown, r: InstanceUpgrade) => (
<span>
<Typography.Text code style={{ fontSize: 11 }}>{r.previousCommit || '?'}</Typography.Text>
{' → '}
<Typography.Text code style={{ fontSize: 11 }}>{r.newCommit || '?'}</Typography.Text>
</span>
),
},
{
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 ? <Tag color="orange">{count}</Tag> : '-';
},
},
]}
/>
) : (
<Empty description="No upgrades recorded" />
)}
</Card>
</Space>
);
// ─── Events Tab ──────────────────────────────────────────────
const severityIcons: Record<string, React.ReactNode> = {
ERROR: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
WARNING: <WarningOutlined style={{ color: '#faad14' }} />,
INFO: <InfoCircleOutlined style={{ color: '#1677ff' }} />,
};
const severityColors: Record<string, string> = {
ERROR: 'red',
WARNING: 'orange',
INFO: 'blue',
};
const unacknowledgedCount = events.filter((e) => !e.acknowledged).length;
const eventsTab = (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space>
<Typography.Text type="secondary">
{eventsTotal} event{eventsTotal !== 1 ? 's' : ''}
</Typography.Text>
{unacknowledgedCount > 0 && (
<Tag color="red">{unacknowledgedCount} unacknowledged</Tag>
)}
</Space>
<Space>
{unacknowledgedCount > 0 && (
<Popconfirm
title={`Acknowledge all ${unacknowledgedCount} event(s)?`}
onConfirm={handleAcknowledgeAll}
>
<Button icon={<CheckCircleOutlined />} size="small">
Acknowledge All
</Button>
</Popconfirm>
)}
<Button icon={<ReloadOutlined />} onClick={fetchEvents} loading={eventsLoading} size="small">
Refresh
</Button>
</Space>
</div>
{events.length > 0 ? (
<Table
dataSource={events}
rowKey="id"
loading={eventsLoading}
size="small"
pagination={{ pageSize: 20 }}
columns={[
{
title: 'Severity',
dataIndex: 'severity',
width: 100,
filters: [
{ text: 'Error', value: 'ERROR' },
{ text: 'Warning', value: 'WARNING' },
{ text: 'Info', value: 'INFO' },
],
onFilter: (value, record) => record.severity === value,
render: (s: string) => (
<Tag color={severityColors[s]} icon={severityIcons[s]}>
{s}
</Tag>
),
},
{
title: 'Source',
dataIndex: 'source',
width: 120,
render: (s: string) => <Tag>{s.replace('_', ' ')}</Tag>,
},
{
title: 'Title',
dataIndex: 'title',
ellipsis: true,
},
{
title: 'Message',
dataIndex: 'message',
ellipsis: true,
render: (msg: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: msg }}>
{msg}
</Typography.Text>
),
},
{
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 ? (
<Tag color="default" icon={<CheckCircleOutlined />}>Acknowledged</Tag>
) : (
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => handleAcknowledgeEvent(record.id)}
>
Acknowledge
</Button>
)
),
},
]}
/>
) : (
<Empty description="No events recorded" />
)}
</Space>
);
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: <UploadOutlined />, children: updatesTab },
{ key: 'events', label: unacknowledgedCount > 0 ? `Events (${unacknowledgedCount})` : 'Events', icon: <BellOutlined />, children: eventsTab },
{ key: 'credentials', label: 'Credentials', icon: <LockOutlined />, children: credentialsTab },
{ key: 'features', label: 'Features', children: featuresTab },
{ key: 'services', label: 'Services', children: servicesTab },

View File

@ -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<string, unknown>;
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 {

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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(

View File

@ -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<void> {
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<string, number>;
// 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<void> {
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(() => {});
}
}

View File

@ -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);

View File

@ -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<string, unknown>
): Promise<void> {
// 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,
};
}

View File

@ -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;
}

View File

@ -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),

View File

@ -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,

View File

@ -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<UpdateStatus> {
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<UpgradeResult> {
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<string | null> {
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 };
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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"