Add browser-based system upgrade UI with file-based IPC
API container writes trigger files to a shared volume (data/upgrade/), and a systemd path watcher on the host detects them and runs the upgrade scripts. This avoids giving the container Docker socket access. - Add upgrade-check.sh (git fetch + compare + write status.json) - Add upgrade-watcher.sh (systemd bridge, dispatches check/upgrade) - Add systemd path/service units with placeholder substitution - Modify upgrade.sh with --api-mode flag (progress.json + result.json) - Add API upgrade module (service + routes, SUPER_ADMIN only) - Add System tab to Settings page with version info, changelog, progress steps, and upgrade confirmation modal - Add upgrade watcher installation to config.sh wizard - Add data/upgrade/ shared volume to api service in docker-compose Bunker Admin
This commit is contained in:
parent
576dea2f98
commit
da3e43fcf7
4
.gitignore
vendored
4
.gitignore
vendored
@ -31,7 +31,9 @@ node_modules/
|
|||||||
/influence/app/public/uploadsdata/
|
/influence/app/public/uploadsdata/
|
||||||
|
|
||||||
# NAR data directory (large voter registry files)
|
# NAR data directory (large voter registry files)
|
||||||
/data/
|
/data/*
|
||||||
|
!/data/upgrade/
|
||||||
|
/data/upgrade/*.json
|
||||||
|
|
||||||
# Media files (managed by Docker volumes, not git)
|
# Media files (managed by Docker volumes, not git)
|
||||||
/media/
|
/media/
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useOutletContext, useLocation } from 'react-router-dom';
|
import { useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
@ -19,6 +19,12 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
|
Slider,
|
||||||
|
Progress,
|
||||||
|
Steps,
|
||||||
|
Modal,
|
||||||
|
Checkbox,
|
||||||
|
Timeline,
|
||||||
message,
|
message,
|
||||||
Spin,
|
Spin,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -39,13 +45,21 @@ import {
|
|||||||
MailOutlined,
|
MailOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
PhoneOutlined,
|
PhoneOutlined,
|
||||||
|
CloudSyncOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
BranchesOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type { SmtpTestResult, SmtpSendTestResult } from '@/types/api';
|
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult } from '@/types/api';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
@ -204,8 +218,8 @@ export default function SettingsPage() {
|
|||||||
<Form.Item label="Container Color" name="publicColorBgContainer">
|
<Form.Item label="Container Color" name="publicColorBgContainer">
|
||||||
<ColorPicker format="hex" showText />
|
<ColorPicker format="hex" showText />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Header Gradient" name="publicHeaderGradient" extra="CSS gradient string, e.g. linear-gradient(135deg, #005a9c 0%, #007acc 100%)">
|
<Form.Item label="Header Gradient" name="publicHeaderGradient">
|
||||||
<Input placeholder="linear-gradient(135deg, #005a9c 0%, #007acc 100%)" />
|
<GradientPicker />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -220,18 +234,6 @@ export default function SettingsPage() {
|
|||||||
<Swatch label="Public BG" color={settings.publicColorBgBase} />
|
<Swatch label="Public BG" color={settings.publicColorBgBase} />
|
||||||
<Swatch label="Public Container" color={settings.publicColorBgContainer} />
|
<Swatch label="Public Container" color={settings.publicColorBgContainer} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: '12px 24px',
|
|
||||||
background: settings.publicHeaderGradient,
|
|
||||||
borderRadius: 8,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Header Gradient Preview
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -652,6 +654,12 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: 'System',
|
||||||
|
icon: <CloudSyncOutlined />,
|
||||||
|
children: <SystemUpgradeTab />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -666,6 +674,433 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// System Upgrade Tab
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const UPGRADE_PHASES = [
|
||||||
|
'Pre-flight Checks',
|
||||||
|
'Backup',
|
||||||
|
'Code Update',
|
||||||
|
'Container Rebuild',
|
||||||
|
'Service Restart',
|
||||||
|
'Verification',
|
||||||
|
];
|
||||||
|
|
||||||
|
function SystemUpgradeTab() {
|
||||||
|
const [status, setStatus] = useState<UpgradeStatus | null>(null);
|
||||||
|
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
|
||||||
|
const [result, setResult] = useState<UpgradeResult | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
|
const [apiOffline, setApiOffline] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [skipBackup, setSkipBackup] = useState(false);
|
||||||
|
const [pullServices, setPullServices] = useState(false);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const checkStartRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<UpgradeStatusResponse>('/upgrade/status');
|
||||||
|
setStatus(data.status);
|
||||||
|
setProgress(data.progress);
|
||||||
|
setResult(data.result);
|
||||||
|
setRunning(data.running);
|
||||||
|
setApiOffline(false);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
setApiOffline(true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus().then((data) => {
|
||||||
|
// If already running when we load, start polling
|
||||||
|
if (data?.running) {
|
||||||
|
setUpgrading(true);
|
||||||
|
startUpgradePoll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => stopPoll();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopPoll = () => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCheckPoll = () => {
|
||||||
|
stopPoll();
|
||||||
|
checkStartRef.current = status?.checkedAt || null;
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
const data = await fetchStatus();
|
||||||
|
if (!data) return;
|
||||||
|
// Check complete when checkedAt changes
|
||||||
|
if (data.status && data.status.checkedAt !== checkStartRef.current) {
|
||||||
|
setChecking(false);
|
||||||
|
stopPoll();
|
||||||
|
message.success('Update check complete');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
// Auto-stop after 30s
|
||||||
|
setTimeout(() => {
|
||||||
|
if (checking) {
|
||||||
|
setChecking(false);
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startUpgradePoll = () => {
|
||||||
|
stopPoll();
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
const data = await fetchStatus();
|
||||||
|
// If API is offline, show restarting message (expected during upgrade)
|
||||||
|
if (!data) return;
|
||||||
|
// Upgrade complete when result appears or not running anymore
|
||||||
|
if (data.result && !data.running) {
|
||||||
|
setUpgrading(false);
|
||||||
|
stopPoll();
|
||||||
|
if (data.result.success) {
|
||||||
|
message.success('Upgrade completed successfully');
|
||||||
|
} else {
|
||||||
|
message.error('Upgrade failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckForUpdates = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
await api.post('/upgrade/check');
|
||||||
|
startCheckPoll();
|
||||||
|
} catch {
|
||||||
|
setChecking(false);
|
||||||
|
message.error('Failed to trigger update check');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartUpgrade = async () => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setUpgrading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
await api.post('/upgrade/start', { skipBackup, pullServices });
|
||||||
|
startUpgradePoll();
|
||||||
|
} catch {
|
||||||
|
setUpgrading(false);
|
||||||
|
message.error('Failed to trigger upgrade');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearResult = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/upgrade/clear-result');
|
||||||
|
setResult(null);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to clear result');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeTime = (dateStr: string) => {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpgrading = upgrading || running;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 800 }}>
|
||||||
|
{/* Current Version */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><BranchesOutlined /> Current Version</Space>}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
{status ? (
|
||||||
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||||||
|
<Descriptions.Item label="Branch">
|
||||||
|
<Tag color="blue">{status.branch}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Commit">
|
||||||
|
<Text code>{status.currentCommit}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Last Commit Message" span={2}>
|
||||||
|
<Text>{status.currentMessage}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Commit Date">
|
||||||
|
{status.currentDate ? formatDate(status.currentDate) : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Last Checked">
|
||||||
|
{status.checkedAt ? (
|
||||||
|
<Text type="secondary">{formatRelativeTime(status.checkedAt)}</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Never</Text>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
) : (
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
No version information available. Click "Check for Updates" to fetch the current status.
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Available Updates */}
|
||||||
|
{status && status.commitsBehind > 0 && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<HistoryOutlined />
|
||||||
|
Available Updates
|
||||||
|
<Tag color="orange">{status.commitsBehind} commit{status.commitsBehind !== 1 ? 's' : ''} behind</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||||
|
<Timeline
|
||||||
|
items={status.changelog.map((entry) => ({
|
||||||
|
color: 'blue',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Space size={4}>
|
||||||
|
<Text code style={{ fontSize: 12 }}>{entry.hash}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{entry.author}</Text>
|
||||||
|
</Space>
|
||||||
|
<div><Text style={{ fontSize: 13 }}>{entry.message}</Text></div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && status.commitsBehind === 0 && status.checkedAt && !status.error && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
message="Up to date"
|
||||||
|
description={`Your installation is on the latest commit (${status.currentCommit}) of the ${status.branch} branch.`}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.error && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message="Update check failed"
|
||||||
|
description={status.error}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={checking ? <LoadingOutlined /> : <SyncOutlined />}
|
||||||
|
loading={checking}
|
||||||
|
onClick={handleCheckForUpdates}
|
||||||
|
disabled={isUpgrading}
|
||||||
|
>
|
||||||
|
Check for Updates
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<RocketOutlined />}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={isUpgrading || !status || status.commitsBehind === 0}
|
||||||
|
>
|
||||||
|
Start Upgrade
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* Upgrade Progress */}
|
||||||
|
{isUpgrading && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><LoadingOutlined spin /> Upgrade in Progress</Space>}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
{apiOffline ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="Services restarting..."
|
||||||
|
description="The API is temporarily offline while containers are being rebuilt. This is expected. The page will automatically reconnect."
|
||||||
|
showIcon
|
||||||
|
icon={<ReloadOutlined spin />}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
) : progress ? (
|
||||||
|
<>
|
||||||
|
<Steps
|
||||||
|
size="small"
|
||||||
|
current={progress.phase - 1}
|
||||||
|
items={UPGRADE_PHASES.map((name, i) => ({
|
||||||
|
title: name,
|
||||||
|
status: progress.phase - 1 > i ? 'finish' : progress.phase - 1 === i ? 'process' : 'wait',
|
||||||
|
}))}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Progress percent={progress.percentage} status="active" />
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{progress.message}
|
||||||
|
</Paragraph>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<Spin />
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
Waiting for upgrade to start...
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last Result */}
|
||||||
|
{result && !isUpgrading && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><HistoryOutlined /> Last Upgrade Result</Space>}
|
||||||
|
extra={
|
||||||
|
<Button size="small" type="text" onClick={handleClearResult}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type={result.success ? 'success' : 'error'}
|
||||||
|
message={result.success ? 'Upgrade Successful' : 'Upgrade Failed'}
|
||||||
|
description={result.message}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||||||
|
<Descriptions.Item label="Previous">
|
||||||
|
<Text code>{result.previousCommit}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Current">
|
||||||
|
<Text code>{result.newCommit}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Commits">
|
||||||
|
{result.commitCount}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Duration">
|
||||||
|
{formatDuration(result.durationSeconds)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Completed">
|
||||||
|
{formatDate(result.completedAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{result.warnings.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="Warnings"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||||
|
{result.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Systemd Setup Info */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><InfoCircleOutlined /> Setup</Space>}
|
||||||
|
style={{ marginBottom: 16, opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||||
|
The upgrade system requires a systemd path watcher on the host. This is normally
|
||||||
|
installed by <Text code>./config.sh</Text> during initial setup. To install or
|
||||||
|
reinstall manually:
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph code copyable style={{ fontSize: 12, marginBottom: 0 }}>
|
||||||
|
{`cd ~/changemaker.lite && sudo cp scripts/systemd/changemaker-upgrade.* /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-upgrade.path`}
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upgrade Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||||
|
Confirm System Upgrade
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={confirmOpen}
|
||||||
|
onOk={handleStartUpgrade}
|
||||||
|
onCancel={() => setConfirmOpen(false)}
|
||||||
|
okText="Start Upgrade"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Paragraph>
|
||||||
|
This will pull the latest code, rebuild containers, and restart all services.
|
||||||
|
The admin panel will be briefly unavailable during the restart.
|
||||||
|
</Paragraph>
|
||||||
|
{status && (
|
||||||
|
<Paragraph>
|
||||||
|
<Text strong>{status.commitsBehind}</Text> commit{status.commitsBehind !== 1 ? 's' : ''} will
|
||||||
|
be applied from <Text code>{status.currentCommit}</Text> to <Text code>{status.remoteCommit}</Text>.
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<Space direction="vertical">
|
||||||
|
<Checkbox
|
||||||
|
checked={pullServices}
|
||||||
|
onChange={(e) => setPullServices(e.target.checked)}
|
||||||
|
>
|
||||||
|
Pull third-party images (PostgreSQL, Redis, etc.)
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={skipBackup}
|
||||||
|
onChange={(e) => setSkipBackup(e.target.checked)}
|
||||||
|
>
|
||||||
|
<Text type="danger">Skip backup (not recommended)</Text>
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Swatch({ label, color }: { label: string; color: string }) {
|
function Swatch({ label, color }: { label: string; color: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
@ -683,3 +1118,139 @@ function Swatch({ label, color }: { label: string; color: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse "linear-gradient(135deg, #005a9c 0%, #007acc 100%)" into parts */
|
||||||
|
function parseGradient(val: string): { angle: number; color1: string; color2: string } {
|
||||||
|
const defaults = { angle: 135, color1: '#005a9c', color2: '#007acc' };
|
||||||
|
if (!val || !val.includes('linear-gradient')) return defaults;
|
||||||
|
const inner = val.replace(/^linear-gradient\(/, '').replace(/\)$/, '');
|
||||||
|
// Match angle
|
||||||
|
const angleMatch = inner.match(/^(\d+)deg/);
|
||||||
|
const angle = angleMatch ? parseInt(angleMatch[1]!, 10) : defaults.angle;
|
||||||
|
// Match colors (hex, rgb, named)
|
||||||
|
const colorMatches = inner.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g);
|
||||||
|
return {
|
||||||
|
angle,
|
||||||
|
color1: colorMatches?.[0] ?? defaults.color1,
|
||||||
|
color2: colorMatches?.[1] ?? colorMatches?.[0] ?? defaults.color2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGradient(angle: number, color1: string, color2: string): string {
|
||||||
|
return `linear-gradient(${angle}deg, ${color1} 0%, ${color2} 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom form control — accepts value/onChange from Form.Item */
|
||||||
|
function GradientPicker({ value, onChange }: { value?: string; onChange?: (val: string) => void }) {
|
||||||
|
const parsed = parseGradient(value || '');
|
||||||
|
const [angle, setAngle] = useState(parsed.angle);
|
||||||
|
const [color1, setColor1] = useState(parsed.color1);
|
||||||
|
const [color2, setColor2] = useState(parsed.color2);
|
||||||
|
|
||||||
|
// Sync from external value changes (e.g., form reset)
|
||||||
|
useEffect(() => {
|
||||||
|
const p = parseGradient(value || '');
|
||||||
|
setAngle(p.angle);
|
||||||
|
setColor1(p.color1);
|
||||||
|
setColor2(p.color2);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const emit = useCallback(
|
||||||
|
(a: number, c1: string, c2: string) => {
|
||||||
|
onChange?.(buildGradient(a, c1, c2));
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gradient = buildGradient(angle, color1, color2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Live preview */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: gradient,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '16px 24px',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Header Gradient Preview
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color pickers row */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Start Color</Text>
|
||||||
|
<ColorPicker
|
||||||
|
value={color1}
|
||||||
|
format="hex"
|
||||||
|
showText
|
||||||
|
onChange={(_val, hex) => {
|
||||||
|
setColor1(hex);
|
||||||
|
emit(angle, hex, color2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>End Color</Text>
|
||||||
|
<ColorPicker
|
||||||
|
value={color2}
|
||||||
|
format="hex"
|
||||||
|
showText
|
||||||
|
onChange={(_val, hex) => {
|
||||||
|
setColor2(hex);
|
||||||
|
emit(angle, color1, hex);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setColor1(color2);
|
||||||
|
setColor2(color1);
|
||||||
|
emit(angle, color2, color1);
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 18 }}
|
||||||
|
>
|
||||||
|
Swap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Angle slider */}
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12 }}>Angle: {angle}°</Text>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={angle}
|
||||||
|
onChange={(val) => {
|
||||||
|
setAngle(val);
|
||||||
|
emit(val, color1, color2);
|
||||||
|
}}
|
||||||
|
tooltip={{ formatter: (v) => `${v}°` }}
|
||||||
|
styles={{ track: { background: gradient } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw CSS output (editable) */}
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={gradient}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
onChange?.(raw);
|
||||||
|
const p = parseGradient(raw);
|
||||||
|
setAngle(p.angle);
|
||||||
|
setColor1(p.color1);
|
||||||
|
setColor2(p.color2);
|
||||||
|
}}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -2854,3 +2854,52 @@ export interface PollDetailResponse extends SchedulingPoll {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- System Upgrade ---
|
||||||
|
|
||||||
|
export interface UpgradeChangelogEntry {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
date: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeStatus {
|
||||||
|
branch: string;
|
||||||
|
currentCommit: string;
|
||||||
|
currentCommitFull: string;
|
||||||
|
currentMessage: string;
|
||||||
|
currentDate: string;
|
||||||
|
remoteCommit: string | null;
|
||||||
|
remoteCommitFull?: string | null;
|
||||||
|
commitsBehind: number;
|
||||||
|
changelog: UpgradeChangelogEntry[];
|
||||||
|
checkedAt: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeProgress {
|
||||||
|
phase: number;
|
||||||
|
phaseName: string;
|
||||||
|
percentage: number;
|
||||||
|
message: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
previousCommit: string;
|
||||||
|
newCommit: string;
|
||||||
|
commitCount: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
warnings: string[];
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeStatusResponse {
|
||||||
|
status: UpgradeStatus | null;
|
||||||
|
progress: UpgradeProgress | null;
|
||||||
|
result: UpgradeResult | null;
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
69
api/src/modules/upgrade/upgrade.routes.ts
Normal file
69
api/src/modules/upgrade/upgrade.routes.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { upgradeService } from './upgrade.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require SUPER_ADMIN
|
||||||
|
router.use(authenticate, requireRole('SUPER_ADMIN'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/upgrade/status
|
||||||
|
* Returns combined status: version info, progress (if running), and last result.
|
||||||
|
*/
|
||||||
|
router.get('/status', (_req, res) => {
|
||||||
|
const status = upgradeService.getStatus();
|
||||||
|
const progress = upgradeService.getProgress();
|
||||||
|
const result = upgradeService.getResult();
|
||||||
|
const running = upgradeService.isRunning();
|
||||||
|
|
||||||
|
res.json({ status, progress: running ? progress : null, result, running });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/upgrade/check
|
||||||
|
* Triggers an update check (writes trigger.json for systemd watcher).
|
||||||
|
*/
|
||||||
|
router.post('/check', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userEmail = req.user?.email || 'unknown';
|
||||||
|
upgradeService.triggerCheck(userEmail);
|
||||||
|
res.json({ message: 'Update check triggered' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to trigger check';
|
||||||
|
res.status(409).json({ error: { message, code: 'UPGRADE_BUSY' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/upgrade/start
|
||||||
|
* Triggers an upgrade (writes trigger.json for systemd watcher).
|
||||||
|
* Body: { skipBackup?: boolean, pullServices?: boolean, dryRun?: boolean }
|
||||||
|
*/
|
||||||
|
router.post('/start', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userEmail = req.user?.email || 'unknown';
|
||||||
|
const { skipBackup, pullServices, dryRun } = req.body as {
|
||||||
|
skipBackup?: boolean;
|
||||||
|
pullServices?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
upgradeService.triggerUpgrade(userEmail, { skipBackup, pullServices, dryRun });
|
||||||
|
res.json({ message: 'Upgrade triggered' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to trigger upgrade';
|
||||||
|
res.status(409).json({ error: { message, code: 'UPGRADE_BUSY' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/upgrade/clear-result
|
||||||
|
* Removes the last upgrade result file.
|
||||||
|
*/
|
||||||
|
router.post('/clear-result', (_req, res) => {
|
||||||
|
upgradeService.clearResult();
|
||||||
|
res.json({ message: 'Result cleared' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as upgradeRouter };
|
||||||
182
api/src/modules/upgrade/upgrade.service.ts
Normal file
182
api/src/modules/upgrade/upgrade.service.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade service — reads/writes JSON files from the shared upgrade directory.
|
||||||
|
* The API container writes trigger files; the host systemd watcher reads them
|
||||||
|
* and runs upgrade scripts. Status/progress/result files are written by the
|
||||||
|
* host scripts and read by this service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UPGRADE_DIR = path.resolve('/app/upgrade');
|
||||||
|
const STATUS_FILE = path.join(UPGRADE_DIR, 'status.json');
|
||||||
|
const PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json');
|
||||||
|
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
|
||||||
|
const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.json');
|
||||||
|
|
||||||
|
// Stale threshold: if progress hasn't been updated in this many ms, assume crashed
|
||||||
|
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
interface UpgradeStatus {
|
||||||
|
branch: string;
|
||||||
|
currentCommit: string;
|
||||||
|
currentCommitFull: string;
|
||||||
|
currentMessage: string;
|
||||||
|
currentDate: string;
|
||||||
|
remoteCommit: string | null;
|
||||||
|
remoteCommitFull?: string | null;
|
||||||
|
commitsBehind: number;
|
||||||
|
changelog: Array<{
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
date: string;
|
||||||
|
author: string;
|
||||||
|
}>;
|
||||||
|
checkedAt: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeProgress {
|
||||||
|
phase: number;
|
||||||
|
phaseName: string;
|
||||||
|
percentage: number;
|
||||||
|
message: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
previousCommit: string;
|
||||||
|
newCommit: string;
|
||||||
|
commitCount: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
warnings: string[];
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerPayload {
|
||||||
|
action: 'check' | 'upgrade';
|
||||||
|
branch?: string;
|
||||||
|
skipBackup?: boolean;
|
||||||
|
pullServices?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
triggeredAt: string;
|
||||||
|
triggeredBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFile<T>(filePath: string): T | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to read ${path.basename(filePath)}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonFile(filePath: string, data: unknown): void {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to write ${path.basename(filePath)}:`, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(): UpgradeStatus | null {
|
||||||
|
return readJsonFile<UpgradeStatus>(STATUS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgress(): UpgradeProgress | null {
|
||||||
|
return readJsonFile<UpgradeProgress>(PROGRESS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResult(): UpgradeResult | null {
|
||||||
|
return readJsonFile<UpgradeResult>(RESULT_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(): boolean {
|
||||||
|
const progress = getProgress();
|
||||||
|
if (!progress) return false;
|
||||||
|
|
||||||
|
// Check if progress is stale (script probably crashed)
|
||||||
|
const lastUpdate = new Date(progress.lastUpdate).getTime();
|
||||||
|
const age = Date.now() - lastUpdate;
|
||||||
|
if (age > STALE_THRESHOLD_MS) {
|
||||||
|
logger.warn(`Upgrade progress is stale (${Math.round(age / 60000)}min old), assuming crashed`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerCheck(triggeredBy: string, branch?: string): void {
|
||||||
|
if (isRunning()) {
|
||||||
|
throw new Error('An upgrade is already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TriggerPayload = {
|
||||||
|
action: 'check',
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
triggeredBy,
|
||||||
|
};
|
||||||
|
if (branch) payload.branch = branch;
|
||||||
|
|
||||||
|
writeJsonFile(TRIGGER_FILE, payload);
|
||||||
|
logger.info(`Update check triggered by ${triggeredBy}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpgrade(
|
||||||
|
triggeredBy: string,
|
||||||
|
options: { skipBackup?: boolean; pullServices?: boolean; dryRun?: boolean; branch?: string } = {},
|
||||||
|
): void {
|
||||||
|
if (isRunning()) {
|
||||||
|
throw new Error('An upgrade is already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TriggerPayload = {
|
||||||
|
action: 'upgrade',
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
triggeredBy,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeJsonFile(TRIGGER_FILE, payload);
|
||||||
|
logger.info(`Upgrade triggered by ${triggeredBy} (options: ${JSON.stringify(options)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResult(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(RESULT_FILE)) {
|
||||||
|
fs.unlinkSync(RESULT_FILE);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to clear result file:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStaleProgress(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(PROGRESS_FILE) && !isRunning()) {
|
||||||
|
fs.unlinkSync(PROGRESS_FILE);
|
||||||
|
logger.info('Cleaned up stale upgrade progress file');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upgradeService = {
|
||||||
|
getStatus,
|
||||||
|
getProgress,
|
||||||
|
getResult,
|
||||||
|
isRunning,
|
||||||
|
triggerCheck,
|
||||||
|
triggerUpgrade,
|
||||||
|
clearResult,
|
||||||
|
clearStaleProgress,
|
||||||
|
};
|
||||||
@ -53,6 +53,7 @@ import { narImportRouter } from './modules/map/locations/nar-import.routes';
|
|||||||
import { areaImportRouter } from './modules/map/locations/area-import.routes';
|
import { areaImportRouter } from './modules/map/locations/area-import.routes';
|
||||||
import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes';
|
import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes';
|
||||||
import { observabilityRouter } from './modules/observability/observability.routes';
|
import { observabilityRouter } from './modules/observability/observability.routes';
|
||||||
|
import { upgradeRouter } from './modules/upgrade/upgrade.routes';
|
||||||
import { dashboardRouter } from './modules/dashboard/dashboard.routes';
|
import { dashboardRouter } from './modules/dashboard/dashboard.routes';
|
||||||
import { initEncryption } from './utils/crypto';
|
import { initEncryption } from './utils/crypto';
|
||||||
import { emailService } from './services/email.service';
|
import { emailService } from './services/email.service';
|
||||||
@ -104,6 +105,7 @@ import { socialRouter } from './modules/social/social.routes';
|
|||||||
import { errorReportRouter } from './modules/reports/error-report.routes';
|
import { errorReportRouter } from './modules/reports/error-report.routes';
|
||||||
import { sseService } from './modules/social/sse.service';
|
import { sseService } from './modules/social/sse.service';
|
||||||
import { presenceService } from './modules/social/presence.service';
|
import { presenceService } from './modules/social/presence.service';
|
||||||
|
import { upgradeService } from './modules/upgrade/upgrade.service';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -233,6 +235,7 @@ app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel ma
|
|||||||
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
|
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
|
||||||
app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required)
|
app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required)
|
||||||
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
|
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
|
||||||
|
app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN)
|
||||||
app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles)
|
app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles)
|
||||||
app.use('/api/donation-pages', donationPagesPublicRouter); // Public donation pages (no auth)
|
app.use('/api/donation-pages', donationPagesPublicRouter); // Public donation pages (no auth)
|
||||||
app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription)
|
app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription)
|
||||||
@ -366,6 +369,9 @@ async function start() {
|
|||||||
sseService.startHeartbeat();
|
sseService.startHeartbeat();
|
||||||
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
||||||
|
|
||||||
|
// Clean up stale upgrade progress on startup
|
||||||
|
upgradeService.clearStaleProgress();
|
||||||
|
|
||||||
// Setup Rocket.Chat notification channels (non-blocking)
|
// Setup Rocket.Chat notification channels (non-blocking)
|
||||||
rocketchatWebhookService.setupChannels().catch(() => {});
|
rocketchatWebhookService.setupChannels().catch(() => {});
|
||||||
|
|
||||||
|
|||||||
59
config.sh
59
config.sh
@ -1004,6 +1004,63 @@ fix_container_permissions() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Upgrade Watcher (systemd)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
install_upgrade_watcher() {
|
||||||
|
header "System Upgrade Watcher"
|
||||||
|
|
||||||
|
info "The upgrade watcher lets you trigger upgrades from the admin Settings page."
|
||||||
|
info "It installs a systemd path watcher that monitors for trigger files."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ensure upgrade IPC directory exists
|
||||||
|
mkdir -p "$SCRIPT_DIR/data/upgrade"
|
||||||
|
|
||||||
|
local unit_src="$SCRIPT_DIR/scripts/systemd"
|
||||||
|
if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then
|
||||||
|
warn "Systemd unit templates not found in scripts/systemd/ — skipping"
|
||||||
|
UPGRADE_WATCHER="skipped"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v systemctl &>/dev/null; then
|
||||||
|
warn "systemctl not found — skipping (not a systemd host?)"
|
||||||
|
UPGRADE_WATCHER="skipped"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then
|
||||||
|
# Generate units with correct paths substituted
|
||||||
|
local tmp_path tmp_service
|
||||||
|
tmp_path=$(mktemp)
|
||||||
|
tmp_service=$(mktemp)
|
||||||
|
|
||||||
|
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-upgrade.path" > "$tmp_path"
|
||||||
|
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-upgrade.service" > "$tmp_service"
|
||||||
|
|
||||||
|
if sudo cp "$tmp_path" /etc/systemd/system/changemaker-upgrade.path \
|
||||||
|
&& sudo cp "$tmp_service" /etc/systemd/system/changemaker-upgrade.service \
|
||||||
|
&& sudo systemctl daemon-reload \
|
||||||
|
&& sudo systemctl enable --now changemaker-upgrade.path; then
|
||||||
|
success "Upgrade watcher installed and enabled"
|
||||||
|
UPGRADE_WATCHER="yes"
|
||||||
|
else
|
||||||
|
warn "Failed to install systemd units (sudo may have failed)"
|
||||||
|
warn "Install manually later:"
|
||||||
|
echo -e " ${CYAN}sudo cp scripts/systemd/changemaker-upgrade.* /etc/systemd/system/${NC}"
|
||||||
|
echo -e " ${CYAN}sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-upgrade.path${NC}"
|
||||||
|
UPGRADE_WATCHER="manual"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$tmp_path" "$tmp_service"
|
||||||
|
else
|
||||||
|
info "Skipped. You can install it later from Settings > System."
|
||||||
|
UPGRADE_WATCHER="skipped"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Summary & Next Steps
|
# Summary & Next Steps
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -1025,6 +1082,7 @@ print_summary() {
|
|||||||
echo -e " ${BOLD}Docs Comments:${NC} ${DOCS_COMMENTS_ENABLED:-no}"
|
echo -e " ${BOLD}Docs Comments:${NC} ${DOCS_COMMENTS_ENABLED:-no}"
|
||||||
echo -e " ${BOLD}Bunker Ops:${NC} ${BUNKER_OPS_ENABLED:-no}"
|
echo -e " ${BOLD}Bunker Ops:${NC} ${BUNKER_OPS_ENABLED:-no}"
|
||||||
echo -e " ${BOLD}Pangolin:${NC} ${PANGOLIN_CONFIGURED:-no}"
|
echo -e " ${BOLD}Pangolin:${NC} ${PANGOLIN_CONFIGURED:-no}"
|
||||||
|
echo -e " ${BOLD}Upgrade watcher:${NC} ${UPGRADE_WATCHER:-skipped}"
|
||||||
echo -e " ${BOLD}Secrets:${NC} 21 auto-generated"
|
echo -e " ${BOLD}Secrets:${NC} 21 auto-generated"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${DIM}Config file: $ENV_FILE${NC}"
|
echo -e " ${DIM}Config file: $ENV_FILE${NC}"
|
||||||
@ -1083,6 +1141,7 @@ main() {
|
|||||||
generate_nginx_configs
|
generate_nginx_configs
|
||||||
generate_services_yaml
|
generate_services_yaml
|
||||||
fix_container_permissions
|
fix_container_permissions
|
||||||
|
install_upgrade_watcher
|
||||||
|
|
||||||
print_summary
|
print_summary
|
||||||
print_next_steps
|
print_next_steps
|
||||||
|
|||||||
0
data/upgrade/.gitkeep
Normal file
0
data/upgrade/.gitkeep
Normal file
@ -102,6 +102,7 @@ services:
|
|||||||
- ./assets/uploads:/app/uploads
|
- ./assets/uploads:/app/uploads
|
||||||
- ./mkdocs:/mkdocs:rw
|
- ./mkdocs:/mkdocs:rw
|
||||||
- ./data:/data:ro
|
- ./data:/data:ro
|
||||||
|
- ./data/upgrade:/app/upgrade:rw
|
||||||
- ./configs:/app/configs:ro
|
- ./configs:/app/configs:ro
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
10
scripts/systemd/changemaker-upgrade.path
Normal file
10
scripts/systemd/changemaker-upgrade.path
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Watch for Changemaker Lite upgrade triggers
|
||||||
|
Documentation=https://docs.cmlite.org/docs/admin/services/
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathExists=__PROJECT_DIR__/data/upgrade/trigger.json
|
||||||
|
MakeDirectory=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
13
scripts/systemd/changemaker-upgrade.service
Normal file
13
scripts/systemd/changemaker-upgrade.service
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Changemaker Lite upgrade dispatcher
|
||||||
|
Documentation=https://docs.cmlite.org/docs/admin/services/
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=__USER__
|
||||||
|
Group=__USER__
|
||||||
|
WorkingDirectory=__PROJECT_DIR__
|
||||||
|
ExecStart=__PROJECT_DIR__/scripts/upgrade-watcher.sh
|
||||||
|
TimeoutStartSec=900
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
105
scripts/upgrade-check.sh
Executable file
105
scripts/upgrade-check.sh
Executable file
@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Changemaker Lite V2 — Upgrade Check Script
|
||||||
|
# Checks for available updates and writes status to data/upgrade/status.json.
|
||||||
|
# Safe to run via cron or on-demand via file trigger.
|
||||||
|
# Usage: ./scripts/upgrade-check.sh [--branch BRANCH]
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
|
||||||
|
STATUS_FILE="${UPGRADE_DIR}/status.json"
|
||||||
|
BRANCH=""
|
||||||
|
|
||||||
|
# --- Parse Arguments ---
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--branch) BRANCH="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
mkdir -p "$UPGRADE_DIR"
|
||||||
|
|
||||||
|
# Determine branch
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write an error status and exit
|
||||||
|
write_error() {
|
||||||
|
local msg="$1"
|
||||||
|
cat > "$STATUS_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"branch": "${BRANCH}",
|
||||||
|
"currentCommit": "$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")",
|
||||||
|
"currentCommitFull": "$(git rev-parse HEAD 2>/dev/null || echo "unknown")",
|
||||||
|
"currentMessage": "$(git log -1 --format='%s' HEAD 2>/dev/null | sed 's/"/\\"/g' || echo "")",
|
||||||
|
"currentDate": "$(git log -1 --format='%aI' HEAD 2>/dev/null || echo "")",
|
||||||
|
"remoteCommit": null,
|
||||||
|
"commitsBehind": 0,
|
||||||
|
"changelog": [],
|
||||||
|
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"error": "${msg}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch latest from remote
|
||||||
|
if ! timeout 30 git fetch origin "$BRANCH" 2>/dev/null; then
|
||||||
|
write_error "Failed to reach git remote"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gather info
|
||||||
|
CURRENT_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
CURRENT_SHORT="$(git rev-parse --short HEAD)"
|
||||||
|
CURRENT_MSG="$(git log -1 --format='%s' HEAD | sed 's/"/\\"/g')"
|
||||||
|
CURRENT_DATE="$(git log -1 --format='%aI' HEAD)"
|
||||||
|
REMOTE_COMMIT="$(git rev-parse "origin/${BRANCH}" 2>/dev/null || echo "")"
|
||||||
|
REMOTE_SHORT="$(git rev-parse --short "origin/${BRANCH}" 2>/dev/null || echo "")"
|
||||||
|
|
||||||
|
if [[ -z "$REMOTE_COMMIT" ]]; then
|
||||||
|
write_error "Remote branch origin/${BRANCH} not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count commits behind
|
||||||
|
COMMITS_BEHIND=0
|
||||||
|
if [[ "$CURRENT_COMMIT" != "$REMOTE_COMMIT" ]]; then
|
||||||
|
COMMITS_BEHIND="$(git rev-list --count HEAD..origin/"${BRANCH}" 2>/dev/null || echo "0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build changelog (last 30 commits we're behind)
|
||||||
|
CHANGELOG="[]"
|
||||||
|
if [[ "$COMMITS_BEHIND" -gt 0 ]]; then
|
||||||
|
CHANGELOG="$(git log --oneline --format='{"hash":"%h","message":"%s","date":"%aI","author":"%an"}' HEAD..origin/"${BRANCH}" 2>/dev/null | head -30 | while IFS= read -r line; do
|
||||||
|
# Escape any double quotes in the message that aren't already escaped
|
||||||
|
echo "$line"
|
||||||
|
done | paste -sd ',' | sed 's/^/[/' | sed 's/$/]/')"
|
||||||
|
# Fallback if jq-less approach fails
|
||||||
|
if [[ -z "$CHANGELOG" ]] || [[ "$CHANGELOG" == "[]" ]]; then
|
||||||
|
CHANGELOG="[]"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write status
|
||||||
|
cat > "$STATUS_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"branch": "${BRANCH}",
|
||||||
|
"currentCommit": "${CURRENT_SHORT}",
|
||||||
|
"currentCommitFull": "${CURRENT_COMMIT}",
|
||||||
|
"currentMessage": "${CURRENT_MSG}",
|
||||||
|
"currentDate": "${CURRENT_DATE}",
|
||||||
|
"remoteCommit": "${REMOTE_SHORT}",
|
||||||
|
"remoteCommitFull": "${REMOTE_COMMIT}",
|
||||||
|
"commitsBehind": ${COMMITS_BEHIND},
|
||||||
|
"changelog": ${CHANGELOG},
|
||||||
|
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Update check complete: ${COMMITS_BEHIND} commit(s) behind on ${BRANCH}"
|
||||||
88
scripts/upgrade-watcher.sh
Executable file
88
scripts/upgrade-watcher.sh
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Changemaker Lite V2 — Upgrade Watcher (systemd bridge)
|
||||||
|
# Called by systemd path unit when data/upgrade/trigger.json is created.
|
||||||
|
# Reads the trigger, dispatches to the appropriate script, cleans up.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
|
||||||
|
TRIGGER_FILE="${UPGRADE_DIR}/trigger.json"
|
||||||
|
LOG_DIR="${PROJECT_DIR}/logs"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "${LOG_DIR}/upgrade-watcher.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bail if no trigger file
|
||||||
|
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
||||||
|
log "No trigger file found, exiting."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read trigger (minimal JSON parsing with grep/sed — no jq dependency)
|
||||||
|
TRIGGER_CONTENT="$(cat "$TRIGGER_FILE")"
|
||||||
|
ACTION="$(echo "$TRIGGER_CONTENT" | grep -o '"action"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"action"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//')"
|
||||||
|
|
||||||
|
if [[ -z "$ACTION" ]]; then
|
||||||
|
log "ERROR: Could not parse action from trigger file"
|
||||||
|
rm -f "$TRIGGER_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Received trigger: action=${ACTION}"
|
||||||
|
|
||||||
|
# Remove trigger immediately to prevent re-execution
|
||||||
|
rm -f "$TRIGGER_FILE"
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
check)
|
||||||
|
log "Running update check..."
|
||||||
|
# Extract optional branch
|
||||||
|
BRANCH="$(echo "$TRIGGER_CONTENT" | grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//' || true)"
|
||||||
|
ARGS=()
|
||||||
|
if [[ -n "$BRANCH" ]]; then
|
||||||
|
ARGS+=(--branch "$BRANCH")
|
||||||
|
fi
|
||||||
|
"$SCRIPT_DIR/upgrade-check.sh" "${ARGS[@]}" 2>&1 | tee -a "${LOG_DIR}/upgrade-watcher.log"
|
||||||
|
log "Update check complete."
|
||||||
|
;;
|
||||||
|
|
||||||
|
upgrade)
|
||||||
|
log "Running upgrade..."
|
||||||
|
# Parse options from trigger
|
||||||
|
ARGS=(--api-mode)
|
||||||
|
|
||||||
|
SKIP_BACKUP="$(echo "$TRIGGER_CONTENT" | grep -o '"skipBackup"[[:space:]]*:[[:space:]]*true' || true)"
|
||||||
|
if [[ -n "$SKIP_BACKUP" ]]; then
|
||||||
|
ARGS+=(--skip-backup --force)
|
||||||
|
fi
|
||||||
|
|
||||||
|
PULL_SERVICES="$(echo "$TRIGGER_CONTENT" | grep -o '"pullServices"[[:space:]]*:[[:space:]]*true' || true)"
|
||||||
|
if [[ -n "$PULL_SERVICES" ]]; then
|
||||||
|
ARGS+=(--pull-services)
|
||||||
|
fi
|
||||||
|
|
||||||
|
DRY_RUN="$(echo "$TRIGGER_CONTENT" | grep -o '"dryRun"[[:space:]]*:[[:space:]]*true' || true)"
|
||||||
|
if [[ -n "$DRY_RUN" ]]; then
|
||||||
|
ARGS+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
BRANCH="$(echo "$TRIGGER_CONTENT" | grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//' || true)"
|
||||||
|
if [[ -n "$BRANCH" ]]; then
|
||||||
|
ARGS+=(--branch "$BRANCH")
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/upgrade.sh" "${ARGS[@]}" 2>&1 | tee -a "${LOG_DIR}/upgrade-watcher.log"
|
||||||
|
log "Upgrade complete."
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
log "ERROR: Unknown action '${ACTION}'"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -46,6 +46,7 @@ DRY_RUN=false
|
|||||||
FORCE=false
|
FORCE=false
|
||||||
BRANCH=""
|
BRANCH=""
|
||||||
ROLLBACK=false
|
ROLLBACK=false
|
||||||
|
API_MODE=false
|
||||||
|
|
||||||
# --- Colors (respects NO_COLOR convention) ---
|
# --- Colors (respects NO_COLOR convention) ---
|
||||||
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
|
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
|
||||||
@ -73,6 +74,52 @@ phase() {
|
|||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- API mode: JSON progress/result writing ---
|
||||||
|
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
|
||||||
|
PROGRESS_FILE="${UPGRADE_DIR}/progress.json"
|
||||||
|
RESULT_FILE="${UPGRADE_DIR}/result.json"
|
||||||
|
|
||||||
|
write_progress() {
|
||||||
|
[[ "$API_MODE" != "true" ]] && return
|
||||||
|
local phase_num="$1" phase_name="$2" pct="$3" msg="$4"
|
||||||
|
mkdir -p "$UPGRADE_DIR"
|
||||||
|
cat > "$PROGRESS_FILE" <<PEOF
|
||||||
|
{
|
||||||
|
"phase": ${phase_num},
|
||||||
|
"phaseName": "${phase_name}",
|
||||||
|
"percentage": ${pct},
|
||||||
|
"message": "$(echo "$msg" | sed 's/"/\\"/g')",
|
||||||
|
"lastUpdate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
PEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_result() {
|
||||||
|
[[ "$API_MODE" != "true" ]] && return
|
||||||
|
local success="$1" msg="$2"
|
||||||
|
local duration_secs=$((SECONDS - START_TIME))
|
||||||
|
local warnings_json="${3:-[]}"
|
||||||
|
mkdir -p "$UPGRADE_DIR"
|
||||||
|
cat > "$RESULT_FILE" <<REOF
|
||||||
|
{
|
||||||
|
"success": ${success},
|
||||||
|
"message": "$(echo "$msg" | sed 's/"/\\"/g')",
|
||||||
|
"previousCommit": "${PRE_UPGRADE_SHORT:-unknown}",
|
||||||
|
"newCommit": "$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")",
|
||||||
|
"commitCount": ${COMMIT_COUNT:-0},
|
||||||
|
"durationSeconds": ${duration_secs},
|
||||||
|
"warnings": ${warnings_json},
|
||||||
|
"completedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
REOF
|
||||||
|
# Clean up progress file
|
||||||
|
rm -f "$PROGRESS_FILE"
|
||||||
|
# Update status.json with new commit info
|
||||||
|
if [[ -x "$SCRIPT_DIR/upgrade-check.sh" ]]; then
|
||||||
|
"$SCRIPT_DIR/upgrade-check.sh" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
elapsed() {
|
elapsed() {
|
||||||
local secs=$((SECONDS - START_TIME))
|
local secs=$((SECONDS - START_TIME))
|
||||||
printf '%dm %ds' $((secs / 60)) $((secs % 60))
|
printf '%dm %ds' $((secs / 60)) $((secs % 60))
|
||||||
@ -213,6 +260,7 @@ on_failure() {
|
|||||||
release_lock
|
release_lock
|
||||||
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
|
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
|
||||||
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
|
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
|
||||||
|
write_result "false" "Upgrade failed at line ${BASH_LINENO[0]} (exit code ${exit_code})"
|
||||||
print_rollback_help
|
print_rollback_help
|
||||||
info "Log file: $LOG_FILE"
|
info "Log file: $LOG_FILE"
|
||||||
fi
|
fi
|
||||||
@ -235,6 +283,7 @@ Options:
|
|||||||
--force Continue past non-critical warnings
|
--force Continue past non-critical warnings
|
||||||
--branch BRANCH Git branch to pull (default: current branch)
|
--branch BRANCH Git branch to pull (default: current branch)
|
||||||
--rollback Rollback to pre-upgrade commit
|
--rollback Rollback to pre-upgrade commit
|
||||||
|
--api-mode Write progress/result JSON for admin UI
|
||||||
--help Show this help message
|
--help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@ -254,6 +303,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--force) FORCE=true; shift ;;
|
--force) FORCE=true; shift ;;
|
||||||
--branch) BRANCH="$2"; shift 2 ;;
|
--branch) BRANCH="$2"; shift 2 ;;
|
||||||
--rollback) ROLLBACK=true; shift ;;
|
--rollback) ROLLBACK=true; shift ;;
|
||||||
|
--api-mode) API_MODE=true; shift ;;
|
||||||
--help|-h) show_help ;;
|
--help|-h) show_help ;;
|
||||||
*) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;;
|
*) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@ -350,6 +400,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "1" "Pre-flight Checks"
|
phase "1" "Pre-flight Checks"
|
||||||
|
write_progress 1 "Pre-flight Checks" 5 "Verifying system requirements..."
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
if command -v docker &>/dev/null; then
|
if command -v docker &>/dev/null; then
|
||||||
@ -454,6 +505,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "2" "Backup"
|
phase "2" "Backup"
|
||||||
|
write_progress 2 "Backup" 15 "Creating backup..."
|
||||||
|
|
||||||
if [[ "$SKIP_BACKUP" == "true" ]]; then
|
if [[ "$SKIP_BACKUP" == "true" ]]; then
|
||||||
warn "Backup skipped (--skip-backup --force)"
|
warn "Backup skipped (--skip-backup --force)"
|
||||||
@ -512,6 +564,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "3" "Code Update"
|
phase "3" "Code Update"
|
||||||
|
write_progress 3 "Code Update" 30 "Pulling latest code..."
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == "true" ]]; then
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
info "[DRY RUN] Would fetch and show incoming changes:"
|
info "[DRY RUN] Would fetch and show incoming changes:"
|
||||||
@ -640,6 +693,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "4" "Container Rebuild"
|
phase "4" "Container Rebuild"
|
||||||
|
write_progress 4 "Container Rebuild" 50 "Rebuilding containers..."
|
||||||
|
|
||||||
# Always rebuild source-built containers
|
# Always rebuild source-built containers
|
||||||
info "Rebuilding source containers: $SOURCE_CONTAINERS"
|
info "Rebuilding source containers: $SOURCE_CONTAINERS"
|
||||||
@ -683,6 +737,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "5" "Service Restart"
|
phase "5" "Service Restart"
|
||||||
|
write_progress 5 "Service Restart" 70 "Restarting services..."
|
||||||
|
|
||||||
# Stop application containers
|
# Stop application containers
|
||||||
info "Stopping application containers..."
|
info "Stopping application containers..."
|
||||||
@ -773,6 +828,7 @@ fi
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
phase "6" "Post-Upgrade Verification"
|
phase "6" "Post-Upgrade Verification"
|
||||||
|
write_progress 6 "Verification" 90 "Running health checks..."
|
||||||
|
|
||||||
VERIFY_FAILED=false
|
VERIFY_FAILED=false
|
||||||
|
|
||||||
@ -846,6 +902,15 @@ fi
|
|||||||
ELAPSED="$(elapsed)"
|
ELAPSED="$(elapsed)"
|
||||||
FINAL_COMMIT="$(git rev-parse --short HEAD)"
|
FINAL_COMMIT="$(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
# Collect warnings for API mode result
|
||||||
|
UPGRADE_WARNINGS="[]"
|
||||||
|
if [[ "$VERIFY_FAILED" == "true" ]]; then
|
||||||
|
UPGRADE_WARNINGS='["Some health checks failed after upgrade — services may still be starting"]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_progress 6 "Verification" 100 "Upgrade complete!"
|
||||||
|
write_result "true" "Upgraded ${PRE_UPGRADE_SHORT} → ${FINAL_COMMIT} (${COMMIT_COUNT} commits)" "$UPGRADE_WARNINGS"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════${NC}"
|
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════${NC}"
|
||||||
echo -e "${BOLD}${GREEN} Upgrade Complete${NC}"
|
echo -e "${BOLD}${GREEN} Upgrade Complete${NC}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user