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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname(fileURLToPath(import.meta.url)),
|
||||
});
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
ignores: ['.next/**', 'node_modules/**'],
|
||||
},
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
];
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Generated
+1457
-1
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
@@ -36,8 +39,10 @@
|
||||
"@types/three": "^0.184.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { Application, Deployment, ResourceUsage } from '@/types';
|
||||
|
||||
/** Core data queries for the application detail page. */
|
||||
export function useAppQueries(appId: string, enabled = true) {
|
||||
const appQuery = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
enabled: enabled && !!appId,
|
||||
});
|
||||
|
||||
const deploymentsQuery = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||
enabled: enabled && !!appId,
|
||||
});
|
||||
|
||||
const usageQuery = useQuery<ResourceUsage>({
|
||||
queryKey: ['resource-usage', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/resources/usage`).then((r) => r.data),
|
||||
enabled: enabled && !!appId && appQuery.data?.lifecycleStatus === 'active',
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const walletQuery = useQuery<{ balance: number }>({
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
|
||||
return { appQuery, deploymentsQuery, usageQuery, walletQuery };
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
@@ -250,7 +251,7 @@ export default function AppDetailPage() {
|
||||
|
||||
// ─── Billing & Renewal ──────────────────────────────
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -271,7 +272,7 @@ export default function AppDetailPage() {
|
||||
onSuccess: (res) => {
|
||||
notify.success(res.data.message || 'Application renewed successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowRenewalModal(false);
|
||||
setRenewCoupon('');
|
||||
},
|
||||
@@ -646,7 +647,7 @@ export default function AppDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
@@ -2134,22 +2135,22 @@ export default function AppDetailPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-1 font-medium">{ad.name}</th>
|
||||
<th className="pb-1 font-medium">{ad.status}</th>
|
||||
<th className="pb-1 font-medium">{ad.ready}</th>
|
||||
<th className="pb-1 font-medium">R</th>
|
||||
<tr className="text-start text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-1 font-medium text-start">{ad.name}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.status}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.ready}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.restarts}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{w.pods.map((pod) => (
|
||||
<tr key={pod.name} className="text-gray-700">
|
||||
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
|
||||
<td className="py-1">
|
||||
<td className="py-1 text-start font-mono truncate max-w-[140px]" dir="ltr" title={pod.name}>{pod.name}</td>
|
||||
<td className="py-1 text-start">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
|
||||
</td>
|
||||
<td className="py-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||
<td className="py-1">{pod.restarts}</td>
|
||||
<td className="py-1 text-start">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||
<td className="py-1 text-start">{pod.restarts}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -15,29 +15,19 @@ import {
|
||||
} 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: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
const statusColors = deploymentStatusBadgeClass;
|
||||
const lifecycleColors = lifecycleStatusClass;
|
||||
|
||||
type AppsDict = Dictionary['dashboard']['apps'];
|
||||
|
||||
function statusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
return deploymentStatusLabel(status, t);
|
||||
}
|
||||
|
||||
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { Select } from '@/components/ui/select';
|
||||
@@ -402,7 +404,7 @@ export default function DeployPage() {
|
||||
|
||||
// Wallet balance for the review step payment
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step >= 2,
|
||||
});
|
||||
@@ -492,7 +494,7 @@ export default function DeployPage() {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/deploy', locale),
|
||||
});
|
||||
|
||||
// In production, redirect to gw.gatewayUrl
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
||||
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
||||
@@ -44,7 +46,7 @@ export default function InvoicesPage() {
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -71,8 +73,8 @@ export default function InvoicesPage() {
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
};
|
||||
|
||||
const verifyGatewayMutation = useMutation({
|
||||
@@ -99,7 +101,7 @@ export default function InvoicesPage() {
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
|
||||
const callbackUrl = localizedCallbackUrl('/dashboard/invoices', locale);
|
||||
const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl });
|
||||
if (data.gatewayUrl && data.gatewayAmount > 0) {
|
||||
window.location.href = data.gatewayUrl;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
@@ -151,7 +152,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
// Fetch wallet balance for all authenticated users (shown in header)
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60000,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="min-h-[40vh] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
@@ -103,7 +104,7 @@ export default function ManagedServiceDetailPage() {
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
@@ -134,7 +136,7 @@ export default function NewManagedServicePage() {
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 2,
|
||||
});
|
||||
@@ -236,7 +238,7 @@ export default function NewManagedServicePage() {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
|
||||
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/services/new'),
|
||||
});
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
|
||||
@@ -16,13 +16,9 @@ import {
|
||||
} from '@/components/deleting-overlay';
|
||||
import { filterManagedServices } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { lifecycleStatusClass } from '@/lib/app-list-utils';
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
const lifecycleColors = lifecycleStatusClass;
|
||||
|
||||
type AppsDict = Dictionary['dashboard']['apps'];
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import { Link } from '@/i18n/Link';
|
||||
@@ -32,12 +34,12 @@ export default function WalletPage() {
|
||||
const [showCharge, setShowCharge] = useState(false);
|
||||
|
||||
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||||
queryKey: ['wallet-transactions'],
|
||||
queryKey: queryKeys.walletTransactions,
|
||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -46,8 +48,8 @@ export default function WalletPage() {
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
notify.success(w.chargedSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
@@ -61,7 +63,7 @@ export default function WalletPage() {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: w.topUpGatewayDesc,
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/wallet', locale),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
@@ -72,8 +74,8 @@ export default function WalletPage() {
|
||||
trackingCode: data.trackingCode,
|
||||
amount: Number(chargeAmount),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
notify.success(w.paymentSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
@@ -114,7 +115,7 @@ export function ManagedServiceResourcesPanel({
|
||||
const isDatabase = app.productType === 'managed_database';
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -181,7 +182,7 @@ export function ManagedServiceResourcesPanel({
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
|
||||
@@ -29,7 +29,13 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
type BadgeTone = 'gray' | 'green' | 'yellow' | 'red' | 'blue' | 'purple';
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
gray: 'badge-gray',
|
||||
green: 'badge-green',
|
||||
yellow: 'badge-yellow',
|
||||
red: 'badge-red',
|
||||
blue: 'badge-blue',
|
||||
purple: 'badge-purple',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = 'gray',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: BadgeTone;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <span className={clsx('badge', toneClass[tone], className)}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
|
||||
const variantClass: Record<ButtonVariant, string> = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
|
||||
return (
|
||||
<button type="button" className={clsx(variantClass[variant], className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={clsx('card', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1482,7 +1482,7 @@ const en: Dictionary = {
|
||||
platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide',
|
||||
removeCustomDomain: 'Remove Custom Domain',
|
||||
resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show',
|
||||
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready',
|
||||
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready', restarts: 'Restarts',
|
||||
noMetrics: 'No metrics yet', storageUsage: 'Storage Usage', loadingStorageMetrics: 'Loading storage metrics...',
|
||||
noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable',
|
||||
used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...',
|
||||
|
||||
@@ -1488,7 +1488,7 @@ const fa = {
|
||||
removeCustomDomain: 'حذف دامنهٔ اختصاصی',
|
||||
// resources
|
||||
resourcesScaling: 'منابع و مقیاسبندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش',
|
||||
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده',
|
||||
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده', restarts: 'ریاستارت',
|
||||
noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریکهای فضای ذخیره…',
|
||||
noStorageData: 'دادهی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست',
|
||||
used: 'مصرفشده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { loginPath } from '@/lib/locale-url';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -55,7 +56,7 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = loginPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
|
||||
export const deploymentStatusBadgeClass: Record<string, string> = {
|
||||
pending: 'badge-gray',
|
||||
building: 'badge-yellow',
|
||||
deploying: 'badge-blue',
|
||||
running: 'badge-green',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
export const lifecycleStatusClass: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
export function deploymentStatusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { classifyApiError } from './errors';
|
||||
|
||||
describe('classifyApiError', () => {
|
||||
it('classifies network errors', () => {
|
||||
const err = new axios.AxiosError('Network Error');
|
||||
expect(classifyApiError(err).kind).toBe('network');
|
||||
});
|
||||
|
||||
it('classifies 403 forbidden', () => {
|
||||
const err = new axios.AxiosError('Forbidden', undefined, undefined, undefined, {
|
||||
status: 403,
|
||||
data: { message: 'Forbidden' },
|
||||
statusText: 'Forbidden',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
});
|
||||
expect(classifyApiError(err).kind).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('classifies generic errors', () => {
|
||||
expect(classifyApiError(new Error('oops')).kind).toBe('generic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatRemainingForLocale,
|
||||
isPersianLocale,
|
||||
parseCpuToMillicores,
|
||||
} from './format-utils';
|
||||
|
||||
describe('isPersianLocale', () => {
|
||||
it('matches fa-IR and legacy fa', () => {
|
||||
expect(isPersianLocale('fa-IR')).toBe(true);
|
||||
expect(isPersianLocale('fa')).toBe(true);
|
||||
expect(isPersianLocale('en-US')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRemainingForLocale', () => {
|
||||
it('uses Persian formatting for fa-IR', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000 + 3600000);
|
||||
const result = formatRemainingForLocale(future, 'fa-IR');
|
||||
expect(result).toMatch(/روز/);
|
||||
});
|
||||
|
||||
it('uses English formatting for en-US', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000);
|
||||
const result = formatRemainingForLocale(future, 'en-US');
|
||||
expect(result).toMatch(/\d+d/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCpuToMillicores', () => {
|
||||
it('parses millicores and cores', () => {
|
||||
expect(parseCpuToMillicores('500m')).toBe(500);
|
||||
expect(parseCpuToMillicores('1')).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,10 @@ export function parseMemoryToMi(mem: string): number {
|
||||
return parseFloat(mem);
|
||||
}
|
||||
|
||||
/** Human-readable time left (days, hours, minutes). */
|
||||
/** True when locale is Persian (fa-IR or legacy "fa"). */
|
||||
export function isPersianLocale(locale: string): boolean {
|
||||
return locale === 'fa' || locale.startsWith('fa-');
|
||||
}
|
||||
export function formatRemainingDurationMs(remainingMs: number): string {
|
||||
const ms = Math.max(0, remainingMs);
|
||||
const days = Math.floor(ms / 86400000);
|
||||
@@ -54,7 +57,7 @@ export function formatRemainingForLocale(
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const remainingMs = date.getTime() - Date.now();
|
||||
return locale === 'fa'
|
||||
return isPersianLocale(locale)
|
||||
? formatRemainingDurationFa(remainingMs)
|
||||
: formatRemainingDurationMs(remainingMs);
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function formatExpiresAtLocal(
|
||||
): string {
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
if (locale === 'fa') {
|
||||
if (isPersianLocale(locale || '')) {
|
||||
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
|
||||
// of the runtime's ICU pattern data.
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getClientLocale, localizedPath, loginPath } from './locale-url';
|
||||
import { defaultLocale, LOCALE_COOKIE } from '@/i18n/config';
|
||||
|
||||
describe('locale-url', () => {
|
||||
const originalDocument = global.document;
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom-less: stub document.cookie
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: { cookie: '' },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: originalDocument,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default locale when cookie is missing', () => {
|
||||
expect(getClientLocale()).toBe(defaultLocale);
|
||||
});
|
||||
|
||||
it('reads locale from NEXT_LOCALE cookie', () => {
|
||||
document.cookie = `${LOCALE_COOKIE}=en-US`;
|
||||
expect(getClientLocale()).toBe('en-US');
|
||||
});
|
||||
|
||||
it('builds localized paths', () => {
|
||||
expect(localizedPath('/dashboard/wallet', 'fa-IR')).toBe('/fa-IR/dashboard/wallet');
|
||||
expect(loginPath('en-US')).toBe('/en-US/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config';
|
||||
|
||||
/** Read the active locale from cookie (client-only). */
|
||||
export function getClientLocale(): Locale {
|
||||
if (typeof document === 'undefined') {
|
||||
return defaultLocale;
|
||||
}
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`));
|
||||
const value = match ? decodeURIComponent(match[1]) : '';
|
||||
return isLocale(value) ? value : defaultLocale;
|
||||
}
|
||||
|
||||
/** Build an absolute URL with the locale prefix, e.g. /fa-IR/dashboard/wallet */
|
||||
export function localizedPath(path: string, locale?: Locale): string {
|
||||
const loc = locale ?? getClientLocale();
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `/${loc}${normalized}`;
|
||||
}
|
||||
|
||||
/** Absolute origin + localized path for payment gateway callbacks. */
|
||||
export function localizedCallbackUrl(path: string, locale?: Locale): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return localizedPath(path, locale);
|
||||
}
|
||||
return `${window.location.origin}${localizedPath(path, locale)}`;
|
||||
}
|
||||
|
||||
/** Localized login path for auth redirects. */
|
||||
export function loginPath(locale?: Locale): string {
|
||||
return localizedPath('/login', locale);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Central React Query keys — keep invalidation consistent across pages. */
|
||||
export const queryKeys = {
|
||||
walletBalance: ['wallet-balance'] as const,
|
||||
walletTransactions: ['wallet-transactions'] as const,
|
||||
applications: (productType?: string) =>
|
||||
productType ? (['applications', productType] as const) : (['applications'] as const),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user