837f0fa63f
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>
258 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|