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:
keyhan
2026-04-06 14:43:46 +03:30
parent a64f8407b9
commit 8243ac7df2
5 changed files with 281 additions and 15 deletions
@@ -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 &quot;{debouncedSearch}&quot;. 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>
);
}
+2
View File
@@ -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: '🔧' },
+1
View File
@@ -27,6 +27,7 @@ export interface Application {
replicas: number;
port: number;
userId: string;
user?: User;
clusterId?: string;
poolId?: string;
latestImageTag?: string;