Add service credential visibility.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- Add DOCKED lifecycle status and dock metadata (run if TypeORM sync is disabled)
|
||||
ALTER TYPE applications_lifecyclestatus_enum ADD VALUE IF NOT EXISTS 'docked';
|
||||
|
||||
ALTER TABLE applications
|
||||
ADD COLUMN IF NOT EXISTS "dockedAt" TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS "dockSnapshotId" VARCHAR;
|
||||
@@ -24,7 +24,7 @@ import { DomainService } from './domain.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, DatabaseType } from '../common/enums';
|
||||
import { UserRole, DatabaseType, ServiceAccessTarget } from '../common/enums';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { DeploymentsService } from '../deployments/deployments.service';
|
||||
import { AccessService } from '../access/access.service';
|
||||
@@ -212,6 +212,41 @@ export class ApplicationsController {
|
||||
return this.applicationsService.findAll(search);
|
||||
}
|
||||
|
||||
@Get(':id/service-credentials')
|
||||
@ApiOperation({ summary: 'Get internal credentials for enabled optional services' })
|
||||
async getServiceCredentials(@Param('id') id: string, @Request() req: any) {
|
||||
const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
|
||||
const credentials: Record<string, any> = {};
|
||||
|
||||
if (app.enableRedis) {
|
||||
const redis = await this.kubernetesService.readAccessCredentials(app, ServiceAccessTarget.REDIS);
|
||||
credentials.redis = {
|
||||
host: `${app.name}-redis`,
|
||||
port: 6379,
|
||||
password: redis.password,
|
||||
url: redis.password
|
||||
? `redis://:${redis.password}@${app.name}-redis:6379`
|
||||
: `redis://${app.name}-redis:6379`,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.enableRabbitmq) {
|
||||
const rabbitmq = await this.kubernetesService.readAccessCredentials(app, ServiceAccessTarget.RABBITMQ_AMQP);
|
||||
const username = rabbitmq.username || 'appuser';
|
||||
credentials.rabbitmq = {
|
||||
host: `${app.name}-rabbitmq`,
|
||||
amqpPort: 5672,
|
||||
managementPort: 15672,
|
||||
username,
|
||||
password: rabbitmq.password,
|
||||
amqpUrl: `amqp://${username}:${rabbitmq.password}@${app.name}-rabbitmq:5672`,
|
||||
managementUrl: `http://${app.name}-rabbitmq:15672`,
|
||||
};
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get application details' })
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -4,13 +4,13 @@ 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, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, OptionalServiceCredentials, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
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, ScrollText } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { BuildProgressModal } from '@/components/build-progress-modal';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
/** Matches backend multipart limit for POST /applications/:id/upload */
|
||||
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
||||
@@ -22,8 +22,8 @@ const statusColors: Record<string, string> = {
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
stopped: 'badge-gray',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -102,6 +102,7 @@ export default function AppDetailPage() {
|
||||
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
|
||||
const [accessDuration, setAccessDuration] = useState(60);
|
||||
const [showAccessSecret, setShowAccessSecret] = useState(false);
|
||||
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
|
||||
const [accessNow, setAccessNow] = useState(() => Date.now());
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
@@ -122,6 +123,12 @@ export default function AppDetailPage() {
|
||||
refetchInterval: 5000, // Poll for status updates
|
||||
});
|
||||
|
||||
const { data: serviceCredentials } = useQuery<OptionalServiceCredentials>({
|
||||
queryKey: ['service-credentials', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/service-credentials`).then((r) => r.data),
|
||||
enabled: !!app?.latestImageTag && (!!app?.enableRedis || !!app?.enableRabbitmq),
|
||||
});
|
||||
|
||||
const { data: logsData } = useQuery<{ logs: string }>({
|
||||
queryKey: ['logs', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
||||
@@ -1821,6 +1828,104 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Service Credentials */}
|
||||
{(app.enableRedis || app.enableRabbitmq) && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" /> Service Credentials
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowServiceSecrets(!showServiceSecrets)}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
{showServiceSecrets ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
{showServiceSecrets ? 'Hide secrets' : 'Show secrets'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{app.enableRedis && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Redis</h3>
|
||||
{[
|
||||
{ label: 'Host', value: serviceCredentials?.redis?.host || `${app.name}-redis`, field: 'redis-host' },
|
||||
{ label: 'Port', value: String(serviceCredentials?.redis?.port || 6379), field: 'redis-port' },
|
||||
{
|
||||
label: 'Password',
|
||||
value: serviceCredentials?.redis?.password || '',
|
||||
field: 'redis-password',
|
||||
secret: true,
|
||||
},
|
||||
{ label: 'URL', value: serviceCredentials?.redis?.url || '', field: 'redis-url', secret: true },
|
||||
].map(({ label, value, field, secret }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.enableRabbitmq && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">RabbitMQ</h3>
|
||||
{[
|
||||
{ label: 'Host', value: serviceCredentials?.rabbitmq?.host || `${app.name}-rabbitmq`, field: 'rabbit-host' },
|
||||
{ label: 'AMQP Port', value: String(serviceCredentials?.rabbitmq?.amqpPort || 5672), field: 'rabbit-amqp-port' },
|
||||
{ label: 'Management Port', value: String(serviceCredentials?.rabbitmq?.managementPort || 15672), field: 'rabbit-mgmt-port' },
|
||||
{ label: 'Username', value: serviceCredentials?.rabbitmq?.username || 'appuser', field: 'rabbit-user' },
|
||||
{
|
||||
label: 'Password',
|
||||
value: serviceCredentials?.rabbitmq?.password || '',
|
||||
field: 'rabbit-password',
|
||||
secret: true,
|
||||
},
|
||||
{ label: 'AMQP URL', value: serviceCredentials?.rabbitmq?.amqpUrl || '', field: 'rabbit-amqp-url', secret: true },
|
||||
{ label: 'Management URL', value: serviceCredentials?.rabbitmq?.managementUrl || '', field: 'rabbit-mgmt-url' },
|
||||
].map(({ label, value, field, secret }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!app.latestImageTag && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Service passwords are available after the application is deployed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporary External Access */}
|
||||
{hasAccessTargets && (
|
||||
<div className="card">
|
||||
|
||||
@@ -118,6 +118,24 @@ export interface ServiceAccessGrant {
|
||||
connection: ServiceAccessConnection;
|
||||
}
|
||||
|
||||
export interface OptionalServiceCredentials {
|
||||
redis?: {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
url?: string;
|
||||
};
|
||||
rabbitmq?: {
|
||||
host: string;
|
||||
amqpPort: number;
|
||||
managementPort: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
amqpUrl?: string;
|
||||
managementUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
status: DeploymentStatus;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user