Add time-limited external access for optional services and database.
Users can open temporary NodePort access with auto-revoke via Bull jobs and a dashboard UI to manage active grants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,9 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { BuildProgressModal } from '@/components/build-progress-modal';
|
||||
|
||||
@@ -91,6 +91,11 @@ export default function AppDetailPage() {
|
||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
|
||||
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
|
||||
const [accessDuration, setAccessDuration] = useState(60);
|
||||
const [showAccessSecret, setShowAccessSecret] = useState(false);
|
||||
const [accessNow, setAccessNow] = useState(() => Date.now());
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
@@ -646,6 +651,77 @@ export default function AppDetailPage() {
|
||||
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||||
});
|
||||
|
||||
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
|
||||
if (app?.databaseType && app.databaseType !== 'none') {
|
||||
accessTargetOptions.push({ value: 'database', label: 'Database' });
|
||||
}
|
||||
if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' });
|
||||
if (app?.enableRabbitmq) {
|
||||
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
|
||||
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
|
||||
}
|
||||
const hasAccessTargets = accessTargetOptions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccessTargets) return;
|
||||
if (!accessTargetOptions.some((o) => o.value === accessTarget)) {
|
||||
setAccessTarget(accessTargetOptions[0].value);
|
||||
}
|
||||
}, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]);
|
||||
|
||||
const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery<ServiceAccessGrant[]>({
|
||||
queryKey: ['access-grants', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data),
|
||||
enabled: hasAccessTargets && !!app?.latestImageTag,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessGrants.some((g) => g.status === 'active')) return;
|
||||
const t = setInterval(() => setAccessNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [accessGrants]);
|
||||
|
||||
const createAccessMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/applications/${appId}/access`, {
|
||||
target: accessTarget,
|
||||
durationMinutes: accessDuration,
|
||||
}).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
refetchAccessGrants();
|
||||
toast.success('Temporary external access enabled');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to enable access');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAccessMutation = useMutation({
|
||||
mutationFn: (grantId: string) =>
|
||||
api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
refetchAccessGrants();
|
||||
toast.success('Access revoked');
|
||||
},
|
||||
onError: () => toast.error('Failed to revoke access'),
|
||||
});
|
||||
|
||||
const accessTargetLabel = (target: ServiceAccessTarget) =>
|
||||
accessTargetOptions.find((o) => o.value === target)?.label || target;
|
||||
|
||||
const formatAccessCountdown = (expiresAt: string) => {
|
||||
const ms = new Date(expiresAt).getTime() - accessNow;
|
||||
if (ms <= 0) return 'Expired';
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData();
|
||||
@@ -1688,6 +1764,130 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporary External Access */}
|
||||
{hasAccessTargets && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5" /> Temporary External Access
|
||||
</h2>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.
|
||||
</p>
|
||||
|
||||
{!app?.latestImageTag ? (
|
||||
<p className="text-sm text-gray-500">Deploy the application first to enable external access.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
|
||||
<select
|
||||
value={accessTarget}
|
||||
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{accessTargetOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
|
||||
<div className="flex gap-2">
|
||||
{[30, 60, 240].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setAccessDuration(mins)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
accessDuration === mins
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createAccessMutation.mutate()}
|
||||
disabled={createAccessMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createAccessMutation.isPending ? 'Opening…' : 'Enable access'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No active external access sessions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accessGrants
|
||||
.filter((g) => g.status === 'active')
|
||||
.map((grant) => (
|
||||
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">ends in {formatAccessCountdown(grant.expiresAt)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeAccessMutation.mutate(grant.id)}
|
||||
disabled={revokeAccessMutation.isPending}
|
||||
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm font-mono">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-800">{grant.host}:{grant.port}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedField === `access-endpoint-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{grant.connection.url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">URL</span>
|
||||
<div className="flex items-center gap-2 max-w-[70%]">
|
||||
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
|
||||
{showAccessSecret ? grant.connection.url : '••••••••••••'}
|
||||
</span>
|
||||
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === `access-url-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource Monitoring & Scaling */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
Reference in New Issue
Block a user