// @ts-nocheck
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../lib/api';
import { navigate } from '../lib/navigation';
import { copyToClipboard, formatDate, formatISO8601, formatRelativeTimeRounded, formatTimeRemaining } from '../lib/utils';
import { useToast } from '../components/toast';
import { Button, ConfirmDialog, Modal, ModalActions, ModalSection, StatusBadge } from '../components/ui';
import { MonoSortButton, MonoTable, MonoTableBody, MonoTableEmptyRow, MonoTableFrame, MonoTableHead, MonoTableRow, MonoTd, MonoTh } from '../components/table';
import { EnvVarsList } from './resources';
import { EmptyState, ErrorState, LoadingState } from '../components/states';
import { useRowKeyboardNavigation, useSortableData } from '../lib/table';
const STATUS_TONES = {
Healthy: 'ok',
Running: 'ok',
Deploying: 'warn',
Pending: 'warn',
Building: 'warn',
Pushing: 'warn',
Pushed: 'warn',
Unhealthy: 'bad',
Failed: 'bad',
Stopped: 'muted',
Cancelled: 'muted',
Superseded: 'muted',
Expired: 'muted',
Terminating: 'muted',
};
function getStatusTone(status) {
return STATUS_TONES[status] || 'muted';
}
export function ActiveDeploymentsSummary({ projectName }) {
const [activeDeployments, setActiveDeployments] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [deploymentToStop, setDeploymentToStop] = useState(null);
const [stopping, setStopping] = useState(false);
const { showToast } = useToast();
const isTerminal = (status) => {
return ['Cancelled', 'Stopped', 'Superseded', 'Failed', 'Expired'].includes(status);
};
const loadSummary = useCallback(async () => {
try {
const deployments = await api.getProjectDeployments(projectName, { limit: 100 });
// Group deployments by deployment group
const grouped = deployments.reduce((acc, d) => {
const group = d.deployment_group || 'default';
if (!acc[group]) {
acc[group] = {
active: null,
progressing: []
};
}
// Track the active deployment (is_active === true)
if (d.is_active) {
acc[group].active = d;
}
// Track progressing (non-terminal) deployments
if (!isTerminal(d.status)) {
acc[group].progressing.push(d);
}
return acc;
}, {});
// Filter to only include groups that have an active deployment or progressing deployments
const filtered = {};
Object.keys(grouped).forEach(group => {
const groupData = grouped[group];
// Always include default group if it has an active deployment
// Include other groups if they have active OR progressing deployments
if (groupData.active || (group !== 'default' && groupData.progressing.length > 0)) {
filtered[group] = groupData;
}
});
setActiveDeployments(filtered);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}, [projectName]);
useEffect(() => {
loadSummary();
const interval = setInterval(loadSummary, 5000);
return () => clearInterval(interval);
}, [loadSummary]);
if (loading) return <LoadingState label="Loading active deployments..." />;
if (error) return <ErrorState message={`Error loading active deployments: ${error}`} onRetry={loadSummary} />;
const handleStopClick = (deployment) => {
setDeploymentToStop(deployment);
setConfirmDialogOpen(true);
};
const handleStopConfirm = async () => {
if (!deploymentToStop) return;
setStopping(true);
try {
await api.stopDeployment(projectName, deploymentToStop.deployment_id);
showToast(`Deployment ${deploymentToStop.deployment_id} stopped successfully`, 'success');
setConfirmDialogOpen(false);
setDeploymentToStop(null);
loadSummary(); // Refresh the list
} catch (err) {
showToast(`Failed to stop deployment: ${err.message}`, 'error');
} finally {
setStopping(false);
}
};
const groups = Object.keys(activeDeployments);
if (groups.length === 0) return <EmptyState message="No active deployments." />;
// Sort groups: "default" first, then by active deployment's created timestamp
const sortedGroups = groups.sort((a, b) => {
if (a === 'default') return -1;
if (b === 'default') return 1;
// Both non-default: sort by active deployment's created timestamp (descending)
const activeA = activeDeployments[a].active;
const activeB = activeDeployments[b].active;
// If both have active deployments, sort by created timestamp
if (activeA && activeB) {
return new Date(activeB.created) - new Date(activeA.created);
}
// Groups with active deployments come first
if (activeA && !activeB) return -1;
if (!activeA && activeB) return 1;
return 0;
});
return (
<>
<div className="mono-active-deployments-grid grid gap-4 md:grid-cols-2">
{sortedGroups.map(group => {
const groupData = activeDeployments[group];
const deployment = groupData.active;
// Skip if no active deployment (shouldn't happen due to filtering, but be safe)
if (!deployment) {
return null;
}
const canStop = !isTerminal(deployment.status);
// Count other progressing deployments (exclude the active one)
const otherProgressing = groupData.progressing.filter(d => d.deployment_id !== deployment.deployment_id).length;
return (
<div
key={group}
className={`mono-active-deployment-card mono-status-card mono-status-card-${getStatusTone(deployment.status)} border border-gray-200 dark:border-gray-800 p-6`}
onClick={() => navigate(`/deployment/${projectName}/${deployment.deployment_id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate(`/deployment/${projectName}/${deployment.deployment_id}`);
}
}}
role="link"
tabIndex={0}
aria-label={`View deployment ${deployment.deployment_id}`}
>
<div className="flex justify-between items-center mb-4">
<h5 className="text-lg font-semibold">{group}</h5>
<div className="flex items-center gap-3">
<StatusBadge status={deployment.status} />
{canStop && (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopClick(deployment);
}}
>
Stop
</Button>
)}
</div>
</div>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-gray-600 dark:text-gray-400">Deployment ID</dt>
<dd className="font-mono text-gray-900 dark:text-gray-200">{deployment.deployment_id}</dd>
</div>
<div>
<dt className="text-gray-600 dark:text-gray-400">Image</dt>
<dd className="font-mono text-gray-900 dark:text-gray-200 text-xs">{deployment.image ? deployment.image.split('/').pop() : '-'}</dd>
</div>
<div>
<dt className="text-gray-600 dark:text-gray-400">URL</dt>
<dd>{deployment.primary_url ? <a href={deployment.primary_url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300">{deployment.primary_url}</a> : '-'}</dd>
</div>
<div>
<dt className="text-gray-600 dark:text-gray-400">Created</dt>
<dd className="text-gray-900 dark:text-gray-200" title={formatISO8601(deployment.created)}>
{formatRelativeTimeRounded(deployment.created)}
</dd>
</div>
{deployment.expires_at && (
<div>
<dt className="text-gray-600 dark:text-gray-400">Expires</dt>
<dd className="text-gray-900 dark:text-gray-200">
{formatTimeRemaining(deployment.expires_at)}
<span className="text-gray-600 dark:text-gray-500 text-xs ml-2">({formatDate(deployment.expires_at)})</span>
</dd>
</div>
)}
</dl>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800 flex items-center justify-end">
{otherProgressing > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-500">
+{otherProgressing} other{otherProgressing === 1 ? '' : 's'} progressing
</span>
)}
</div>
</div>
);
})}
</div>
<ConfirmDialog
isOpen={confirmDialogOpen}
onClose={() => {
setConfirmDialogOpen(false);
setDeploymentToStop(null);
}}
onConfirm={handleStopConfirm}
title="Stop Deployment"
message={`Are you sure you want to stop deployment ${deploymentToStop?.deployment_id}? Impact: traffic for group "${deploymentToStop?.deployment_group || 'default'}" may terminate.`}
confirmText="Stop Deployment"
variant="danger"
loading={stopping}
/>
</>
);
}
// Deployments List Component (with pagination)
export function DeploymentsList({ projectName }) {
const [deployments, setDeployments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [groupFilter, setGroupFilter] = useState('');
const [deploymentGroups, setDeploymentGroups] = useState([]);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [deploymentToStop, setDeploymentToStop] = useState(null);
const [stopping, setStopping] = useState(false);
const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false);
const [deploymentToRollback, setDeploymentToRollback] = useState(null);
const [rollingBack, setRollingBack] = useState(false);
const [actionStatus, setActionStatus] = useState('');
const { showToast } = useToast();
const pageSize = 10;
const { sortedItems: sortedDeployments, sortKey, sortDirection, requestSort } = useSortableData(deployments, 'created', 'desc');
const { activeIndex, setActiveIndex, onKeyDown } = useRowKeyboardNavigation(
(idx) => {
const deployment = sortedDeployments[idx];
if (deployment) navigate(`/deployment/${projectName}/${deployment.deployment_id}`);
},
sortedDeployments.length
);
// Load deployment groups
useEffect(() => {
async function loadGroups() {
try {
const groups = await api.getDeploymentGroups(projectName);
setDeploymentGroups(groups);
} catch (err) {
console.error('Failed to load deployment groups:', err);
}
}
loadGroups();
}, [projectName]);
const loadDeployments = useCallback(async () => {
try {
const params = {
limit: pageSize,
offset: page * pageSize,
};
if (groupFilter) params.group = groupFilter;
const data = await api.getProjectDeployments(projectName, params);
setDeployments(data);
setHasMore(data.length >= pageSize);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}, [projectName, page, groupFilter]);
useEffect(() => {
loadDeployments();
const interval = setInterval(loadDeployments, 5000);
return () => clearInterval(interval);
}, [loadDeployments]);
const handleGroupChange = (e) => {
setGroupFilter(e.target.value);
setPage(0);
};
const isTerminal = (status) => {
return ['Cancelled', 'Stopped', 'Superseded', 'Failed', 'Expired'].includes(status);
};
const isRollbackable = (deployment) => {
return Boolean(deployment?.can_rollback);
};
const handleStopClick = (deployment) => {
setDeploymentToStop(deployment);
setConfirmDialogOpen(true);
};
const handleStopConfirm = async () => {
if (!deploymentToStop) return;
setStopping(true);
setActionStatus(`Stopping deployment ${deploymentToStop.deployment_id}...`);
try {
await api.stopDeployment(projectName, deploymentToStop.deployment_id);
showToast(`Deployment ${deploymentToStop.deployment_id} stopped successfully`, 'success');
setActionStatus(`Stopped deployment ${deploymentToStop.deployment_id}.`);
setConfirmDialogOpen(false);
setDeploymentToStop(null);
loadDeployments();
} catch (err) {
showToast(`Failed to stop deployment: ${err.message}`, 'error');
setActionStatus(`Failed to stop deployment ${deploymentToStop.deployment_id}.`);
} finally {
setStopping(false);
}
};
const handleRollbackClick = (deployment) => {
setDeploymentToRollback(deployment);
setRollbackDialogOpen(true);
};
const [useSourceEnvVars, setUseSourceEnvVars] = useState(false);
const handleRollbackConfirm = async () => {
if (!deploymentToRollback) return;
setRollingBack(true);
setActionStatus(`${deploymentToRollback.is_active ? 'Redeploying' : 'Rolling back'} deployment ${deploymentToRollback.deployment_id}...`);
try {
const response = await api.createDeploymentFrom(projectName, deploymentToRollback.deployment_id, useSourceEnvVars);
showToast(`${deploymentToRollback.is_active ? 'Redeploy' : 'Rollback'} successful! New deployment: ${response.deployment_id}`, 'success');
setActionStatus(`${deploymentToRollback.is_active ? 'Redeployed' : 'Rolled back'} to new deployment ${response.deployment_id}.`);
setRollbackDialogOpen(false);
setDeploymentToRollback(null);
setUseSourceEnvVars(false); // Reset checkbox
loadDeployments();
} catch (err) {
showToast(`Failed to ${deploymentToRollback.is_active ? 'redeploy' : 'rollback'} deployment: ${err.message}`, 'error');
setActionStatus(`Failed to ${deploymentToRollback.is_active ? 'redeploy' : 'rollback'} deployment ${deploymentToRollback.deployment_id}.`);
} finally {
setRollingBack(false);
}
};
if (loading && deployments.length === 0) return <LoadingState label="Loading deployments..." />;
if (error) return <ErrorState message={`Error loading deployments: ${error}`} onRetry={loadDeployments} />;
// Find the most recent deployment in the default group (only non-terminal)
const mostRecentDefault = sortedDeployments.find(d => d.deployment_group === 'default' && !isTerminal(d.status));
return (
<div>
<div className="mb-4 flex items-center gap-2">
<label htmlFor="deployment-group-filter" className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">Filter by group:</span>
<select
id="deployment-group-filter"
value={groupFilter}
onChange={handleGroupChange}
className="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
<option value="">All groups</option>
{deploymentGroups.map(group => (
<option key={group} value={group}>{group}</option>
))}
</select>
</label>
</div>
{actionStatus && <p className="mono-inline-status mb-3">{actionStatus}</p>}
<MonoTableFrame>
<MonoTable className="mono-sticky-table mono-table--sticky" onKeyDown={onKeyDown}>
<MonoTableHead>
<tr>
<MonoTh stickyCol className="px-6 py-3 text-left">
<MonoSortButton label="ID" active={sortKey === 'deployment_id'} direction={sortDirection} onClick={() => requestSort('deployment_id')} />
</MonoTh>
<MonoTh className="px-6 py-3 text-left">
<MonoSortButton label="Status" active={sortKey === 'status'} direction={sortDirection} onClick={() => requestSort('status')} />
</MonoTh>
<MonoTh className="px-6 py-3 text-left">Created by</MonoTh>
<MonoTh className="px-6 py-3 text-left">Image</MonoTh>
<MonoTh className="px-6 py-3 text-left">Group</MonoTh>
<MonoTh className="px-6 py-3 text-left">URL</MonoTh>
<MonoTh className="px-6 py-3 text-left">Expires</MonoTh>
<MonoTh className="px-6 py-3 text-left">
<MonoSortButton label="Created" active={sortKey === 'created'} direction={sortDirection} onClick={() => requestSort('created')} />
</MonoTh>
<MonoTh className="px-6 py-3 text-left">Actions</MonoTh>
</tr>
</MonoTableHead>
<MonoTableBody>
{sortedDeployments.length === 0 ? (
<MonoTableEmptyRow colSpan={9}>
<EmptyState message="No deployments found." />
</MonoTableEmptyRow>
) : (
sortedDeployments.map((d, idx) => {
const isHighlighted = mostRecentDefault && d.id === mostRecentDefault.id;
return (
<MonoTableRow
key={d.id}
onClick={() => navigate(`/deployment/${projectName}/${d.deployment_id}`)}
onFocus={() => setActiveIndex(idx)}
tabIndex={0}
aria-label={`Deployment ${d.deployment_id}`}
interactive
active={activeIndex === idx}
highlight={Boolean(isHighlighted)}
className="transition-colors"
>
<MonoTd stickyCol mono className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-200">{d.deployment_id}</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm"><StatusBadge status={d.status} /></MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{d.created_by_email || '-'}</MonoTd>
<MonoTd mono className="px-6 py-4 whitespace-nowrap text-xs text-gray-700 dark:text-gray-300">{d.image ? d.image.split('/').pop() : '-'}</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{d.deployment_group}</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm">
{d.primary_url ? (
<a
href={d.primary_url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300"
onClick={(e) => e.stopPropagation()}
>
Link
</a>
) : '-'}
</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{d.expires_at ? (
<span>
{formatTimeRemaining(d.expires_at)}
<br />
<span className="text-gray-600 dark:text-gray-500 text-xs">({formatDate(d.expires_at)})</span>
</span>
) : '-'}
</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300" title={formatISO8601(d.created)}>
{formatRelativeTimeRounded(d.created)}
</MonoTd>
<MonoTd className="px-6 py-4 whitespace-nowrap text-sm">
<div className="mono-table-action-slot">
{isRollbackable(d) && (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRollbackClick(d);
}}
>
{d.is_active ? 'Redeploy' : 'Rollback'}
</Button>
)}
{!isTerminal(d.status) && (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopClick(d);
}}
>
Stop
</Button>
)}
</div>
</MonoTd>
</MonoTableRow>
);
})
)}
</MonoTableBody>
</MonoTable>
</MonoTableFrame>
<div className="mt-4 flex justify-between items-center">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
className="bg-gray-700 hover:bg-gray-600 disabled:bg-gray-100 dark:bg-gray-800 disabled:text-gray-600 text-white px-4 py-2 rounded text-sm transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page {page + 1} (showing {deployments.length} deployments)
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!hasMore}
className="bg-gray-700 hover:bg-gray-600 disabled:bg-gray-100 dark:bg-gray-800 disabled:text-gray-600 text-white px-4 py-2 rounded text-sm transition-colors"
>
Next
</button>
</div>
<ConfirmDialog
isOpen={confirmDialogOpen}
onClose={() => {
setConfirmDialogOpen(false);
setDeploymentToStop(null);
}}
onConfirm={handleStopConfirm}
title="Stop Deployment"
message={`Are you sure you want to stop deployment ${deploymentToStop?.deployment_id}? Impact: traffic for group "${deploymentToStop?.deployment_group || 'default'}" may terminate.`}
confirmText="Stop Deployment"
variant="danger"
loading={stopping}
/>
<Modal
isOpen={rollbackDialogOpen}
onClose={() => {
setRollbackDialogOpen(false);
setDeploymentToRollback(null);
setUseSourceEnvVars(false);
}}
title={deploymentToRollback?.is_active ? 'Redeploy' : 'Rollback to Deployment'}
>
<ModalSection>
<p className="text-gray-700 dark:text-gray-300">
{deploymentToRollback?.is_active
? `Are you sure you want to redeploy ${deploymentToRollback?.deployment_id}? This will create a new deployment with the same image.`
: `Are you sure you want to rollback to deployment ${deploymentToRollback?.deployment_id}? This will create a new deployment with the same image.`}
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={useSourceEnvVars}
onChange={(e) => setUseSourceEnvVars(e.target.checked)}
className="mt-1 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Use source deployment's environment variables
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{useSourceEnvVars
? "Will copy environment variables from the source deployment"
: "Will use the current project's environment variables (default)"}
</div>
</div>
</label>
</div>
<ModalActions>
<Button
variant="secondary"
onClick={() => {
setRollbackDialogOpen(false);
setDeploymentToRollback(null);
setUseSourceEnvVars(false);
}}
disabled={rollingBack}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleRollbackConfirm}
loading={rollingBack}
disabled={rollingBack}
>
{deploymentToRollback?.is_active ? 'Redeploy' : 'Rollback'}
</Button>
</ModalActions>
</ModalSection>
</Modal>
</div>
);
}
// Deployment Logs Component with SSE streaming
function DeploymentLogs({ projectName, deploymentId, deploymentStatus }) {
const [logs, setLogs] = useState([]);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState(null);
const [autoScroll, setAutoScroll] = useState(true);
const [tailLines, setTailLines] = useState(1000);
const [tailInputValue, setTailInputValue] = useState('1000');
const logsEndRef = useRef(null);
const abortControllerRef = useRef(null);
const isLoggable = (status) => {
// Can view logs for deployments that are running or have run
return ['Deploying', 'Healthy', 'Unhealthy', 'Stopped', 'Failed', 'Superseded'].includes(status);
};
const scrollToBottom = () => {
if (autoScroll && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
};
useEffect(() => {
scrollToBottom();
}, [logs]);
const startStreaming = useCallback(() => {
// Stop any existing stream first
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Clear existing logs when starting a new stream
setLogs([]);
setError(null);
setStreaming(true);
const baseUrl = window.API_BASE_URL || '';
const url = `${baseUrl}/api/v1/projects/${projectName}/deployments/${deploymentId}/logs?follow=true&tail=${tailLines}`;
// Create new AbortController for this stream
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Use fetch for SSE with cookies
fetch(url, {
headers: {
'Accept': 'text/event-stream',
},
credentials: 'include', // Include cookies (rise_jwt)
signal: abortController.signal,
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = () => {
reader.read().then(({ done, value }) => {
if (done) {
setStreaming(false);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
lines.forEach(line => {
if (line.startsWith('data: ')) {
const logLine = line.substring(6); // Remove 'data: ' prefix
if (logLine.trim()) {
setLogs(prevLogs => [...prevLogs, logLine]);
}
}
});
processStream();
}).catch(err => {
// Ignore abort errors
if (err.name === 'AbortError') {
return;
}
console.error('Stream error:', err);
setError(err.message);
setStreaming(false);
});
};
processStream();
})
.catch(err => {
// Ignore abort errors
if (err.name === 'AbortError') {
return;
}
console.error('Failed to start log stream:', err);
setError(err.message);
setStreaming(false);
});
}, [projectName, deploymentId, tailLines]);
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setStreaming(false);
}, []);
const loadInitialLogs = useCallback(async () => {
const baseUrl = window.API_BASE_URL || '';
const url = `${baseUrl}/api/v1/projects/${projectName}/deployments/${deploymentId}/logs?tail=${tailLines}`;
try {
const response = await fetch(url, {
headers: {
'Accept': 'text/event-stream',
},
credentials: 'include', // Include cookies (rise_jwt)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const newLogs = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
lines.forEach(line => {
if (line.startsWith('data: ')) {
const logLine = line.substring(6);
if (logLine.trim()) {
newLogs.push(logLine);
}
}
});
}
setLogs(newLogs);
} catch (err) {
console.error('Failed to load logs:', err);
setError(err.message);
}
}, [projectName, deploymentId, tailLines]);
const clearLogs = () => {
setLogs([]);
};
const handleTailLinesChange = (e) => {
setTailInputValue(e.target.value);
};
const handleTailLinesBlur = () => {
const newTail = parseInt(tailInputValue, 10);
if (!isNaN(newTail) && newTail > 0) {
setTailLines(newTail);
} else {
// Reset to current value if invalid
setTailInputValue(tailLines.toString());
}
};
const handleTailLinesKeyPress = (e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur which will handle the update
}
};
// Effect to restart streaming when tailLines changes and we're currently streaming
useEffect(() => {
if (streaming) {
console.log('Tail lines changed to', tailLines, ', restarting stream...');
startStreaming();
}
}, [tailLines]); // Only depend on tailLines, not streaming or startStreaming to avoid loops
useEffect(() => {
return () => {
stopStreaming();
};
}, [stopStreaming]);
if (!isLoggable(deploymentStatus)) {
return null;
}
return (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-bold">Runtime Logs</h3>
<div className="flex gap-2 items-center">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>Tail lines:</span>
<input
type="number"
value={tailInputValue}
onChange={handleTailLinesChange}
onBlur={handleTailLinesBlur}
onKeyPress={handleTailLinesKeyPress}
min="1"
className="w-20 bg-gray-100 dark:bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-500"
/>
</label>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded border-gray-600 bg-gray-100 dark:bg-gray-800 text-indigo-600 focus:ring-indigo-500"
/>
Auto-scroll
</label>
<Button
variant="secondary"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0}
>
Clear
</Button>
{!streaming ? (
<>
<Button
variant="secondary"
size="sm"
onClick={loadInitialLogs}
>
Load Logs
</Button>
<Button
variant="primary"
size="sm"
onClick={startStreaming}
>
Follow Logs
</Button>
</>
) : (
<Button
variant="secondary"
size="sm"
onClick={stopStreaming}
>
Stop
</Button>
)}
</div>
</div>
{error && (
<div className="mb-3 p-3 bg-red-900/20 border border-red-800 rounded text-red-600 dark:text-red-400 text-sm">
Error: {error}
</div>
)}
<div className="bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden">
<div
className="p-4 overflow-y-auto font-mono text-xs text-gray-700 dark:text-gray-300"
style={{ height: '400px' }}
>
{logs.length === 0 ? (
<div className="text-gray-600 dark:text-gray-500 text-center py-8">
{streaming ? 'Waiting for logs...' : 'No logs yet. Click "Load Logs" or "Follow Logs" to view.'}
</div>
) : (
<>
{logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap break-all">
{log}
</div>
))}
<div ref={logsEndRef} />
</>
)}
</div>
</div>
{streaming && (
<div className="mt-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
Live streaming logs...
</div>
)}
</div>
);
}
function getPhaseForEvent(event: string) {
const e = event.toLowerCase();
if (e.includes('build')) return 'build';
if (e.includes('push') || e.includes('image')) return 'push';
if (e.includes('rollout') || e.includes('deploy')) return 'rollout';
if (e.includes('health') || e.includes('ready') || e.includes('active')) return 'health';
return 'other';
}
function formatDurationDelta(fromTs?: string | null, toTs?: string | null) {
if (!fromTs || !toTs) return '--';
const from = new Date(fromTs).getTime();
const to = new Date(toTs).getTime();
if (Number.isNaN(from) || Number.isNaN(to) || to < from) return '--';
const seconds = Math.floor((to - from) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const rem = seconds % 60;
if (minutes < 60) return `${minutes}m ${rem}s`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
}
function buildDeploymentTimeline(deployment: any) {
const events: Array<{ label: string; ts: string | null; phase: string }> = [
{ label: 'Deployment requested', ts: deployment.created || null, phase: 'build' },
{ label: 'Image prepared', ts: deployment.created || null, phase: 'push' },
{ label: 'Rollout started', ts: deployment.created || null, phase: 'rollout' },
];
if (deployment.completed_at) {
events.push({
label: deployment.status === 'Failed' ? 'Deployment failed' : 'Deployment completed',
ts: deployment.completed_at,
phase: deployment.status === 'Failed' ? 'rollout' : 'health',
});
}
const healthLastCheck = deployment.controller_metadata?.health?.last_check || null;
if (healthLastCheck) {
events.push({
label: deployment.controller_metadata?.health?.healthy ? 'Health check healthy' : 'Health check degraded',
ts: healthLastCheck,
phase: 'health',
});
}
const statusEventTime = deployment.completed_at || deployment.created || null;
events.push({
label: `Current status: ${deployment.status}`,
ts: statusEventTime,
phase: getPhaseForEvent(deployment.status || ''),
});
const sorted = events
.filter((e) => e.ts)
.sort((a, b) => new Date(a.ts || '').getTime() - new Date(b.ts || '').getTime());
return sorted.map((event, index) => {
const prev = index > 0 ? sorted[index - 1] : null;
return {
...event,
delta: prev ? formatDurationDelta(prev.ts, event.ts) : '--',
};
});
}
// TypeScript interfaces matching Rust backend structs
interface PodEvent {
type: string;
reason: string;
message: string;
count: number;
last_timestamp: string;
}
interface ContainerState {
state_type: 'waiting' | 'running' | 'terminated';
reason?: string;
message?: string;
exit_code?: number;
}
interface ContainerStatusInfo {
name: string;
ready: boolean;
restart_count: number;
state?: ContainerState;
}
interface PodCondition {
type: string;
status: string;
reason?: string;
message?: string;
}
interface PodInfo {
name: string;
phase: 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown';
conditions?: PodCondition[];
containers?: ContainerStatusInfo[];
events?: PodEvent[];
}
interface PodStatus {
desired_replicas: number;
ready_replicas: number;
current_replicas: number;
pods: PodInfo[];
last_checked: string;
}
function PodInfoRow({ pod }: { pod: PodInfo }) {
const [expanded, setExpanded] = useState(false);
const detailsId = `pod-details-${pod.name.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
// Check if pod has issues
const hasIssues = pod.events?.length > 0 ||
pod.containers?.some(c => !c.ready || c.restart_count > 0) ||
pod.conditions?.some(c => c.status === 'False');
const phaseTone = {
Running: '#b7ffce',
Pending: '#ffe3a8',
Failed: '#ffc0c0',
Succeeded: '#b7ffce',
Unknown: '#888',
};
// Use appropriate border color based on issues
const borderColor = hasIssues ? '#7d4b4b' : 'var(--mono-line)';
return (
<div className="border-b" style={{ borderColor }}>
<button
type="button"
className="w-full p-3 text-left cursor-pointer"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={detailsId}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono font-semibold" style={{ color: '#e8e8e8' }}>
{pod.name}
</span>
<span className="text-xs px-1.5 py-0.5" style={{
color: phaseTone[pod.phase] || '#888',
border: `1px solid ${phaseTone[pod.phase] || '#888'}`,
background: 'rgba(0, 0, 0, 0.3)'
}}>
{pod.phase}
</span>
{hasIssues && (
<span className="text-xs" style={{ color: '#ffc0c0' }}>
⚠
</span>
)}
</div>
<div className="text-xs" style={{ color: 'var(--mono-muted)' }}>
{pod.containers?.length || 0} container(s) •{' '}
{pod.containers?.filter(c => c.ready).length || 0} ready
</div>
</div>
<span className="text-xs" style={{ color: 'var(--mono-muted)' }} aria-hidden="true">
{expanded ? '▼' : '▶'}
</span>
</div>
</button>
{expanded && (
<div id={detailsId} className="p-3 pt-0" style={{ background: '#0a0a0a' }}>
{/* Container statuses */}
{pod.containers && pod.containers.length > 0 && (
<div className="mb-3">
<h6 className="text-xs font-semibold mb-2" style={{ color: 'var(--mono-muted)' }}>
Containers
</h6>
<div className="space-y-2">
{pod.containers.map((container, idx) => (
<div key={idx} className="text-xs p-2" style={{ background: '#0f0f0f', border: '1px solid var(--mono-line)' }}>
<div className="flex items-center justify-between mb-1">
<span className="font-mono" style={{ color: '#e8e8e8' }}>{container.name}</span>
<span style={{ color: container.ready ? '#b7ffce' : '#ffc0c0' }}>
{container.ready ? '✓ Ready' : '✗ Not ready'}
</span>
</div>
{container.restart_count > 0 && (
<div style={{ color: 'var(--mono-warn)' }}>
Restarts: {container.restart_count}
</div>
)}
{container.state && (
<div style={{ color: 'var(--mono-muted)' }}>
State: {container.state.state_type}
{container.state.reason && ` (${container.state.reason})`}
</div>
)}
{container.state?.message && (
<div className="mt-1 font-mono" style={{ color: '#ffc0c0' }}>
{container.state.message}
</div>
)}
{container.state?.exit_code !== undefined && (
<div style={{ color: 'var(--mono-muted)' }}>
Exit code: {container.state.exit_code}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Pod conditions */}
{pod.conditions && pod.conditions.length > 0 && (
<div className="mb-3">
<h6 className="text-xs font-semibold mb-2" style={{ color: 'var(--mono-muted)' }}>
Conditions
</h6>
<div className="space-y-1">
{pod.conditions.map((condition, idx) => (
<div key={idx} className="text-xs flex items-center justify-between">
<span style={{ color: '#e8e8e8' }}>{condition.type}</span>
<span style={{ color: condition.status === 'True' ? '#b7ffce' : '#ffc0c0' }}>
{condition.status}
</span>
</div>
))}
</div>
</div>
)}
{/* Recent events */}
{pod.events && pod.events.length > 0 && (
<div>
<h6 className="text-xs font-semibold mb-2" style={{ color: 'var(--mono-muted)' }}>
Recent Events
</h6>
<div className="space-y-2">
{pod.events.map((event, idx) => (
<div key={idx} className="text-xs p-2" style={{
background: event.type === 'Error' ? 'rgba(125, 75, 75, 0.24)' : 'rgba(139, 112, 57, 0.22)',
border: `1px solid ${event.type === 'Error' ? '#7d4b4b' : '#7b6333'}`
}}>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold" style={{ color: event.type === 'Error' ? '#ffc0c0' : 'var(--mono-warn)' }}>
{event.reason}
</span>
<span style={{ color: 'var(--mono-muted)' }}>
{event.count > 1 && `${event.count}× `}
{formatRelativeTimeRounded(event.last_timestamp)}
</span>
</div>
<div className="font-mono" style={{ color: '#e8e8e8' }}>
{event.message}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function PodStatusSection({ podStatus }: { podStatus: PodStatus }) {
const replicasMismatch = podStatus.ready_replicas < podStatus.desired_replicas;
const hasPodIssues =
podStatus.pods &&
podStatus.pods.some(
(p) =>
(p.containers && p.containers.some(c => c.restart_count > 0)) ||
(p.events && p.events.length > 0)
);
const hasIssues = replicasMismatch || hasPodIssues;
// Determine tone based on replica counts and pod-level issues
let tone = 'ok';
if (podStatus.ready_replicas === 0) {
tone = 'bad';
} else if (replicasMismatch || hasPodIssues) {
tone = 'warn';
}
const toneColors = {
ok: { color: '#b7ffce', borderColor: '#2e6c44', background: 'rgba(44, 105, 66, 0.2)' },
warn: { color: '#ffe3a8', borderColor: '#7b6333', background: 'rgba(139, 112, 57, 0.22)' },
bad: { color: '#ffc0c0', borderColor: '#7d4b4b', background: 'rgba(125, 75, 75, 0.24)' },
};
const borderColors = {
ok: '#2e6c44',
warn: '#7b6333',
bad: '#7d4b4b',
};
const headerColors = {
ok: '#b7ffce',
warn: '#ffe3a8',
bad: '#ffc0c0',
};
return (
<div className="mb-6">
<h4 className="text-sm font-semibold mb-2" style={{ color: 'var(--mono-muted)' }}>
Pod Status
</h4>
{/* Replica summary */}
<div className="mono-inline-status mb-3" style={toneColors[tone]}>
<div className="flex items-center justify-between">
<span>Pods: {podStatus.ready_replicas}/{podStatus.desired_replicas} ready</span>
<span className="text-xs" style={{ color: 'var(--mono-muted)' }}>
{podStatus.current_replicas} total
</span>
</div>
</div>
{/* Per-pod details */}
{podStatus.pods && podStatus.pods.length > 0 && (
<div className="border border-solid" style={{ borderColor: borderColors[tone], background: tone === 'ok' ? '#0a1210' : '#1a1212' }}>
<div className="p-3" style={{ borderBottom: `1px solid ${borderColors[tone]}` }}>
<h5 className="text-xs font-semibold" style={{ color: headerColors[tone] }}>
Pods ({podStatus.pods.length})
</h5>
</div>
<div>
{podStatus.pods.map((pod, idx) => (
<PodInfoRow key={pod.name || `pod-${idx}`} pod={pod} />
))}
</div>
</div>
)}
<p className="text-xs mt-2" style={{ color: 'var(--mono-muted)' }}>
Last checked: {formatRelativeTimeRounded(podStatus.last_checked)}
</p>
</div>
);
}
export function DeploymentDetail({ projectName, deploymentId }) {
const [deployment, setDeployment] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false);
const [rolling, setRolling] = useState(false);
const [useSourceEnvVars, setUseSourceEnvVars] = useState(false);
const [detailActionStatus, setDetailActionStatus] = useState('');
const { showToast } = useToast();
const handleCopy = useCallback(async (value, label) => {
if (!value || value === '-') return;
try {
await copyToClipboard(value);
showToast(`${label} copied`, 'success');
} catch (err) {
showToast(`Failed to copy ${label.toLowerCase()}: ${err.message}`, 'error');
}
}, [showToast]);
const isTerminal = (status) => {
return ['Cancelled', 'Stopped', 'Superseded', 'Failed', 'Expired'].includes(status);
};
const loadDeployment = useCallback(async () => {
try {
const data = await api.getDeployment(projectName, deploymentId);
setDeployment(data);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}, [projectName, deploymentId]);
const handleRollbackClick = () => {
setRollbackDialogOpen(true);
};
const handleRollback = async () => {
setRolling(true);
setDetailActionStatus(`${deployment.is_active ? 'Redeploying' : 'Rolling back'} deployment ${deploymentId}...`);
try {
const response = await api.createDeploymentFrom(projectName, deploymentId, useSourceEnvVars);
showToast(`${deployment.is_active ? 'Redeploy' : 'Rollback'} successful! New deployment: ${response.deployment_id}`, 'success');
setDetailActionStatus(`${deployment.is_active ? 'Redeployed' : 'Rolled back'} to deployment ${response.deployment_id}.`);
setRollbackDialogOpen(false);
setUseSourceEnvVars(false); // Reset checkbox
// Redirect to project page to see the new deployment
navigate(`/project/${projectName}`);
} catch (err) {
showToast(`Failed to ${deployment.is_active ? 'redeploy' : 'rollback'} deployment: ${err.message}`, 'error');
setDetailActionStatus(`Failed to ${deployment.is_active ? 'redeploy' : 'rollback'} deployment ${deploymentId}.`);
} finally {
setRolling(false);
}
};
useEffect(() => {
loadDeployment();
}, [loadDeployment]);
// Auto-refresh only if deployment is not in a terminal state
useEffect(() => {
if (deployment && !isTerminal(deployment.status)) {
const interval = setInterval(loadDeployment, 5000);
return () => clearInterval(interval);
}
}, [deployment?.status, loadDeployment]);
if (loading) return <LoadingState label="Loading deployment..." />;
if (error) return <ErrorState message={`Error loading deployment: ${error}`} onRetry={loadDeployment} />;
if (!deployment) return <EmptyState message="Deployment not found." />;
const timeline = buildDeploymentTimeline(deployment);
const phases = ['build', 'push', 'rollout', 'health', 'other'];
const groupedTimeline = phases
.map((phase) => ({ phase, events: timeline.filter((e) => e.phase === phase) }))
.filter((group) => group.events.length > 0);
return (
<section>
<div className="flex justify-end items-center mb-4">
{deployment.can_rollback && (
<Button
variant="secondary"
size="sm"
onClick={handleRollbackClick}
>
{deployment.is_active ? 'Redeploy' : 'Rollback'}
</Button>
)}
</div>
{detailActionStatus && <p className="mono-inline-status mb-4">{detailActionStatus}</p>}
<div className="mono-status-strip mono-status-strip-normalcase mb-6">
<div className={`mono-status-card mono-status-card-${getStatusTone(deployment.status)}`}>
<span>status</span>
<strong>{deployment.status}</strong>
</div>
<div>
<span>deployment</span>
<strong className="mono-copyable-value">
<span>{deployment.deployment_id}</span>
<button
type="button"
className="mono-copy-button"
title="Copy deployment ID"
aria-label="Copy deployment ID"
onClick={() => handleCopy(deployment.deployment_id, 'Deployment ID')}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
</strong>
</div>
<div><span>group</span><strong>{deployment.deployment_group}</strong></div>
<div>
<span>created</span>
<strong className="mono-copyable-value" title={formatISO8601(deployment.created)}>
<span>{formatRelativeTimeRounded(deployment.created)}</span>
<button
type="button"
className="mono-copy-button"
title="Copy created timestamp (ISO8601)"
aria-label="Copy created timestamp (ISO8601)"
onClick={() => handleCopy(formatISO8601(deployment.created), 'Created timestamp')}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
</strong>
</div>
<div><span>project</span><strong>{projectName}</strong></div>
<div><span>created_by</span><strong>{deployment.created_by_email || '-'}</strong></div>
<div>
<span>image</span>
<strong className="mono-copyable-value">
<span>{deployment.image || '-'}</span>
{deployment.image && (
<button
type="button"
className="mono-copy-button"
title="Copy image"
aria-label="Copy image"
onClick={() => handleCopy(deployment.image, 'Image')}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
)}
</strong>
</div>
<div><span>digest</span><strong>{deployment.image_digest || '-'}</strong></div>
<div>
<span>primary_url</span>
<strong className="mono-copyable-value">
<span>
{deployment.primary_url ? (
<a href={deployment.primary_url} target="_blank" rel="noopener noreferrer" className="underline">
{deployment.primary_url}
</a>
) : '-'}
</span>
{deployment.primary_url && (
<button
type="button"
className="mono-copy-button"
title="Copy primary URL"
aria-label="Copy primary URL"
onClick={() => handleCopy(deployment.primary_url, 'Primary URL')}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
)}
</strong>
</div>
<div>
<span>custom_urls</span>
<strong>
{deployment.custom_domain_urls && deployment.custom_domain_urls.length > 0
? deployment.custom_domain_urls.map((url, idx) => (
<Fragment key={url}>
{idx > 0 ? ', ' : ''}
<a href={url} target="_blank" rel="noopener noreferrer" className="underline">
{url}
</a>
</Fragment>
))
: '-'}
</strong>
</div>
<div><span>completed</span><strong>{deployment.completed_at ? formatDate(deployment.completed_at) : '-'}</strong></div>
<div><span>expires</span><strong>{deployment.expires_at ? formatTimeRemaining(deployment.expires_at) : '-'}</strong></div>
</div>
{deployment.error_message && (
<div className="mono-inline-status mb-6" style={{ color: '#ffc0c0', borderColor: '#7d4b4b', background: '#1a1212' }}>
Error: {deployment.error_message}
</div>
)}
{deployment.controller_metadata?.pod_status && (
<PodStatusSection podStatus={deployment.controller_metadata.pod_status} />
)}
{deployment.build_logs && (
<details className="mb-6">
<summary className="cursor-pointer text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-semibold">Build Logs</summary>
<pre className="mt-2 bg-gray-950 border border-gray-200 dark:border-gray-800 rounded p-4 overflow-x-auto text-xs">
<code className="text-gray-700 dark:text-gray-300">{deployment.build_logs}</code>
</pre>
</details>
)}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 mb-6">
<h3 className="text-xl font-bold mb-4">Deployment Timeline</h3>
<div className="space-y-4">
{groupedTimeline.map((group) => (
<div key={group.phase} className="mono-timeline-group">
<h4 className="mono-timeline-phase">{group.phase}</h4>
<div className="mono-timeline-list">
{group.events.map((event, idx) => (
<div key={`${group.phase}-${idx}`} className="mono-timeline-item">
<span>{formatDate(event.ts || '')}</span>
<span>{event.label}</span>
<span>+{event.delta}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
<DeploymentLogs projectName={projectName} deploymentId={deploymentId} deploymentStatus={deployment.status} />
<h3 className="text-xl font-bold mb-4">Environment Variables</h3>
<EnvVarsList projectName={projectName} deploymentId={deploymentId} />
<Modal
isOpen={rollbackDialogOpen}
onClose={() => {
setRollbackDialogOpen(false);
setUseSourceEnvVars(false);
}}
title={deployment?.is_active ? 'Redeploy' : 'Rollback to Deployment'}
>
<ModalSection>
<p className="text-gray-700 dark:text-gray-300">
{deployment?.is_active
? `Are you sure you want to redeploy ${deploymentId}? This will create a new deployment with the same image.`
: `Are you sure you want to rollback to deployment ${deploymentId}? This will create a new deployment with the same image.`}
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={useSourceEnvVars}
onChange={(e) => setUseSourceEnvVars(e.target.checked)}
className="mt-1 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Use source deployment's environment variables
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{useSourceEnvVars
? "Will copy environment variables from the source deployment"
: "Will use the current project's environment variables (default)"}
</div>
</div>
</label>
</div>
<ModalActions>
<Button
variant="secondary"
onClick={() => {
setRollbackDialogOpen(false);
setUseSourceEnvVars(false);
}}
disabled={rolling}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleRollback}
loading={rolling}
disabled={rolling}
>
{deployment?.is_active ? 'Redeploy' : 'Rollback'}
</Button>
</ModalActions>
</ModalSection>
</Modal>
</section>
);
}