mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 13:32:06 +07:00
feat: implement dual CDN strategy and archive management UI
This commit is contained in:
23
build_log.txt
Normal file
23
build_log.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
> trustlab-web@1.0.0 build
|
||||||
|
> next build
|
||||||
|
|
||||||
|
Γû▓ Next.js 16.0.10 (Turbopack)
|
||||||
|
- Environments: .env.local
|
||||||
|
|
||||||
|
Creating an optimized production build ...
|
||||||
|
Γ£ô Compiled successfully in 13.8s
|
||||||
|
Running TypeScript ...
|
||||||
|
Failed to compile.
|
||||||
|
|
||||||
|
./src/components/admin/ArchiveManagementTable.tsx:120:36
|
||||||
|
Type error: Type '"xs"' is not assignable to type 'BadgeSize | undefined'.
|
||||||
|
|
||||||
|
118 | </Badge>
|
||||||
|
119 | {cert.is_latest && (
|
||||||
|
> 120 | <Badge size="xs" color="indigo">LIVE - LATEST</Badge>
|
||||||
|
| ^
|
||||||
|
121 | )}
|
||||||
|
122 | </div>
|
||||||
|
123 | </TableCell>
|
||||||
|
Next.js build worker exited with code: 1 and signal: null
|
||||||
@@ -12,6 +12,8 @@ import { useToast } from "@/context/ToastContext";
|
|||||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||||
import PageLoader from "@/components/ui/PageLoader";
|
import PageLoader from "@/components/ui/PageLoader";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import CdnManagementCard from "@/components/admin/CdnManagementCard";
|
||||||
|
import ArchiveManagementTable from "@/components/admin/ArchiveManagementTable";
|
||||||
|
|
||||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export default function RootCaManagementClient() {
|
|||||||
const { data, error, mutate, isLoading } = useSWR("/api/admin/ca-certificates", fetcher);
|
const { data, error, mutate, isLoading } = useSWR("/api/admin/ca-certificates", fetcher);
|
||||||
const [isRenewing, setIsRenewing] = useState(false);
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [isPromoting, setIsPromoting] = useState(false);
|
||||||
const [confirmRenewUuid, setConfirmRenewUuid] = useState<string | null>(null);
|
const [confirmRenewUuid, setConfirmRenewUuid] = useState<string | null>(null);
|
||||||
|
|
||||||
// Redirect if not admin or owner (double security, backend also checks)
|
// Redirect if not admin or owner (double security, backend also checks)
|
||||||
@@ -79,24 +82,24 @@ export default function RootCaManagementClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncCdn = async (type: 'all' | 'crt' | 'installers' | 'bundles') => {
|
const handleSyncCdn = async (type: 'all' | 'crt' | 'installers' | 'bundles', mode: 'latest' | 'archive' | 'both' = 'both') => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
try {
|
||||||
let endpoint = "/api/admin/ca-certificates/sync-cdn";
|
let endpoint = "/api/admin/ca-certificates/sync-cdn";
|
||||||
let msg = "Full Sync successful";
|
let msg = `Sync successful (Mode: ${mode})`;
|
||||||
|
|
||||||
if (type === 'crt') {
|
if (type === 'crt') {
|
||||||
endpoint = "/api/admin/ca-certificates/sync-crt";
|
endpoint = "/api/admin/ca-certificates/sync-crt";
|
||||||
msg = "CRT Files Sync successful";
|
msg = `CRT Files Sync successful (${mode})`;
|
||||||
} else if (type === 'installers') {
|
} else if (type === 'installers') {
|
||||||
endpoint = "/api/admin/ca-certificates/sync-installers";
|
endpoint = "/api/admin/ca-certificates/sync-installers";
|
||||||
msg = "Individual Installers Sync successful";
|
msg = `Individual Installers Sync successful (${mode})`;
|
||||||
} else if (type === 'bundles') {
|
} else if (type === 'bundles') {
|
||||||
endpoint = "/api/admin/ca-certificates/sync-bundles";
|
endpoint = "/api/admin/ca-certificates/sync-bundles";
|
||||||
msg = "Global Bundles Sync successful";
|
msg = "Global Bundles Sync successful";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(endpoint);
|
const response = await axios.post(endpoint, { mode });
|
||||||
addToast(response.data.message || msg, "success");
|
addToast(response.data.message || msg, "success");
|
||||||
mutate();
|
mutate();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -107,6 +110,20 @@ export default function RootCaManagementClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePromote = async (uuid: string) => {
|
||||||
|
setIsPromoting(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/admin/ca-certificates/${uuid}/promote`);
|
||||||
|
addToast(response.data.message || "Promoted successfully", "success");
|
||||||
|
mutate();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
addToast(err.response?.data?.message || "Promotion failed", "error");
|
||||||
|
} finally {
|
||||||
|
setIsPromoting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
||||||
|
|
||||||
const certificates = data?.data || [];
|
const certificates = data?.data || [];
|
||||||
@@ -115,47 +132,24 @@ export default function RootCaManagementClient() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
|
||||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<SyncButton
|
|
||||||
onClick={() => handleSyncCdn('crt')}
|
|
||||||
disabled={isSyncing || isLoading}
|
|
||||||
isLoading={isSyncing}
|
|
||||||
label="Sync CRT Only"
|
|
||||||
variant="gray"
|
|
||||||
/>
|
|
||||||
<SyncButton
|
|
||||||
onClick={() => handleSyncCdn('installers')}
|
|
||||||
disabled={isSyncing || isLoading}
|
|
||||||
isLoading={isSyncing}
|
|
||||||
label="Sync Installers Only"
|
|
||||||
variant="blue"
|
|
||||||
/>
|
|
||||||
<SyncButton
|
|
||||||
onClick={() => handleSyncCdn('bundles')}
|
|
||||||
disabled={isSyncing || isLoading}
|
|
||||||
isLoading={isSyncing}
|
|
||||||
label="Sync Full Bundles"
|
|
||||||
variant="purple"
|
|
||||||
/>
|
|
||||||
<SyncButton
|
|
||||||
onClick={() => handleSyncCdn('all')}
|
|
||||||
disabled={isSyncing || isLoading}
|
|
||||||
isLoading={isSyncing}
|
|
||||||
label="Sync All to CDN"
|
|
||||||
variant="indigo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column: Management & Renew Table */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<CdnManagementCard
|
||||||
|
onSync={handleSyncCdn}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<ComponentCard
|
<ComponentCard
|
||||||
title={t("card_title")}
|
title={t("card_title")}
|
||||||
desc={t("card_desc")}
|
desc={t("card_desc")}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<RootCaTable
|
<RootCaTable
|
||||||
certificates={certificates}
|
certificates={certificates.filter((c: any) => c.is_latest || certificates.length === 1)}
|
||||||
onRenew={setConfirmRenewUuid}
|
onRenew={setConfirmRenewUuid}
|
||||||
isRenewing={isRenewing}
|
isRenewing={isRenewing}
|
||||||
/>
|
/>
|
||||||
@@ -178,6 +172,21 @@ export default function RootCaManagementClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Archives */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<ComponentCard
|
||||||
|
title="Version Archives"
|
||||||
|
desc="Browse historical versions and promote them back to Latest if needed."
|
||||||
|
>
|
||||||
|
<ArchiveManagementTable
|
||||||
|
certificates={certificates}
|
||||||
|
onPromote={handlePromote}
|
||||||
|
isPromoting={isPromoting}
|
||||||
|
/>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={confirmRenewUuid !== null}
|
isOpen={confirmRenewUuid !== null}
|
||||||
onClose={() => setConfirmRenewUuid(null)}
|
onClose={() => setConfirmRenewUuid(null)}
|
||||||
|
|||||||
154
src/components/admin/ArchiveManagementTable.tsx
Normal file
154
src/components/admin/ArchiveManagementTable.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import Badge from "../ui/badge/Badge";
|
||||||
|
import Button from "../ui/button/Button";
|
||||||
|
import InputField from "../form/input/InputField";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface CaCertificate {
|
||||||
|
uuid: string;
|
||||||
|
ca_type: string;
|
||||||
|
common_name: string;
|
||||||
|
serial_number: string;
|
||||||
|
valid_from: string;
|
||||||
|
valid_to: string;
|
||||||
|
status: string;
|
||||||
|
is_latest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveManagementTableProps {
|
||||||
|
certificates: CaCertificate[];
|
||||||
|
onPromote: (uuid: string) => void;
|
||||||
|
isPromoting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArchiveManagementTable({
|
||||||
|
certificates,
|
||||||
|
onPromote,
|
||||||
|
isPromoting,
|
||||||
|
}: ArchiveManagementTableProps) {
|
||||||
|
const t = useTranslations("RootCA");
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const formatType = (type: string) => {
|
||||||
|
return type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCertificates = useMemo(() => {
|
||||||
|
return certificates.filter((cert) => {
|
||||||
|
const search = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
cert.common_name.toLowerCase().includes(search) ||
|
||||||
|
cert.ca_type.toLowerCase().includes(search) ||
|
||||||
|
cert.serial_number.toLowerCase().includes(search) ||
|
||||||
|
cert.uuid.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [certificates, searchTerm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Table Header Controls */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-1">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 dark:text-white/90">CDN Archive History</h4>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<InputField
|
||||||
|
placeholder="Search version/CN..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="!py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||||
|
<TableRow>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
Version (UUID)
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
{t("common_name_th")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
{t("validity_th")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||||
|
Status
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||||
|
{t("actions_th")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||||
|
{filteredCertificates.map((cert) => (
|
||||||
|
<TableRow key={cert.uuid} className={`hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors ${cert.is_latest ? 'bg-blue-50/30 dark:bg-blue-900/10' : ''}`}>
|
||||||
|
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{cert.uuid}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start text-theme-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-gray-800 dark:text-white/90">{cert.common_name}</span>
|
||||||
|
<span className="text-[10px] text-gray-400">{formatType(cert.ca_type)}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start text-theme-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{new Date(cert.valid_from).toLocaleDateString()}</span>
|
||||||
|
<span className="text-gray-400 text-[10px]">{t("validity_to_label")}</span>
|
||||||
|
<span>{new Date(cert.valid_to).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Badge size="sm" color={cert.status === "valid" ? "success" : "error"}>
|
||||||
|
{cert.status === "valid" ? t("status_valid") : t("status_expired")}
|
||||||
|
</Badge>
|
||||||
|
{cert.is_latest && (
|
||||||
|
<Badge size="sm" color="info">LIVE - LATEST</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-center">
|
||||||
|
{!cert.is_latest && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onPromote(cert.uuid)}
|
||||||
|
loading={isPromoting}
|
||||||
|
>
|
||||||
|
Set as Latest
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{cert.is_latest && (
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">Currently Public</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{filteredCertificates.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="px-5 py-10 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No versions found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/admin/CdnManagementCard.tsx
Normal file
96
src/components/admin/CdnManagementCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface CdnManagementCardProps {
|
||||||
|
onSync: (type: "all" | "crt" | "installers" | "bundles", mode: "latest" | "archive" | "both") => void;
|
||||||
|
isSyncing: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CdnManagementCard({ onSync, isSyncing, disabled }: CdnManagementCardProps) {
|
||||||
|
const t = useTranslations("RootCA");
|
||||||
|
|
||||||
|
const SyncButton = ({
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
desc,
|
||||||
|
variant = "blue"
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
variant?: "blue" | "purple" | "indigo" | "gray"
|
||||||
|
}) => {
|
||||||
|
const variantClasses = {
|
||||||
|
blue: "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400",
|
||||||
|
purple: "bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400",
|
||||||
|
indigo: "bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400",
|
||||||
|
gray: "bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-4 border border-gray-100 dark:border-white/[0.05] rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-gray-800 dark:text-white/90">{label}</span>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 max-w-[250px]">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || isSyncing}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-2 ${variantClasses[variant]} text-white text-xs font-medium rounded-lg transition-all shadow-sm active:scale-95 whitespace-nowrap`}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<svg className="animate-spin h-3.5 w-3.5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isSyncing ? "Syncing..." : "Sync Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComponentCard
|
||||||
|
title="CDN Management"
|
||||||
|
desc="Control how your SSL assets are distributed and archived on the Global CDN."
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SyncButton
|
||||||
|
label="Sync All (Latest & Archive)"
|
||||||
|
desc="The safest option. Updates human-friendly URLs and creates permanent archives for all certs."
|
||||||
|
onClick={() => onSync("all", "both")}
|
||||||
|
variant="indigo"
|
||||||
|
/>
|
||||||
|
<SyncButton
|
||||||
|
label="Update Public Link (Latest Only)"
|
||||||
|
desc="Only updates 'Clean URLs'. Useful for quickly updating current public certificates."
|
||||||
|
onClick={() => onSync("all", "latest")}
|
||||||
|
variant="blue"
|
||||||
|
/>
|
||||||
|
<SyncButton
|
||||||
|
label="Create Archives (Archive Only)"
|
||||||
|
desc="Only uploads to versioned folders. Won't affect current public download links."
|
||||||
|
onClick={() => onSync("all", "archive")}
|
||||||
|
variant="purple"
|
||||||
|
/>
|
||||||
|
<SyncButton
|
||||||
|
label="Sync Global Bundles"
|
||||||
|
desc="Regenerate all-in-one installers using currently promoted 'Latest' versions."
|
||||||
|
onClick={() => onSync("bundles", "latest")}
|
||||||
|
variant="gray"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -658,7 +658,7 @@
|
|||||||
"validity_th": "Validity Period",
|
"validity_th": "Validity Period",
|
||||||
"status_th": "Status",
|
"status_th": "Status",
|
||||||
"actions_th": "Actions",
|
"actions_th": "Actions",
|
||||||
"to": "to",
|
"validity_to_label": "to",
|
||||||
"status_valid": "Valid",
|
"status_valid": "Valid",
|
||||||
"status_expired": "Expired",
|
"status_expired": "Expired",
|
||||||
"renew_button": "Renew (10Y)",
|
"renew_button": "Renew (10Y)",
|
||||||
|
|||||||
@@ -658,7 +658,7 @@
|
|||||||
"validity_th": "Masa Berlaku",
|
"validity_th": "Masa Berlaku",
|
||||||
"status_th": "Status",
|
"status_th": "Status",
|
||||||
"actions_th": "Aksi",
|
"actions_th": "Aksi",
|
||||||
"to": "ke",
|
"validity_to_label": "ke",
|
||||||
"status_valid": "Valid",
|
"status_valid": "Valid",
|
||||||
"status_expired": "Kedaluwarsa",
|
"status_expired": "Kedaluwarsa",
|
||||||
"renew_button": "Perbarui (10T)",
|
"renew_button": "Perbarui (10T)",
|
||||||
|
|||||||
Reference in New Issue
Block a user