diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index d9874ac..5664aa9 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, UseGuards, Request, UseInterceptors, @@ -62,16 +63,20 @@ export class ApplicationsController { @Get() @ApiOperation({ summary: 'List my applications' }) async findAll(@Request() req: any) { - if (req.user.role === UserRole.ADMIN) { - return this.applicationsService.findAll(); - } 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') @ApiOperation({ summary: 'Get application details' }) 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, req.user.id); @@ -84,6 +89,11 @@ export class ApplicationsController { @Request() req: any, @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); } @@ -92,7 +102,7 @@ export class ApplicationsController { async getResources(@Param('id') id: string, @Request() req: any) { const app = await this.applicationsService.findOne( 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); } @@ -104,7 +114,8 @@ export class ApplicationsController { @Request() req: any, @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) await this.kubernetesService.updateResources(app, dto); @@ -117,7 +128,7 @@ export class ApplicationsController { if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit; 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)}`); return updated; } @@ -127,7 +138,7 @@ export class ApplicationsController { async getPreview(@Param('id') id: string, @Request() req: any) { const app = await this.applicationsService.findOne( 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); } @@ -135,8 +146,9 @@ export class ApplicationsController { @Delete(':id') @ApiOperation({ summary: 'Delete an application and all its resources' }) 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 - 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) try { @@ -156,7 +168,7 @@ export class ApplicationsController { } // 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` }; } diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 016752a..019df68 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -84,11 +84,22 @@ export class ApplicationsService { }); } - async findAll(): Promise { - return this.appsRepository.find({ - relations: ['user', 'deployments'], - order: { createdAt: 'DESC' }, - }); + async findAll(search?: string): Promise { + const qb = this.appsRepository + .createQueryBuilder('app') + .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 { diff --git a/frontend/src/app/dashboard/admin/apps/page.tsx b/frontend/src/app/dashboard/admin/apps/page.tsx new file mode 100644 index 0000000..4639c7f --- /dev/null +++ b/frontend/src/app/dashboard/admin/apps/page.tsx @@ -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 = { + 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(null); + + const handleSearch = (value: string) => { + setSearch(value); + if (timer) clearTimeout(timer); + const t = setTimeout(() => setDebouncedSearch(value), 400); + setTimer(t); + }; + + const { data: apps = [], isLoading } = useQuery({ + 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 ( +
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+
+
+

All Applications

+

+ {apps.length} application{apps.length !== 1 ? 's' : ''} + {debouncedSearch && ` matching "${debouncedSearch}"`} +

+
+
+ + {/* Search */} +
+ 🔍 + handleSearch(e.target.value)} + className="input pl-10 w-full" + /> + {search && ( + + )} +
+ + {apps.length === 0 ? ( +
+
📦
+ {debouncedSearch ? ( + <> +

No applications found

+

+ No results for "{debouncedSearch}". Try a different search. +

+ + ) : ( + <> +

No applications yet

+

No applications have been created by any user.

+ + )} +
+ ) : ( + <> + {/* Desktop Table */} +
+ + + + + + + + + + + + + + {apps.map((app) => { + const latestStatus = app.deployments?.[0]?.status || 'pending'; + return ( + + + + + + + + + + ); + })} + +
ApplicationOwnerRuntimeDatabaseStatusReplicasActions
+ +
+ {app.runtime === 'nodejs' ? '🟩' : '🟧'} +
+
+ + {app.name} + + {app.id.slice(0, 8)} +
+ +
+ {app.user ? ( +
+

+ {app.user.firstName} {app.user.lastName} +

+

{app.user.email}

+

{app.userId.slice(0, 8)}

+
+ ) : ( + {app.userId.slice(0, 8)} + )} +
{app.runtime}{app.databaseType} + + {latestStatus} + + {app.replicas} +
+ + View + + +
+
+
+ + {/* Mobile Cards */} +
+ {apps.map((app) => { + const latestStatus = app.deployments?.[0]?.status || 'pending'; + return ( + +
+
+
+ {app.runtime === 'nodejs' ? '🟩' : '🟧'} +
+
+

{app.name}

+

{app.runtime}

+
+
+ + {latestStatus} + +
+ {app.user && ( +
+ 👤 {app.user.firstName} {app.user.lastName} + + {app.user.email} +
+ )} +
+ 💾 {app.databaseType} + 📦 {app.replicas} replica{app.replicas > 1 ? 's' : ''} +
+ + ); + })} +
+ + )} +
+ ); +} diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index b820bfe..33dbcde 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -16,6 +16,7 @@ const userNavItems = [ const adminNavItems = [ { 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/pools', label: 'Cluster Pools', icon: '⚖️' }, { href: '/dashboard/admin/tickets', label: 'All Tickets', icon: '📋' }, @@ -23,6 +24,7 @@ const adminNavItems = [ const technicalNavItems = [ { 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/pools', label: 'Cluster Pools', icon: '⚖️' }, { href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: '🔧' }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a5abed1..00c3d55 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -27,6 +27,7 @@ export interface Application { replicas: number; port: number; userId: string; + user?: User; clusterId?: string; poolId?: string; latestImageTag?: string;