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:
parent
63e05adcee
commit
abdfd50cb8
29
.env.example
29
.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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -74,6 +74,6 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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"
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
@ -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(
|
||||
|
||||
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
174
changemaker-control-panel/api/src/services/event.service.ts
Normal file
174
changemaker-control-panel/api/src/services/event.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
410
changemaker-control-panel/api/src/services/upgrade.service.ts
Normal file
410
changemaker-control-panel/api/src/services/upgrade.service.ts
Normal 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 };
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user