Files
cloud-host/frontend/src/app/[lang]/dashboard/apps/page.tsx
T
keyhan 837f0fa63f Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production
secret validation, health probes, Redis-backed build progress, GitHub
Actions CI, expanded tests, billing/k8s refactors, and ops runbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 20:59:49 +03:30

258 lines
13 KiB
TypeScript

'use client';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@/i18n/Link';
import { useT } from '@/i18n/I18nProvider';
import type { Dictionary } from '@/i18n/dictionaries/fa';
import api from '@/lib/api';
import type { Application } from '@/types';
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { DeleteButtonLabel } from '@/components/delete-button-label';
import {
DeletingTableRowOverlay,
deletingRowContentClass,
} from '@/components/deleting-overlay';
import { filterApplications } from '@/lib/product-type';
import { useApplicationDelete } from '@/lib/use-application-delete';
import {
deploymentStatusBadgeClass,
deploymentStatusLabel,
lifecycleStatusClass,
} from '@/lib/app-list-utils';
const statusColors = deploymentStatusBadgeClass;
const lifecycleColors = lifecycleStatusClass;
type AppsDict = Dictionary['dashboard']['apps'];
function statusLabel(status: string, t: Dictionary): string {
return deploymentStatusLabel(status, t);
}
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
return (a.lifecycle as Record<string, string>)[lifecycle] ?? lifecycle;
}
function formatExpiry(expiresAt: string | undefined, a: AppsDict): { text: string; urgent: boolean } {
if (!expiresAt) return { text: '—', urgent: false };
const now = new Date();
const exp = new Date(expiresAt);
const diff = exp.getTime() - now.getTime();
if (diff <= 0) return { text: a.expired, urgent: true };
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0)
return {
text: a.daysHours.replace('{d}', String(days)).replace('{h}', String(hours % 24)),
urgent: days < 3,
};
if (hours > 0) return { text: a.hours.replace('{h}', String(hours)), urgent: hours < 6 };
const mins = Math.floor(diff / 60000);
return { text: a.mins.replace('{m}', String(mins)), urgent: true };
}
export default function AppsPage() {
const t = useT();
const a = t.dashboard.apps;
const confirm = useConfirm();
const { data: appsRaw = [], isLoading } = useQuery<Application[]>({
queryKey: ['applications', 'application'],
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
});
const apps = filterApplications(appsRaw);
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
invalidateKeys: [['applications', 'application'], ['applications']],
successMessage: a.deleted,
successWithCreditMessage: a.deletedWithCredit,
});
if (isLoading) {
return (
<div className="space-y-6">
<div className="page-header">
<div className="skeleton h-8 w-48" />
<div className="skeleton h-10 w-40 rounded-xl" />
</div>
<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">{a.title}</h1>
<p className="page-subtitle">{a.count.replace('{n}', String(apps.length))}</p>
</div>
<Link href="/dashboard/deploy" className="btn-primary">
<Rocket className="w-4 h-4 mr-1 rtl:mr-0 rtl:ml-1 inline" /> {a.newApplication}
</Link>
</div>
{apps.length === 0 ? (
<div className="card text-center py-16">
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-600 text-lg font-medium">{a.noApplications}</p>
<p className="text-gray-400 mt-1 text-sm">{a.emptyDesc}</p>
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
<Rocket className="w-4 h-4" /> {a.deployFirst}
</Link>
</div>
) : (
<>
<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 rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider min-w-[300px]">{a.colApplication}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colRuntime}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colStatus}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colServiceStatus}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colExpiry}</th>
<th className="px-6 py-3.5 text-right rtl:text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt, a);
const rowDeleting = isDeleting(app.id);
return (
<tr
key={app.id}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
>
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group min-w-[300px]">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</div>
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
</Link>
</td>
<td className={`px-6 py-4 text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>{app.runtime}</td>
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{statusLabel(latestStatus, t)}</span>
</td>
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
{lifecycleLabel(lifecycle, a)}
</span>
</td>
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.planExpiresAt ? (
<span className={`inline-flex items-center gap-1 text-xs font-medium ${expiry.urgent ? 'text-red-600' : 'text-gray-600'}`}>
<Clock className="w-3 h-3" />
{expiry.text}
</span>
) : (
<span className="text-xs text-gray-400">{a.noPlan}</span>
)}
</td>
<td className={`px-6 py-4 text-right rtl:text-left ${rowDeleting ? deletingRowContentClass : ''}`}>
<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">
{a.view}
</Link>
<button
type="button"
disabled={isAnyDeleting}
onClick={async () => {
const ok = await confirm({
title: a.deleteTitle,
message: a.deleteMessage.replace('{name}', app.name),
confirmText: t.common.delete,
variant: 'danger',
});
if (ok) deleteApplication(app.id);
}}
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
<DeleteButtonLabel loading={isDeleting(app.id)} />
</button>
</div>
</td>
{rowDeleting && (
<DeletingTableRowOverlay
colSpan={6}
name={app.name}
kind="application"
/>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="md:hidden grid gap-3">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt, a);
return (
<Link
key={app.id}
href={`/dashboard/apps/${app.id}`}
className={`card-hover ${lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''}`}
>
<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">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</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'}`}>{statusLabel(latestStatus, t)}</span>
</div>
<div className="flex items-center gap-3 mb-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
{lifecycleLabel(lifecycle, a)}
</span>
{app.planExpiresAt && (
<span className={`inline-flex items-center gap-1 text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
<Clock className="w-3 h-3" />
{expiry.text}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Database className="w-3 h-3" /> {app.databaseType}
</span>
<span className="flex items-center gap-1">
<Box className="w-3 h-3" /> {app.replicas} {app.replicas > 1 ? t.dashboard.home.replicaPlural : t.dashboard.home.replica}
</span>
</div>
</Link>
);
})}
</div>
</>
)}
</div>
);
}