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:
keyhan
2026-05-15 13:58:08 +03:30
parent c7981074d4
commit 2303985d0c
13 changed files with 1015 additions and 19 deletions
+202 -2
View File
@@ -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">