feat: technical role can view all applications with search, separate /all route
- Add GET /applications/all endpoint for admin/technical with search by user name/email/ID - GET /applications now always returns only the current user's apps (fix for technical seeing all apps) - Technical role can view/edit/delete any application (same as admin) - Add 'All Applications' page with search bar, user info columns, status badges - Add 'All Applications' link to admin and technical sidebar navigation - Add user relation to Application TypeScript interface
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -62,16 +63,20 @@ export class ApplicationsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List my applications' })
|
@ApiOperation({ summary: 'List my applications' })
|
||||||
async findAll(@Request() req: any) {
|
async findAll(@Request() req: any) {
|
||||||
if (req.user.role === UserRole.ADMIN) {
|
|
||||||
return this.applicationsService.findAll();
|
|
||||||
}
|
|
||||||
return this.applicationsService.findAllByUser(req.user.id);
|
return this.applicationsService.findAllByUser(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('all')
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||||
|
@ApiOperation({ summary: 'List all applications (admin/technical)' })
|
||||||
|
async findAllAdmin(@Request() req: any, @Query('search') search?: string) {
|
||||||
|
return this.applicationsService.findAll(search);
|
||||||
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
if (req.user.role === UserRole.ADMIN) {
|
if (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) {
|
||||||
return this.applicationsService.findOne(id);
|
return this.applicationsService.findOne(id);
|
||||||
}
|
}
|
||||||
return this.applicationsService.findOne(id, req.user.id);
|
return this.applicationsService.findOne(id, req.user.id);
|
||||||
@@ -84,6 +89,11 @@ export class ApplicationsController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() dto: UpdateApplicationDto,
|
@Body() dto: UpdateApplicationDto,
|
||||||
) {
|
) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
if (isStaff) {
|
||||||
|
const app = await this.applicationsService.findOne(id);
|
||||||
|
return this.applicationsService.update(id, app.userId, dto);
|
||||||
|
}
|
||||||
return this.applicationsService.update(id, req.user.id, dto);
|
return this.applicationsService.update(id, req.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +102,7 @@ export class ApplicationsController {
|
|||||||
async getResources(@Param('id') id: string, @Request() req: any) {
|
async getResources(@Param('id') id: string, @Request() req: any) {
|
||||||
const app = await this.applicationsService.findOne(
|
const app = await this.applicationsService.findOne(
|
||||||
id,
|
id,
|
||||||
req.user.role === UserRole.ADMIN ? undefined : req.user.id,
|
(req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? undefined : req.user.id,
|
||||||
);
|
);
|
||||||
return this.kubernetesService.getResourceUsage(app);
|
return this.kubernetesService.getResourceUsage(app);
|
||||||
}
|
}
|
||||||
@@ -104,7 +114,8 @@ export class ApplicationsController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() dto: ScaleResourcesDto,
|
@Body() dto: ScaleResourcesDto,
|
||||||
) {
|
) {
|
||||||
const app = await this.applicationsService.findOne(id, req.user.id);
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||||
|
|
||||||
// Update in K8s (live)
|
// Update in K8s (live)
|
||||||
await this.kubernetesService.updateResources(app, dto);
|
await this.kubernetesService.updateResources(app, dto);
|
||||||
@@ -117,7 +128,7 @@ export class ApplicationsController {
|
|||||||
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
|
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
|
||||||
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
|
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
|
||||||
|
|
||||||
const updated = await this.applicationsService.update(id, req.user.id, updateFields);
|
const updated = await this.applicationsService.update(id, app.userId, updateFields);
|
||||||
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
|
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -127,7 +138,7 @@ export class ApplicationsController {
|
|||||||
async getPreview(@Param('id') id: string, @Request() req: any) {
|
async getPreview(@Param('id') id: string, @Request() req: any) {
|
||||||
const app = await this.applicationsService.findOne(
|
const app = await this.applicationsService.findOne(
|
||||||
id,
|
id,
|
||||||
req.user.role === UserRole.ADMIN ? undefined : req.user.id,
|
(req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? undefined : req.user.id,
|
||||||
);
|
);
|
||||||
return this.kubernetesService.getPreviewInfo(app);
|
return this.kubernetesService.getPreviewInfo(app);
|
||||||
}
|
}
|
||||||
@@ -135,8 +146,9 @@ export class ApplicationsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||||
async delete(@Param('id') id: string, @Request() req: any) {
|
async delete(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
// 1. Get the app first
|
// 1. Get the app first
|
||||||
const app = await this.applicationsService.findOne(id, req.user.id);
|
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||||
|
|
||||||
// 2. Delete K8s resources (deployment, service, ingress, db, secrets)
|
// 2. Delete K8s resources (deployment, service, ingress, db, secrets)
|
||||||
try {
|
try {
|
||||||
@@ -156,7 +168,7 @@ export class ApplicationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Delete app (also deletes uploaded files)
|
// 4. Delete app (also deletes uploaded files)
|
||||||
await this.applicationsService.delete(id, req.user.id);
|
await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id);
|
||||||
|
|
||||||
return { message: `Application "${app.name}" and all resources deleted` };
|
return { message: `Application "${app.name}" and all resources deleted` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,22 @@ export class ApplicationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Application[]> {
|
async findAll(search?: string): Promise<Application[]> {
|
||||||
return this.appsRepository.find({
|
const qb = this.appsRepository
|
||||||
relations: ['user', 'deployments'],
|
.createQueryBuilder('app')
|
||||||
order: { createdAt: 'DESC' },
|
.leftJoinAndSelect('app.user', 'user')
|
||||||
});
|
.leftJoinAndSelect('app.deployments', 'deployments')
|
||||||
|
.orderBy('app.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
const s = `%${search.trim()}%`;
|
||||||
|
qb.where(
|
||||||
|
'(user.firstName ILIKE :s OR user.lastName ILIKE :s OR user.email ILIKE :s OR CAST(app.userId AS TEXT) ILIKE :s OR app.name ILIKE :s)',
|
||||||
|
{ s },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string, userId?: string): Promise<Application> {
|
async findOne(id: string, userId?: string): Promise<Application> {
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { Application } from '@/types';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
running: 'badge-green',
|
||||||
|
pending: 'badge-yellow',
|
||||||
|
building: 'badge-blue',
|
||||||
|
deploying: 'badge-blue',
|
||||||
|
failed: 'badge-red',
|
||||||
|
build_failed: 'badge-red',
|
||||||
|
stopped: 'badge-gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminAppsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(value), 400);
|
||||||
|
setTimer(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||||
|
queryKey: ['admin-applications', debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get('/applications/all', { params: debouncedSearch ? { search: debouncedSearch } : {} })
|
||||||
|
.then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-applications'] });
|
||||||
|
toast.success('Application deleted');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="skeleton h-8 w-56" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-10 w-full rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="card flex items-center gap-4">
|
||||||
|
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="skeleton h-4 w-36" />
|
||||||
|
<div className="skeleton h-3 w-56" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">All Applications</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
{apps.length} application{apps.length !== 1 ? 's' : ''}
|
||||||
|
{debouncedSearch && ` matching "${debouncedSearch}"`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-base">🔍</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by app name, user name, email, or user ID…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setDebouncedSearch(''); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apps.length === 0 ? (
|
||||||
|
<div className="card text-center py-16">
|
||||||
|
<div className="text-5xl mb-4">📦</div>
|
||||||
|
{debouncedSearch ? (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 text-lg font-medium">No applications found</p>
|
||||||
|
<p className="text-gray-400 mt-1 text-sm">
|
||||||
|
No results for "{debouncedSearch}". Try a different search.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||||
|
<p className="text-gray-400 mt-1 text-sm">No applications have been created by any user.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className="hidden md:block table-wrapper">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50/80">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Application</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Owner</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Runtime</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Database</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Replicas</th>
|
||||||
|
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{apps.map((app) => {
|
||||||
|
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||||
|
return (
|
||||||
|
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center text-base shrink-0">
|
||||||
|
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors block">
|
||||||
|
{app.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{app.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{app.user ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{app.user.firstName} {app.user.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{app.user.email}</p>
|
||||||
|
<p className="text-xs text-gray-300 font-mono">{app.userId.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{app.userId.slice(0, 8)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.databaseType}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||||
|
{latestStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<div className="md:hidden grid gap-3">
|
||||||
|
{apps.map((app) => {
|
||||||
|
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={app.id}
|
||||||
|
href={`/dashboard/apps/${app.id}`}
|
||||||
|
className="card-hover"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center text-lg">
|
||||||
|
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||||
|
{latestStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{app.user && (
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-xs text-gray-500">
|
||||||
|
<span>👤 {app.user.firstName} {app.user.lastName}</span>
|
||||||
|
<span className="text-gray-300">•</span>
|
||||||
|
<span>{app.user.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span>💾 {app.databaseType}</span>
|
||||||
|
<span>📦 {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const userNavItems = [
|
|||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
||||||
|
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: '📦' },
|
||||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
||||||
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
|
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
|
||||||
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' },
|
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' },
|
||||||
@@ -23,6 +24,7 @@ const adminNavItems = [
|
|||||||
|
|
||||||
const technicalNavItems = [
|
const technicalNavItems = [
|
||||||
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
||||||
|
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: '📦' },
|
||||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
||||||
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
|
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: '⚖️' },
|
||||||
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' },
|
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' },
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface Application {
|
|||||||
replicas: number;
|
replicas: number;
|
||||||
port: number;
|
port: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
user?: User;
|
||||||
clusterId?: string;
|
clusterId?: string;
|
||||||
poolId?: string;
|
poolId?: string;
|
||||||
latestImageTag?: string;
|
latestImageTag?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user