diff --git a/admin/src/components/volunteer/dashboard/ActionStepsList.tsx b/admin/src/components/volunteer/dashboard/ActionStepsList.tsx
index 98731738..6a333ade 100644
--- a/admin/src/components/volunteer/dashboard/ActionStepsList.tsx
+++ b/admin/src/components/volunteer/dashboard/ActionStepsList.tsx
@@ -10,6 +10,8 @@ import {
LinkOutlined,
CheckSquareOutlined,
CheckCircleFilled,
+ RightOutlined,
+ ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
@@ -66,6 +68,97 @@ function resolveStepLink(step: DashboardActionStep): { to: string; external: boo
}
}
+function HighlightedStep({
+ step,
+ onNavigate,
+ onSelfReport,
+ loading,
+}: {
+ step: DashboardActionStep;
+ onNavigate: (step: DashboardActionStep) => void;
+ onSelfReport: (step: DashboardActionStep) => void;
+ loading: boolean;
+}) {
+ const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
+ const canNavigate = resolveStepLink(step) !== null;
+
+ return (
+
+
+
+
+ Next Up
+
+
+
+
+ {KIND_ICONS[step.kind]}
+
+
+
+ {step.label}
+
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+
+
+ {isSelfReport ? (
+ <>
+ {canNavigate && (
+
+ )}
+
+ >
+ ) : (
+ }
+ onClick={() => onNavigate(step)}
+ disabled={!canNavigate}
+ >
+ Take Action
+
+ )}
+
+
+ );
+}
+
export default function ActionStepsList({ campaign, onRefresh }: ActionStepsListProps) {
const navigate = useNavigate();
const { message } = App.useApp();
@@ -95,6 +188,8 @@ export default function ActionStepsList({ campaign, onRefresh }: ActionStepsList
};
const sortedSteps = [...campaign.steps].sort((a, b) => a.order - b.order);
+ const highlightedStep = sortedSteps.find((s) => !s.completed);
+ const remainingSteps = sortedSteps.filter((s) => s.id !== highlightedStep?.id);
return (
}
>
- {sortedSteps.map((step, i) => {
+ {highlightedStep && (
+
+
+
+ )}
+
+ {remainingSteps.map((step, i) => {
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
const canNavigate = resolveStepLink(step) !== null;
@@ -119,8 +225,8 @@ export default function ActionStepsList({ campaign, onRefresh }: ActionStepsList
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
- padding: '12px 20px',
- borderTop: i > 0 ? '1px solid rgba(255,255,255,0.04)' : undefined,
+ padding: '10px 20px',
+ borderTop: (highlightedStep || i > 0) ? '1px solid rgba(255,255,255,0.04)' : undefined,
opacity: step.completed ? 0.55 : 1,
gap: 12,
}}
@@ -128,22 +234,22 @@ export default function ActionStepsList({ campaign, onRefresh }: ActionStepsList
{step.completed ? : KIND_ICONS[step.kind]}
-
+
{KIND_LABELS[step.kind]}
{step.completed ? (
- Done
+ Done
) : isSelfReport ? (
{canNavigate && (
diff --git a/admin/src/pages/events/TicketedEventsPage.tsx b/admin/src/pages/events/TicketedEventsPage.tsx
index 0fc69c15..74373630 100644
--- a/admin/src/pages/events/TicketedEventsPage.tsx
+++ b/admin/src/pages/events/TicketedEventsPage.tsx
@@ -7,7 +7,7 @@ import {
import {
PlusOutlined, SearchOutlined, EditOutlined, EyeOutlined, DeleteOutlined,
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, ScanOutlined,
- TagOutlined, VideoCameraOutlined, EnvironmentOutlined,
+ TagOutlined, VideoCameraOutlined, EnvironmentOutlined, StarOutlined, StarFilled,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import dayjs from 'dayjs';
@@ -45,6 +45,7 @@ interface TicketedEvent {
currentAttendees: number;
coverImageUrl: string | null;
organizerName: string | null;
+ featured: boolean;
ticketTiers: TicketTier[];
_count: { tickets: number; checkIns: number };
createdAt: string;
@@ -198,18 +199,55 @@ export default function TicketedEventsPage() {
}
};
+ const handleFeature = async (id: string, featured: boolean) => {
+ try {
+ if (featured) {
+ // Unfeature all others first (exclusive toggle)
+ const othersToUnfeature = events.filter((e) => e.featured && e.id !== id);
+ await Promise.all(
+ othersToUnfeature.map((e) => api.put(`/api/ticketed-events/admin/${e.id}`, { featured: false }))
+ );
+ }
+ await api.put(`/api/ticketed-events/admin/${id}`, { featured });
+ message.success(featured ? 'Event featured on volunteer dashboard' : 'Event unfeatured');
+ fetchEvents();
+ } catch {
+ message.error('Failed to update featured status');
+ }
+ };
+
const copyLink = (slug: string) => {
navigator.clipboard.writeText(`${window.location.origin}/event/${slug}`);
message.success('Link copied');
};
const columns = [
+ {
+ title: '',
+ key: 'featured',
+ width: 36,
+ render: (_: unknown, record: TicketedEvent) => (
+
+
+ : }
+ onClick={(e) => { e.stopPropagation(); handleFeature(record.id, !record.featured); }}
+ />
+
+ ),
+ },
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (text: string, record: TicketedEvent) => (
- navigate(`/app/events/${record.id}`)}>{text}
+
+ navigate(`/app/events/${record.id}`)}>{text}
+ {record.featured && Featured}
+
),
},
{
diff --git a/api/src/modules/ticketed-events/ticketed-events.schemas.ts b/api/src/modules/ticketed-events/ticketed-events.schemas.ts
index 5bf1f0ad..50ddff2a 100644
--- a/api/src/modules/ticketed-events/ticketed-events.schemas.ts
+++ b/api/src/modules/ticketed-events/ticketed-events.schemas.ts
@@ -50,6 +50,7 @@ export const updateEventSchema = z.object({
maxAttendees: z.number().int().positive().nullable().optional(),
organizerName: z.string().max(200).nullable().optional(),
organizerEmail: z.string().email().nullable().optional(),
+ featured: z.boolean().optional(),
});
export const createTierSchema = z.object({
diff --git a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
index e1911f93..36ee19d7 100644
--- a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
+++ b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
@@ -114,24 +114,31 @@ async function getReferral(userId: string): Promise {
async function getFeaturedEvent(): Promise {
const today = new Date();
today.setHours(0, 0, 0, 0);
- const event = await prisma.ticketedEvent.findFirst({
- where: {
- featured: true,
- status: TicketedEventStatus.PUBLISHED,
- date: { gte: today },
- },
- orderBy: { date: 'asc' },
- select: {
- slug: true,
- title: true,
- date: true,
- startTime: true,
- venueName: true,
- coverImageUrl: true,
- currentAttendees: true,
- maxAttendees: true,
- },
- });
+ const eventSelect = {
+ slug: true,
+ title: true,
+ date: true,
+ startTime: true,
+ venueName: true,
+ coverImageUrl: true,
+ currentAttendees: true,
+ maxAttendees: true,
+ } as const;
+ const baseWhere = { status: TicketedEventStatus.PUBLISHED, date: { gte: today } };
+
+ // Prefer admin-featured event; fall back to next upcoming published event
+ const event =
+ await prisma.ticketedEvent.findFirst({
+ where: { ...baseWhere, featured: true },
+ orderBy: { date: 'asc' },
+ select: eventSelect,
+ }) ??
+ await prisma.ticketedEvent.findFirst({
+ where: baseWhere,
+ orderBy: { date: 'asc' },
+ select: eventSelect,
+ });
+
if (!event) return null;
return {
slug: event.slug,
diff --git a/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx b/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx
index 05eef6b8..6bd1f660 100644
--- a/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx
@@ -14,7 +14,7 @@ export default function AgentRegistrationsPage() {
const fetchRegistrations = useCallback(async () => {
try {
setLoading(true);
- const { data } = await api.get('/api/agents/registrations');
+ const { data } = await api.get('/agents/registrations');
setRegistrations(data);
} catch {
message.error('Failed to load registrations');
@@ -27,7 +27,7 @@ export default function AgentRegistrationsPage() {
const handleApprove = async (id: string) => {
try {
- await api.post(`/api/agents/registrations/${id}/approve`);
+ await api.post(`/agents/registrations/${id}/approve`);
message.success('Registration approved — agent will receive certificates on next poll');
fetchRegistrations();
setDetailModal(null);
@@ -39,7 +39,7 @@ export default function AgentRegistrationsPage() {
const handleReject = async (id: string) => {
try {
- await api.post(`/api/agents/registrations/${id}/reject`);
+ await api.post(`/agents/registrations/${id}/reject`);
message.success('Registration rejected');
fetchRegistrations();
setDetailModal(null);
diff --git a/changemaker-control-panel/admin/src/pages/BackupsPage.tsx b/changemaker-control-panel/admin/src/pages/BackupsPage.tsx
index 9c8a3b6d..60493142 100644
--- a/changemaker-control-panel/admin/src/pages/BackupsPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/BackupsPage.tsx
@@ -203,8 +203,16 @@ export default function BackupsPage() {
{
title: 'Instance',
dataIndex: 'instance',
- width: 160,
- render: (inst: BackupRow['instance']) => inst?.name || '-',
+ width: 180,
+ render: (inst: BackupRow['instance'], record: BackupRow) => {
+ const isRemote = record.manifest?.source === 'remote';
+ return (
+
+ {inst?.name || '-'}
+ {isRemote && remote}
+
+ );
+ },
},
{
title: 'Status',
diff --git a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
index 1269ac50..ba0310b4 100644
--- a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
@@ -44,6 +44,7 @@ import {
WarningOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
+ UndoOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate, useParams } from 'react-router-dom';
@@ -89,6 +90,16 @@ export default function InstanceDetailPage() {
const [backupsLoading, setBackupsLoading] = useState(false);
const [creatingBackup, setCreatingBackup] = useState(false);
+ // Restore state
+ const [restoreModal, setRestoreModal] = useState<{ backup: Backup; typedSlug: string } | null>(null);
+ const [restoring, setRestoring] = useState(false);
+ const [activeRestoreId, setActiveRestoreId] = useState(null);
+ const [activeRestoreState, setActiveRestoreState] = useState<{
+ status: string;
+ logTail?: string | null;
+ errorMessage?: string | null;
+ } | null>(null);
+
// Feature reconfiguration state
const [featureFlags, setFeatureFlags] = useState>({});
const [reconfiguring, setReconfiguring] = useState(false);
@@ -109,6 +120,18 @@ export default function InstanceDetailPage() {
const [tunnelSaving, setTunnelSaving] = useState(false);
const [tunnelRemoving, setTunnelRemoving] = useState(false);
+ // Remote tunnel state (Pangolin API managed by CCP)
+ const [tunnelStatus, setTunnelStatus] = useState<{
+ configured: boolean;
+ online?: boolean;
+ siteId?: string;
+ endpoint?: string;
+ resources?: Array<{ subdomain: string; name: string; resourceId: string; hasTarget: boolean; targetIp?: string; targetPort?: number }>;
+ } | null>(null);
+ const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
+ const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
+ const [tunnelSyncing, setTunnelSyncing] = useState(false);
+
// Upgrade state
const [updateStatus, setUpdateStatus] = useState(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
@@ -390,6 +413,64 @@ export default function InstanceDetailPage() {
window.open(`/api/backups/${backupId}/download`, '_blank');
};
+ const handleRestoreConfirm = async () => {
+ if (!restoreModal) return;
+ if (restoreModal.typedSlug !== instance?.slug) {
+ message.error('Typed slug does not match — restore cancelled');
+ return;
+ }
+ setRestoring(true);
+ try {
+ const { data } = await api.post(`/instances/${id}/restore`, {
+ backupId: restoreModal.backup.id,
+ });
+ const restoreId = data.data.id as string;
+ setActiveRestoreId(restoreId);
+ setActiveRestoreState({ status: 'PENDING' });
+ setRestoreModal(null);
+ message.success('Restore started — polling for progress');
+ } catch (err: unknown) {
+ const e = err as { response?: { data?: { error?: { message?: string } } } };
+ message.error(e?.response?.data?.error?.message || 'Failed to start restore');
+ } finally {
+ setRestoring(false);
+ }
+ };
+
+ // Poll the active restore's status every 3s until it completes or fails
+ useEffect(() => {
+ if (!activeRestoreId) return;
+ let cancelled = false;
+ const poll = async () => {
+ try {
+ const { data } = await api.get(`/instances/${id}/restores/${activeRestoreId}`);
+ if (cancelled) return;
+ const row = data.data;
+ setActiveRestoreState({
+ status: row.status,
+ logTail: row.logTail,
+ errorMessage: row.errorMessage,
+ });
+ if (row.status === 'COMPLETED') {
+ message.success('Restore completed successfully');
+ setActiveRestoreId(null);
+ fetchBackups();
+ } else if (row.status === 'FAILED') {
+ message.error(`Restore failed: ${row.errorMessage || 'unknown error'}`);
+ setActiveRestoreId(null);
+ }
+ } catch {
+ // keep trying; transient errors are expected during remote restart
+ }
+ };
+ poll();
+ const handle = setInterval(poll, 3000);
+ return () => {
+ cancelled = true;
+ clearInterval(handle);
+ };
+ }, [activeRestoreId, id, fetchBackups]);
+
// Initialize feature flags and tunnel form when instance loads
useEffect(() => {
if (instance) {
@@ -508,6 +589,11 @@ export default function InstanceDetailPage() {
const ports = instance.portConfig as Record;
const isProvisioning = instance.status === 'PROVISIONING';
const isRegistered = instance.isRegistered;
+ const isRemote = instance.isRemote;
+ // A "managed" instance is one CCP can run backup/restore/upgrade on.
+ // Local CCP-managed and remote (agent-backed) both qualify; only locally-
+ // adopted registered instances (isRegistered && !isRemote) are unmanaged.
+ const isManaged = !isRegistered || isRemote;
const canStart = instance.status === 'STOPPED' || instance.status === 'ERROR';
const canStop = instance.status === 'RUNNING' || instance.status === 'ERROR';
const canRestart = instance.status === 'RUNNING';
@@ -731,7 +817,7 @@ export default function InstanceDetailPage() {
const backupsTab = (
- {isRegistered && (
+ {!isManaged && (
)}
+ {isRemote && (
+
+ )}
{backups.length} backup{backups.length !== 1 ? 's' : ''}
@@ -749,7 +844,7 @@ export default function InstanceDetailPage() {
type="primary"
onClick={handleCreateBackup}
loading={creatingBackup}
- disabled={instance.status !== 'RUNNING' || isRegistered}
+ disabled={instance.status !== 'RUNNING' || !isManaged}
>
Create Backup
@@ -784,20 +879,36 @@ export default function InstanceDetailPage() {
{
title: 'Size',
dataIndex: 'sizeBytes',
- render: (b: number | null) => (b ? `${(b / 1024 / 1024).toFixed(1)} MB` : '-'),
+ render: (b: number | string | null) => {
+ if (b == null) return '-';
+ const n = typeof b === 'string' ? parseInt(b, 10) : b;
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
+ },
},
{
title: 'Actions',
- width: 120,
+ width: 160,
render: (_: unknown, record: Backup) => (
{record.status === 'COMPLETED' && (
- }
- size="small"
- type="text"
- onClick={() => handleDownloadBackup(record.id)}
- />
+ <>
+ }
+ size="small"
+ type="text"
+ title="Download archive"
+ onClick={() => handleDownloadBackup(record.id)}
+ />
+ {isManaged && (
+ }
+ size="small"
+ type="text"
+ title="Restore this backup (destructive)"
+ onClick={() => setRestoreModal({ backup: record, typedSlug: '' })}
+ />
+ )}
+ >
)}
{
+ if (!isRemote) return;
+ setTunnelStatusLoading(true);
+ try {
+ const { data } = await api.get(`/instances/${id}/tunnel/status`);
+ setTunnelStatus(data.data);
+ } catch {
+ setTunnelStatus(null);
+ } finally {
+ setTunnelStatusLoading(false);
+ }
+ }, [id, isRemote]);
+
+ useEffect(() => {
+ if (activeTab === 'tunnel' && isRemote) {
+ fetchTunnelStatus();
+ }
+ }, [activeTab, isRemote, fetchTunnelStatus]);
+
+ const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
+ setTunnelSetupRunning(true);
+ try {
+ await api.post(`/instances/${id}/tunnel/setup`, {
+ subdomainPrefix: values.subdomainPrefix || instance.slug,
+ });
+ message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
+ fetchInstance();
+ fetchTunnelStatus();
+ } catch (err: unknown) {
+ const e = err as { response?: { data?: { error?: { message?: string } } } };
+ message.error(e?.response?.data?.error?.message || 'Tunnel setup failed');
+ } finally {
+ setTunnelSetupRunning(false);
+ }
+ };
+
+ const handleTunnelSync = async () => {
+ setTunnelSyncing(true);
+ try {
+ const { data } = await api.post(`/instances/${id}/tunnel/sync`);
+ message.success(`Sync complete — ${data.data.created} new resource(s) created`);
+ fetchTunnelStatus();
+ } catch (err: unknown) {
+ const e = err as { response?: { data?: { error?: { message?: string } } } };
+ message.error(e?.response?.data?.error?.message || 'Sync failed');
+ } finally {
+ setTunnelSyncing(false);
+ }
+ };
+
+ const handleRemoteTunnelTeardown = async () => {
+ setTunnelRemoving(true);
+ try {
+ await api.delete(`/instances/${id}/tunnel`);
+ message.success('Tunnel torn down — Pangolin site deleted');
+ fetchInstance();
+ setTunnelStatus(null);
+ } catch (err: unknown) {
+ const e = err as { response?: { data?: { error?: { message?: string } } } };
+ message.error(e?.response?.data?.error?.message || 'Teardown failed');
+ } finally {
+ setTunnelRemoving(false);
+ }
+ };
const handleConfigureTunnel = async (values: { pangolinEndpoint: string; pangolinNewtId: string; pangolinNewtSecret?: string }) => {
setTunnelSaving(true);
@@ -1088,9 +1265,111 @@ export default function InstanceDetailPage() {
}
};
- const tunnelTab = (
+ const remoteTunnelTab = (
- {isRegistered && (
+ {tunnelStatus?.configured ? (
+ <>
+ }
+ />
+
+
+
+
+ {tunnelStatus.endpoint || instance.pangolinEndpoint}
+
+
+ {tunnelStatus.siteId || instance.pangolinSiteId}
+
+
+ {instance.pangolinNewtId}
+
+
+ {tunnelStatus.online ? 'Online' : 'Offline'}
+
+
+
+
+ {tunnelStatus.resources && tunnelStatus.resources.length > 0 && (
+
+ } size="small" onClick={handleTunnelSync} loading={tunnelSyncing}>
+ Sync
+
+ } size="small" onClick={fetchTunnelStatus} loading={tunnelStatusLoading}>
+ Refresh
+
+
+ }
+ >
+ s || '(root)' },
+ { title: 'Name', dataIndex: 'name' },
+ { title: 'Target', render: (_: unknown, r: { hasTarget: boolean; targetIp?: string; targetPort?: number }) =>
+ r.hasTarget ? `${r.targetIp}:${r.targetPort}` : No target
+ },
+ ]}
+ />
+
+ )}
+
+
+ } loading={tunnelRemoving}>
+ Teardown Tunnel
+
+
+ >
+ ) : (
+ <>
+
+
+
+ -app.${instance.domain}, -api.${instance.domain}, etc.`}
+ rules={[{ required: true }, { pattern: /^[a-z0-9-]+$/, message: 'Lowercase alphanumeric + hyphens only' }]}
+ >
+
+
+
+ } loading={tunnelSetupRunning}>
+ Setup Tunnel
+
+
+
+
+ >
+ )}
+
+ );
+
+ const localTunnelTab = (
+
+ {!isManaged && (
)}
- {!isRegistered && tunnelConfigured && (
+ {isManaged && tunnelConfigured && (
)}
- {!isRegistered && !tunnelConfigured && (
+ {isManaged && !tunnelConfigured && (
)}
- {canConfigureTunnel && (
+ {canConfigureTunnel && !isRemote && (