Add service credential visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-19 00:33:22 +03:30
parent 41a276d16d
commit 18c6abd0e8
5 changed files with 169 additions and 5 deletions
@@ -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 { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; 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 { KubernetesService } from '../kubernetes/kubernetes.service';
import { DeploymentsService } from '../deployments/deployments.service'; import { DeploymentsService } from '../deployments/deployments.service';
import { AccessService } from '../access/access.service'; import { AccessService } from '../access/access.service';
@@ -212,6 +212,41 @@ export class ApplicationsController {
return this.applicationsService.findAll(search); 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') @Get(':id')
@ApiOperation({ summary: 'Get application details' }) @ApiOperation({ summary: 'Get application details' })
async findOne(@Param('id') id: string, @Request() req: any) { async findOne(@Param('id') id: string, @Request() req: any) {
+108 -3
View File
@@ -4,13 +4,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import { toast } from 'react-toastify'; 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 { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link'; 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 { 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 { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { BuildProgressModal } from '@/components/build-progress-modal'; import { BuildProgressModal } from '@/components/build-progress-modal';
import { useAuthStore } from '@/lib/store';
/** Matches backend multipart limit for POST /applications/:id/upload */ /** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3; const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
@@ -22,8 +22,8 @@ const statusColors: Record<string, string> = {
deploying: 'badge-blue', deploying: 'badge-blue',
failed: 'badge-red', failed: 'badge-red',
build_failed: 'badge-red', build_failed: 'badge-red',
stopped: 'badge-gray',
cancelled: 'badge-gray', cancelled: 'badge-gray',
stopped: 'badge-gray',
}; };
/** /**
@@ -102,6 +102,7 @@ export default function AppDetailPage() {
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database'); const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60); const [accessDuration, setAccessDuration] = useState(60);
const [showAccessSecret, setShowAccessSecret] = useState(false); const [showAccessSecret, setShowAccessSecret] = useState(false);
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
const [accessNow, setAccessNow] = useState(() => Date.now()); const [accessNow, setAccessNow] = useState(() => Date.now());
const { data: app, isLoading } = useQuery<Application>({ const { data: app, isLoading } = useQuery<Application>({
@@ -122,6 +123,12 @@ export default function AppDetailPage() {
refetchInterval: 5000, // Poll for status updates 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 }>({ const { data: logsData } = useQuery<{ logs: string }>({
queryKey: ['logs', appId], queryKey: ['logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data), queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
@@ -1821,6 +1828,104 @@ export default function AppDetailPage() {
</div> </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 */} {/* Temporary External Access */}
{hasAccessTargets && ( {hasAccessTargets && (
<div className="card"> <div className="card">
+18
View File
@@ -118,6 +118,24 @@ export interface ServiceAccessGrant {
connection: ServiceAccessConnection; 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 { export interface Deployment {
id: string; id: string;
status: DeploymentStatus; status: DeploymentStatus;
File diff suppressed because one or more lines are too long