diff --git a/app/(dashboard)/site-replication/page.tsx b/app/(dashboard)/site-replication/page.tsx index 77ce88e5..21598364 100644 --- a/app/(dashboard)/site-replication/page.tsx +++ b/app/(dashboard)/site-replication/page.tsx @@ -1,12 +1,13 @@ "use client" import * as React from "react" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { RiAddLine, RiArrowLeftRightLine, RiDeleteBin5Line, + RiFileList3Line, RiMore2Line, RiRefreshLine, RiRestartLine, @@ -36,16 +37,29 @@ import { useDataTable } from "@/hooks/use-data-table" import { usePermissions } from "@/hooks/use-permissions" import { useSiteReplication } from "@/hooks/use-site-replication" import { useSystem } from "@/hooks/use-system" -import { formatDateTime, niceBytes } from "@/lib/functions" +import { formatDateTime, formatDuration, formatInteger, niceBytes } from "@/lib/functions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" +import { + assessSiteReplicationHealth, + siteReplicationFailures, + type SiteReplicationBucketMismatch, + type SiteReplicationConnection, + type SiteReplicationDriftEntity, + type SiteReplicationDriftItem, + type SiteReplicationHealthIssue, + type SiteReplicationHealthLevel, +} from "@/lib/site-replication-health" import { extractServerDeploymentId, resolveSiteReplicationLocalSite } from "@/lib/site-replication-local-site" import { isHttpsSiteReplicationEndpoint } from "@/lib/site-replication-tls" +import { cn } from "@/lib/utils" import type { SiteReplicationInfo, SiteReplicationPeerInfo, SiteReplicationResyncOperation, + SiteReplicationResyncResponse, SiteReplicationStatus, + SiteReplicationVolume, } from "@/types/site-replication" const STATUS_QUERY = { @@ -58,22 +72,178 @@ const STATUS_QUERY = { "ilm-expiry-rules": "true", } +const AUTO_REFRESH_MS = 30_000 +const DRIFT_PREVIEW_LIMIT = 10 + +type Translate = (key: string, options?: Record) => string + function isNotImplementedError(error: unknown) { const message = (error as Error | undefined)?.message ?? "" return /notimplemented|not implemented/i.test(message) } -function formatSyncBadge(t: (key: string) => string, syncState: SiteReplicationPeerInfo["syncState"]) { - switch (syncState) { - case "enable": - return {t("Enabled")} - case "disable": - return {t("Disabled")} - default: - return {t("Unknown")} +// The sync flag is a per-peer setting, not link health, so it must not look +// like a status badge (the connection column carries health). +function formatSyncSetting(t: Translate, syncState: SiteReplicationPeerInfo["syncState"]) { + const label = syncState === "enable" ? t("Enabled") : syncState === "disable" ? t("Disabled") : t("Unknown") + return {label} +} + +const HEALTH_BADGES: Record< + SiteReplicationHealthLevel, + { label: string; variant: "secondary" | "destructive" | "outline"; className?: string } +> = { + healthy: { label: "Healthy", variant: "secondary" }, + degraded: { label: "Needs attention", variant: "outline", className: "border-amber-500/40 bg-amber-500/10" }, + critical: { label: "Unhealthy", variant: "destructive" }, + unknown: { label: "Unknown", variant: "outline" }, +} + +function HealthBadge({ t, level }: { t: Translate; level: SiteReplicationHealthLevel }) { + const badge = HEALTH_BADGES[level] + return ( + + {t(badge.label)} + + ) +} + +function ConnectionCell({ t, connection }: { t: Translate; connection?: SiteReplicationConnection }) { + if (!connection || connection.state === "unknown") { + return {t("Unknown")} + } + if (connection.state === "online") { + return {t("Online")} + } + + return ( +
+ {t("Offline")} + {connection.lastOnline ? ( +

+ {t("Last Online")}: {formatDateTime(connection.lastOnline)} +

+ ) : null} + {connection.downtimeNs > 0 ? ( +

+ {t("Downtime")}: {formatDuration(connection.downtimeNs / 1e9, t)} +

+ ) : null} +
+ ) +} + +const OPERATION_LABELS: Record = { + remove: "Site removal", + "endpoint-refresh": "Endpoint update", + "rotate-svc-acct": "Service account rotation", +} + +function describeIssue(t: Translate, issue: SiteReplicationHealthIssue, siteName: (deploymentId: string) => string) { + switch (issue.kind) { + case "peer-offline": + return t("Site {site} is unreachable, so nothing replicates to it.", { site: siteName(issue.deploymentId) }) + case "pending-operation": { + const { operation, pendingPeers } = issue.operation + const label = OPERATION_LABELS[operation] ? t(OPERATION_LABELS[operation]) : operation + return pendingPeers.length + ? t("{operation} has not finished; waiting for {sites}.", { + operation: label, + sites: pendingPeers.map(siteName).join(", "), + }) + : t("{operation} has not finished.", { operation: label }) + } + case "retry-backlog": + return t("{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.", { + pending: issue.retryStats.pending, + failed: issue.retryStats.failed, + }) + case "recent-failures": + return t("{count} object replication failures in the last hour.", { count: issue.count }) + case "drift": + return t("{count} buckets, IAM entities, or expiry rules differ between sites.", { count: issue.count }) } } +// Server errors can be long debug strings; they are shown clamped, in full on hover. +function issueError(issue: SiteReplicationHealthIssue) { + if (issue.kind === "peer-offline") return issue.error + if (issue.kind === "retry-backlog") return issue.retryStats.lastError || undefined + return undefined +} + +const DRIFT_ENTITY_LABELS: Record = { + bucket: "Bucket", + policy: "Policy", + user: "User", + group: "Group", + ilmExpiryRule: "ILM Expiry Rule", +} + +const DRIFT_MISMATCH_LABELS: Record = { + markedDeleted: "Marked for deletion", + tags: "Tags", + versioning: "Versioning", + objectLock: "Object Lock", + policy: "Bucket Policy", + encryption: "Encryption", + replication: "Replication", + quota: "Quota", + cors: "CORS", + content: "Policy document", +} + +function describeDrift(t: Translate, item: SiteReplicationDriftItem, siteName: (deploymentId: string) => string) { + return [ + item.missingOn.length ? t("Missing on {sites}", { sites: item.missingOn.map(siteName).join(", ") }) : "", + item.mismatches.length + ? t("Differs: {settings}", { + settings: item.mismatches.map((mismatch) => t(DRIFT_MISMATCH_LABELS[mismatch])).join(", "), + }) + : "", + ] + .filter(Boolean) + .join("; ") +} + +function formatVolume(volume: SiteReplicationVolume) { + return `${formatInteger(volume.count)} (${niceBytes(String(volume.bytes))})` +} + +const RESYNC_STATE_LABELS: Record = { + pending: "Waiting", + running: "Running", + canceling: "Canceling", + canceled: "Canceled", + completed: "Done", + failed: "Error", +} + +function describeResync(t: Translate, resync: SiteReplicationResyncResponse) { + if (!resync.resyncId) { + return t("No resync has been started for this site.") + } + + const state = RESYNC_STATE_LABELS[resync.state] ? t(RESYNC_STATE_LABELS[resync.state]) : resync.state + return [ + t( + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.", + { + state, + completed: resync.completedBuckets, + total: resync.totalBuckets, + failed: resync.failedBuckets, + objects: resync.replicatedObjects, + failedObjects: resync.failedObjects, + }, + ), + resync.updatedAt ? `${t("Updated")}: ${formatDateTime(resync.updatedAt)}` : "", + resync.errorDetail, + ] + .filter(Boolean) + .join(" ") +} + function formatBandwidth(t: (key: string) => string, peer: SiteReplicationPeerInfo) { if (!peer.defaultBandwidth.set) { return t("Not configured") @@ -133,67 +303,96 @@ export default function SiteReplicationPage() { const [pendingResyncDeploymentId, setPendingResyncDeploymentId] = useState("") const [pendingRemoveDeploymentId, setPendingRemoveDeploymentId] = useState("") const [pendingIlmToggle, setPendingIlmToggle] = useState(false) + const [updatedAt, setUpdatedAt] = useState(null) + const loadingRef = useRef(false) const canManageSites = isAdmin || hasPermission("admin:SiteReplicationAdd") const canRemoveSites = isAdmin || hasPermission("admin:SiteReplicationRemove") const canResyncSites = isAdmin || hasPermission("admin:SiteReplicationResync") - const loadData = useCallback(async () => { - setLoading(true) - setError(null) - setFeatureUnavailable(false) - setStatusUnavailable(false) - - // The local deployment ID is the only stable way to tell which entry of - // the shared, deployment-ID-sorted site list belongs to this server. Fetch - // it alongside the replication data; a failure here only degrades the - // "current site" identification, so it must not fail the whole page. - const serverDeploymentIdPromise = getSystemInfo(undefined, { suppress403Redirect: true }) - .then(extractServerDeploymentId) - .catch(() => "") + const loadData = useCallback( + async ({ quiet = false }: { quiet?: boolean } = {}) => { + // A quiet refresh never overlaps another load and keeps the current view + // (no spinner) so the periodic poll does not flicker the page. + if (quiet && loadingRef.current) { + return + } + loadingRef.current = true + if (!quiet) { + setLoading(true) + } + setError(null) + setFeatureUnavailable(false) + setStatusUnavailable(false) - try { - let nextInfo: SiteReplicationInfo | null = null - let nextStatus: SiteReplicationStatus | null = null + // The local deployment ID is the only stable way to tell which entry of + // the shared, deployment-ID-sorted site list belongs to this server. Fetch + // it alongside the replication data; a failure here only degrades the + // "current site" identification, so it must not fail the whole page. + const serverDeploymentIdPromise = getSystemInfo(undefined, { suppress403Redirect: true }) + .then(extractServerDeploymentId) + .catch(() => "") try { - nextInfo = await getSiteReplicationInfo() - } catch (loadError) { - if (isNotImplementedError(loadError)) { - setFeatureUnavailable(true) - setInfo(null) - setStatus(null) - return - } + let nextInfo: SiteReplicationInfo | null = null + let nextStatus: SiteReplicationStatus | null = null - throw loadError - } + try { + nextInfo = await getSiteReplicationInfo() + } catch (loadError) { + if (isNotImplementedError(loadError)) { + setFeatureUnavailable(true) + setInfo(null) + setStatus(null) + return + } - try { - nextStatus = await getSiteReplicationStatus(STATUS_QUERY) - } catch (loadError) { - if (isNotImplementedError(loadError)) { - setStatusUnavailable(true) - nextStatus = null - } else { throw loadError } - } - setServerDeploymentId(await serverDeploymentIdPromise) - setInfo(nextInfo) - setStatus(nextStatus) - } catch (loadError) { - setError((loadError as Error).message || t("Load Failed")) - } finally { - setLoading(false) - } - }, [getSiteReplicationInfo, getSiteReplicationStatus, getSystemInfo, t]) + try { + nextStatus = await getSiteReplicationStatus(STATUS_QUERY) + } catch (loadError) { + if (isNotImplementedError(loadError)) { + setStatusUnavailable(true) + nextStatus = null + } else { + throw loadError + } + } + + setServerDeploymentId(await serverDeploymentIdPromise) + setInfo(nextInfo) + setStatus(nextStatus) + setUpdatedAt(new Date()) + } catch (loadError) { + setError((loadError as Error).message || t("Load Failed")) + } finally { + loadingRef.current = false + setLoading(false) + } + }, + [getSiteReplicationInfo, getSiteReplicationStatus, getSystemInfo, t], + ) useEffect(() => { void loadData() }, [loadData]) + const replicationEnabled = !!info?.enabled + useEffect(() => { + if (!replicationEnabled) return + const refresh = () => { + if (document.visibilityState === "visible") void loadData({ quiet: true }) + } + const interval = window.setInterval(refresh, AUTO_REFRESH_MS) + document.addEventListener("visibilitychange", refresh) + return () => { + window.clearInterval(interval) + document.removeEventListener("visibilitychange", refresh) + } + }, [loadData, replicationEnabled]) + const peers = info?.sites ?? [] const ilmExpiryEnabled = peers.some((peer) => peer.replicateIlmExpiry) // Never pick the local site by array or key position: every site returns @@ -207,6 +406,32 @@ export default function SiteReplicationPage() { const localPeer = peers.find((peer) => peer.deploymentId === localDeploymentId) ?? null const localSummary = (localDeploymentId && status?.statsSummary[localDeploymentId]) || null const localMetric = (localDeploymentId && status?.metrics.metrics[localDeploymentId]) || null + const health = useMemo( + () => assessSiteReplicationHealth({ info, status, localDeploymentId }), + [info, status, localDeploymentId], + ) + const failures = siteReplicationFailures(status) + const siteName = (deploymentId: string) => + peers.find((peer) => peer.deploymentId === deploymentId)?.name || deploymentId + + const showResyncStatus = useCallback( + async (peer: SiteReplicationPeerInfo) => { + setPendingResyncDeploymentId(peer.deploymentId) + try { + const resync = await resyncSiteReplication("status", peer) + dialog.info({ + title: `${t("Resync Status")}: ${peer.name || peer.endpoint}`, + content: describeResync(t, resync), + positiveText: t("Close"), + }) + } catch (resyncError) { + message.error((resyncError as Error).message || t("Load Failed")) + } finally { + setPendingResyncDeploymentId("") + } + }, + [dialog, message, resyncSiteReplication, t], + ) const handleResync = useCallback( async (peer: SiteReplicationPeerInfo, operation: SiteReplicationResyncOperation) => { @@ -331,6 +556,16 @@ export default function SiteReplicationPage() { ) }, }, + { + id: "connection", + header: () => t("Connection"), + cell: ({ row }) => + row.original.deploymentId === localDeploymentId ? ( + - + ) : ( + + ), + }, { id: "endpoint", header: () => t("Endpoint"), @@ -350,9 +585,9 @@ export default function SiteReplicationPage() { }, { id: "syncState", - header: () => t("Sync"), + header: () => t("Sync Setting"), accessorKey: "syncState", - cell: ({ row }) => formatSyncBadge(t, row.original.syncState), + cell: ({ row }) => formatSyncSetting(t, row.original.syncState), }, { id: "ilmExpiry", @@ -414,6 +649,12 @@ export default function SiteReplicationPage() { {t("Cancel Resync")} ) : null} + {canResyncSites ? ( + void showResyncStatus(peer)}> + + {t("Resync Status")} + + ) : null} {canRemoveSites ? : null} {canRemoveSites ? ( @@ -434,9 +675,11 @@ export default function SiteReplicationPage() { canResyncSites, confirmRemoveSite, handleResync, + health.connections, localDeploymentId, pendingRemoveDeploymentId, pendingResyncDeploymentId, + showResyncStatus, t, ], ) @@ -611,6 +854,52 @@ export default function SiteReplicationPage() { ) : ( <> +
+
+
+

+ {t("Replication Health")} +

+ +
+

+ {t("Updated")}: {formatDateTime(updatedAt)} · {t("Refreshes every 30 seconds")} +

+
+ {health.issues.length ? ( +
    + {health.issues.map((issue, index) => { + const error = issueError(issue) + return ( +
  • + {describeIssue(t, issue, siteName)} + {error ? ( +

    + {t("Last error")}: {error} +

    + ) : null} +
  • + ) + })} +
+ ) : ( +

+ {health.level === "healthy" + ? t("Every peer site is reachable and nothing is waiting to be replicated or repaired.") + : t("Peer reachability is not reported by this server, so replication health cannot be confirmed.")} +

+ )} +
+
@@ -667,27 +956,33 @@ export default function SiteReplicationPage() {
-

{t("Replication Summary")}

+

{t("Failures and Backlog")}

- {t("Overview of replicated configuration and object replication activity.")} + {t("Object counters come from the node that answered this request and reset when it restarts.")}

+ + + + {t("Enabled")} - ) : ( - {t("Not configured")} - ) + status + ? `${formatInteger(status.metrics.activeWorkers.curr)} / ${formatInteger(status.metrics.activeWorkers.max)}` + : "--" } /> - -
@@ -718,6 +1013,32 @@ export default function SiteReplicationPage() { )}

+ {status ? ( +
+

{t("Consistency")}

+ {health.drift === null ? ( +

+ {t("Consistency cannot be checked until every peer site is reachable.")} +

+ ) : health.drift.length === 0 ? ( +

{t("Every site holds the same buckets, IAM entities, and expiry rules.")}

+ ) : ( +
    + {health.drift.slice(0, DRIFT_PREVIEW_LIMIT).map((item) => ( +
  • + {t(DRIFT_ENTITY_LABELS[item.entity])}{" "} + {item.name} — {describeDrift(t, item, siteName)} +
  • + ))} + {health.drift.length > DRIFT_PREVIEW_LIMIT ? ( +
  • + {t("and {count} more", { count: health.drift.length - DRIFT_PREVIEW_LIMIT })} +
  • + ) : null} +
+ )} +
+ ) : null}
@@ -730,7 +1051,6 @@ export default function SiteReplicationPage() { label={t("ILM Expiry Rules")} value={status?.maxIlmExpiryRules ?? localSummary?.totalIlmExpiryRulesCount ?? 0} /> -
diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx index 01b00a22..f20dcdbe 100644 --- a/app/(dashboard)/status/page.tsx +++ b/app/(dashboard)/status/page.tsx @@ -12,6 +12,7 @@ import { isScannerCycleActive, resolveUsageFreshness, } from "@/lib/performance-data" +import { formatDuration } from "@/lib/functions" import { cn } from "@/lib/utils" import { RiArchiveDrawerFill, @@ -32,20 +33,6 @@ import { PerformanceStatusSources } from "../_components/performance-status-sour import { PerformanceStatusSummary } from "../_components/performance-status-summary" import { PerformanceUsageCard } from "../_components/performance-usage-card" -function formatDuration(seconds: number | undefined, t: (key: string) => string) { - if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown") - const totalMinutes = Math.floor(seconds / 60) - const days = Math.floor(totalMinutes / 1440) - const hours = Math.floor((totalMinutes % 1440) / 60) - const minutes = totalMinutes % 60 - const parts = [ - days ? `${days} ${t("Days")}` : "", - hours ? `${hours} ${t("Hours")}` : "", - !days && minutes ? `${minutes} ${t("Minutes")}` : "", - ].filter(Boolean) - return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}` -} - export default function PerformancePage() { const { t, i18n } = useTranslation() const { canAccessPath } = usePermissions() diff --git a/hooks/use-site-replication.ts b/hooks/use-site-replication.ts index 86f3e0fd..09c2e972 100644 --- a/hooks/use-site-replication.ts +++ b/hooks/use-site-replication.ts @@ -5,19 +5,28 @@ import { useApi } from "@/contexts/api-context" import type { SiteReplicationAddResponse, SiteReplicationAddSiteInput, + SiteReplicationBucketStats, SiteReplicationEditResponse, + SiteReplicationEntityStats, SiteReplicationInfo, SiteReplicationMetric, SiteReplicationMetricsSummary, + SiteReplicationPeerError, SiteReplicationPeerInfo, + SiteReplicationPendingOperation, + SiteReplicationPolicyStats, + SiteReplicationQueueStat, SiteReplicationRemoveInput, SiteReplicationRemoveResponse, SiteReplicationResyncOperation, SiteReplicationResyncResponse, + SiteReplicationRetryStats, SiteReplicationSiteSummary, SiteReplicationStateInfo, SiteReplicationStatus, SiteReplicationSyncState, + SiteReplicationTimedErrors, + SiteReplicationVolume, SiteReplicationWorkerStat, } from "@/types/site-replication" @@ -39,6 +48,14 @@ function asBoolean(value: unknown): boolean { return typeof value === "boolean" ? value : false } +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] +} + +function mapRecord(value: unknown, normalize: (item: unknown) => T): Record { + return Object.fromEntries(Object.entries(asRecord(value)).map(([key, item]) => [key, normalize(item)])) +} + function normalizeSyncState(value: unknown): SiteReplicationSyncState { const normalized = asString(value).toLowerCase() if (normalized === "enable" || normalized === "disable") { @@ -57,6 +74,102 @@ function normalizeWorkerStat(value: unknown): SiteReplicationWorkerStat { } } +function normalizeVolume(value: unknown): SiteReplicationVolume { + const record = asRecord(value) + + return { count: asNumber(record.count), bytes: asNumber(record.bytes) } +} + +function normalizeTimedErrors(value: unknown): SiteReplicationTimedErrors { + const record = asRecord(value) + + return { + lastMinute: normalizeVolume(record.lastMinute), + lastHour: normalizeVolume(record.lastHour), + totals: normalizeVolume(record.totals), + } +} + +function normalizeQueueStat(value: unknown): SiteReplicationQueueStat { + const record = asRecord(value) + + return { + curr: normalizeVolume(record.curr), + avg: normalizeVolume(record.avg), + max: normalizeVolume(record.max), + } +} + +// Both fields are omitted by the server while nothing is outstanding, so an +// absent value means "healthy", not "unknown". +function normalizePendingOperation(value: unknown): SiteReplicationPendingOperation | undefined { + const record = asRecord(value) + const operation = asString(record.operation) + if (!operation) { + return undefined + } + + return { + operation, + id: asString(record.id), + pendingPeers: asStringArray(record.pendingPeers), + ackedPeers: asStringArray(record.ackedPeers), + updatedAt: asString(record.updatedAt) || undefined, + } +} + +function normalizeRetryStats(value: unknown): SiteReplicationRetryStats | undefined { + if (!value || typeof value !== "object") { + return undefined + } + const record = asRecord(value) + + return { + pending: asNumber(record.pending), + failed: asNumber(record.failed), + lastError: asString(record.lastError), + } +} + +function normalizePeerError(value: unknown): SiteReplicationPeerError { + const record = asRecord(value) + + return { name: asString(record.name), endpoint: asString(record.endpoint), error: asString(record.error) } +} + +function normalizeBucketStats(value: unknown): SiteReplicationBucketStats { + const record = asRecord(value) + + return { + hasBucket: asBoolean(record.HasBucket), + bucketMarkedDeleted: asBoolean(record.BucketMarkedDeleted), + tagMismatch: asBoolean(record.TagMismatch), + versioningConfigMismatch: asBoolean(record.VersioningConfigMismatch), + objectLockConfigMismatch: asBoolean(record.OLockConfigMismatch), + policyMismatch: asBoolean(record.PolicyMismatch), + sseConfigMismatch: asBoolean(record.SSEConfigMismatch), + replicationCfgMismatch: asBoolean(record.ReplicationCfgMismatch), + quotaCfgMismatch: asBoolean(record.QuotaCfgMismatch), + corsCfgMismatch: asBoolean(record.CorsCfgMismatch), + } +} + +function normalizePolicyStats(value: unknown): SiteReplicationPolicyStats { + const record = asRecord(value) + + return { hasPolicy: asBoolean(record.HasPolicy), policyMismatch: asBoolean(record.PolicyMismatch) } +} + +function normalizeEntityStats(value: unknown, normalize: (item: unknown) => T): SiteReplicationEntityStats { + return mapRecord(value, (entity) => mapRecord(entity, normalize)) +} + +// User, group, and ILM expiry rule entries only exist on the sites that hold +// the entity, so the inner keys are the holding deployment IDs. +function normalizeEntitySites(value: unknown): Record { + return mapRecord(value, (entity) => Object.keys(asRecord(entity))) +} + function normalizePeerInfo(value: unknown): SiteReplicationPeerInfo { const record = asRecord(value) const bandwidth = asRecord(record.defaultbandwidth) @@ -174,6 +287,7 @@ function normalizeMetric(value: unknown): SiteReplicationMetric { online: asBoolean(record.isOnline), replicatedSize: asNumber(record.replicatedSize), replicatedCount: asNumber(record.replicatedCount), + failed: normalizeTimedErrors(record.failed), } } @@ -190,6 +304,7 @@ function normalizeMetricsSummary(value: unknown): SiteReplicationMetricsSummary activeWorkers: normalizeWorkerStat(record.activeWorkers), replicaSize: asNumber(record.replicaSize), replicaCount: asNumber(record.replicaCount), + queued: normalizeQueueStat(record.queued), metrics: normalizeMetricMap(record.replMetrics), } } @@ -203,6 +318,8 @@ function normalizeInfo(value: unknown): SiteReplicationInfo { name: asString(record.name), sites, serviceAccountAccessKey: asString(record.serviceAccountAccessKey), + pendingOperation: normalizePendingOperation(record.pendingOperation), + retryStats: normalizeRetryStats(record.retryStats), apiVersion: asString(record.apiVersion) || undefined, } } @@ -220,7 +337,15 @@ function normalizeStatus(value: unknown): SiteReplicationStatus { sites: normalizePeerMap(record.Sites), statsSummary: normalizeSiteSummaryMap(record.StatsSummary), peerStates: normalizeStateInfoMap(record.PeerStates), + peerErrors: mapRecord(record.PeerErrors, normalizePeerError), + pendingOperation: normalizePendingOperation(record.PendingOperation), + retryStats: normalizeRetryStats(record.RetryStats), metrics: normalizeMetricsSummary(record.Metrics), + bucketStats: normalizeEntityStats(record.BucketStats, normalizeBucketStats), + policyStats: normalizeEntityStats(record.PolicyStats, normalizePolicyStats), + userSites: normalizeEntitySites(record.UserStats), + groupSites: normalizeEntitySites(record.GroupStats), + ilmExpiryRuleSites: normalizeEntitySites(record.ILMExpiryStats), apiVersion: asString(record.apiVersion) || undefined, } } @@ -275,7 +400,14 @@ function normalizeResyncResponse(value: unknown): SiteReplicationResyncResponse opType: asString(record.op), resyncId: asString(record.id), status: asString(record.status), + state: asString(record.state), buckets, + totalBuckets: asNumber(record.totalBuckets), + completedBuckets: asNumber(record.completedBuckets), + failedBuckets: asNumber(record.failedBuckets), + replicatedObjects: asNumber(record.replicatedObjects), + failedObjects: asNumber(record.failedObjects), + updatedAt: asString(record.updatedAt) || undefined, errorDetail: asString(record.errorDetail), } } diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index c293e768..b0b0151b 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -2241,5 +2241,45 @@ "Operations": "العمليات", "Resources": "الموارد", "Bucket size distribution": "توزيع أحجام الحاويات", - "Other buckets": "حاويات أخرى" + "Other buckets": "حاويات أخرى", + "Active workers": "العمال النشطون", + "Configuration deliveries": "تسليم الإعدادات", + "Connection": "الاتصال", + "Consistency": "الاتساق", + "Consistency cannot be checked until every peer site is reachable.": "لا يمكن التحقق من الاتساق حتى يصبح كل موقع نظير قابلاً للوصول.", + "Differs: {settings}": "مختلف: {settings}", + "Downtime": "مدة الانقطاع", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "كل المواقع النظيرة قابلة للوصول ولا يوجد ما ينتظر النسخ أو الإصلاح.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "تحتوي كل المواقع على نفس الحاويات وكيانات IAM وقواعد الانتهاء.", + "Failed in the last hour": "فشل خلال الساعة الأخيرة", + "Failed since start": "فشل منذ البدء", + "Failures and Backlog": "الإخفاقات والتراكم", + "Missing on {sites}": "مفقود في {sites}", + "No resync has been started for this site.": "لم تبدأ أي إعادة مزامنة لهذا الموقع.", + "Object counters come from the node that answered this request and reset when it restarts.": "تأتي عدادات الكائنات من العقدة التي أجابت على هذا الطلب وتُعاد إلى الصفر عند إعادة تشغيلها.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "لا يبلّغ هذا الخادم عن إمكانية الوصول إلى النظراء، لذا لا يمكن تأكيد سلامة النسخ.", + "Queued now": "في الانتظار الآن", + "Queued peak": "ذروة الانتظار", + "Refreshes every 30 seconds": "يتحدث كل 30 ثانية", + "Replication Health": "سلامة النسخ", + "Resync Status": "حالة إعادة المزامنة", + "Site {site} is unreachable, so nothing replicates to it.": "الموقع {site} غير قابل للوصول، لذا لا يتم نسخ أي شيء إليه.", + "Sync Setting": "إعداد المزامنة", + "and {count} more": "و{count} أخرى", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} من الحاويات أو كيانات IAM أو قواعد الانتهاء تختلف بين المواقع.", + "{count} object replication failures in the last hour.": "{count} من حالات فشل نسخ الكائنات خلال الساعة الأخيرة.", + "{operation} has not finished.": "{operation} لم تكتمل.", + "{operation} has not finished; waiting for {sites}.": "{operation} لم تكتمل؛ في انتظار {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} من تغييرات الإعدادات بانتظار التسليم إلى المواقع النظيرة، وفشل {failed}.", + "{pending} waiting, {failed} failed": "{pending} قيد الانتظار، {failed} فشل", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: اكتملت {completed} من {total} حاوية، وفشلت {failed}. تم نسخ {objects} كائن، وفشل {failedObjects}.", + "Site removal": "إزالة الموقع", + "Endpoint update": "تحديث نقطة النهاية", + "Group": "المجموعة", + "ILM Expiry Rule": "قاعدة انتهاء ILM", + "Marked for deletion": "محدد للحذف", + "Quota": "الحصة", + "CORS": "CORS", + "Policy document": "مستند السياسة", + "Service account rotation": "تدوير حساب الخدمة" } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 40b40014..389ae254 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -2241,5 +2241,45 @@ "Operations": "Betrieb", "Resources": "Ressourcen", "Bucket size distribution": "Verteilung der Bucket-Größen", - "Other buckets": "Weitere Buckets" + "Other buckets": "Weitere Buckets", + "Active workers": "Aktive Worker", + "Configuration deliveries": "Konfigurationszustellungen", + "Connection": "Verbindung", + "Consistency": "Konsistenz", + "Consistency cannot be checked until every peer site is reachable.": "Die Konsistenz kann erst geprüft werden, wenn alle Peer-Standorte erreichbar sind.", + "Differs: {settings}": "Abweichend: {settings}", + "Downtime": "Ausfallzeit", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Alle Peer-Standorte sind erreichbar, und nichts wartet auf Replikation oder Reparatur.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Alle Standorte enthalten dieselben Buckets, IAM-Entitäten und Ablaufregeln.", + "Failed in the last hour": "Fehlgeschlagen in der letzten Stunde", + "Failed since start": "Fehlgeschlagen seit Start", + "Failures and Backlog": "Fehler und Rückstand", + "Missing on {sites}": "Fehlt auf {sites}", + "No resync has been started for this site.": "Für diesen Standort wurde noch keine Neusynchronisierung gestartet.", + "Object counters come from the node that answered this request and reset when it restarts.": "Die Objektzähler stammen vom Knoten, der diese Anfrage beantwortet hat, und werden bei dessen Neustart zurückgesetzt.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Dieser Server meldet die Erreichbarkeit der Peers nicht, daher kann der Replikationszustand nicht bestätigt werden.", + "Queued now": "Aktuell in Warteschlange", + "Queued peak": "Höchststand Warteschlange", + "Refreshes every 30 seconds": "Aktualisierung alle 30 Sekunden", + "Replication Health": "Replikationszustand", + "Resync Status": "Status der Neusynchronisierung", + "Site {site} is unreachable, so nothing replicates to it.": "Standort {site} ist nicht erreichbar, daher wird nichts dorthin repliziert.", + "Sync Setting": "Sync-Einstellung", + "and {count} more": "und {count} weitere", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} Buckets, IAM-Entitäten oder Ablaufregeln unterscheiden sich zwischen den Standorten.", + "{count} object replication failures in the last hour.": "{count} fehlgeschlagene Objektreplikationen in der letzten Stunde.", + "{operation} has not finished.": "{operation} ist nicht abgeschlossen.", + "{operation} has not finished; waiting for {sites}.": "{operation} ist nicht abgeschlossen; wartet auf {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} Konfigurationsänderungen warten auf Zustellung an Peer-Standorte, {failed} fehlgeschlagen.", + "{pending} waiting, {failed} failed": "{pending} wartend, {failed} fehlgeschlagen", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} von {total} Buckets abgeschlossen, {failed} fehlgeschlagen. {objects} Objekte repliziert, {failedObjects} fehlgeschlagen.", + "Site removal": "Standortentfernung", + "Endpoint update": "Endpunkt-Aktualisierung", + "Group": "Gruppe", + "ILM Expiry Rule": "ILM-Ablaufregel", + "Marked for deletion": "Zum Löschen markiert", + "Quota": "Kontingent", + "CORS": "CORS", + "Policy document": "Richtliniendokument", + "Service account rotation": "Rotation des Dienstkontos" } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 7d1f570e..fb0318fb 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -2241,5 +2241,45 @@ "Operations": "Operations", "Resources": "Resources", "Bucket size distribution": "Bucket size distribution", - "Other buckets": "Other buckets" + "Other buckets": "Other buckets", + "Active workers": "Active workers", + "Configuration deliveries": "Configuration deliveries", + "Connection": "Connection", + "Consistency": "Consistency", + "Consistency cannot be checked until every peer site is reachable.": "Consistency cannot be checked until every peer site is reachable.", + "Differs: {settings}": "Differs: {settings}", + "Downtime": "Downtime", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Every peer site is reachable and nothing is waiting to be replicated or repaired.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Every site holds the same buckets, IAM entities, and expiry rules.", + "Failed in the last hour": "Failed in the last hour", + "Failed since start": "Failed since start", + "Failures and Backlog": "Failures and Backlog", + "Missing on {sites}": "Missing on {sites}", + "No resync has been started for this site.": "No resync has been started for this site.", + "Object counters come from the node that answered this request and reset when it restarts.": "Object counters come from the node that answered this request and reset when it restarts.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Peer reachability is not reported by this server, so replication health cannot be confirmed.", + "Queued now": "Queued now", + "Queued peak": "Queued peak", + "Refreshes every 30 seconds": "Refreshes every 30 seconds", + "Replication Health": "Replication Health", + "Resync Status": "Resync Status", + "Site {site} is unreachable, so nothing replicates to it.": "Site {site} is unreachable, so nothing replicates to it.", + "Sync Setting": "Sync Setting", + "and {count} more": "and {count} more", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} buckets, IAM entities, or expiry rules differ between sites.", + "{count} object replication failures in the last hour.": "{count} object replication failures in the last hour.", + "{operation} has not finished.": "{operation} has not finished.", + "{operation} has not finished; waiting for {sites}.": "{operation} has not finished; waiting for {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.", + "{pending} waiting, {failed} failed": "{pending} waiting, {failed} failed", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.", + "Site removal": "Site removal", + "Endpoint update": "Endpoint update", + "Group": "Group", + "ILM Expiry Rule": "ILM Expiry Rule", + "Marked for deletion": "Marked for deletion", + "Quota": "Quota", + "CORS": "CORS", + "Policy document": "Policy document", + "Service account rotation": "Service account rotation" } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index e807fc96..80f61a35 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -2241,5 +2241,45 @@ "Operations": "Operaciones", "Resources": "Recursos", "Bucket size distribution": "Distribución del tamaño de los buckets", - "Other buckets": "Otros buckets" + "Other buckets": "Otros buckets", + "Active workers": "Workers activos", + "Configuration deliveries": "Entregas de configuración", + "Connection": "Conexión", + "Consistency": "Coherencia", + "Consistency cannot be checked until every peer site is reachable.": "No se puede comprobar la coherencia hasta que todos los sitios pares sean accesibles.", + "Differs: {settings}": "Difiere: {settings}", + "Downtime": "Tiempo de inactividad", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Todos los sitios pares son accesibles y no hay nada pendiente de replicar o reparar.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Todos los sitios tienen los mismos buckets, entidades IAM y reglas de expiración.", + "Failed in the last hour": "Fallidos en la última hora", + "Failed since start": "Fallidos desde el inicio", + "Failures and Backlog": "Fallos y pendientes", + "Missing on {sites}": "Falta en {sites}", + "No resync has been started for this site.": "No se ha iniciado ninguna resincronización para este sitio.", + "Object counters come from the node that answered this request and reset when it restarts.": "Los contadores de objetos provienen del nodo que respondió a esta solicitud y se reinician cuando este se reinicia.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Este servidor no informa la accesibilidad de los pares, por lo que no se puede confirmar el estado de la replicación.", + "Queued now": "En cola ahora", + "Queued peak": "Pico en cola", + "Refreshes every 30 seconds": "Se actualiza cada 30 segundos", + "Replication Health": "Estado de la replicación", + "Resync Status": "Estado de resincronización", + "Site {site} is unreachable, so nothing replicates to it.": "El sitio {site} no es accesible, por lo que no se replica nada hacia él.", + "Sync Setting": "Ajuste de sincronización", + "and {count} more": "y {count} más", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} buckets, entidades IAM o reglas de expiración difieren entre los sitios.", + "{count} object replication failures in the last hour.": "{count} fallos de replicación de objetos en la última hora.", + "{operation} has not finished.": "{operation} no ha terminado.", + "{operation} has not finished; waiting for {sites}.": "{operation} no ha terminado; esperando a {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} cambios de configuración esperan ser entregados a los sitios pares; {failed} fallaron.", + "{pending} waiting, {failed} failed": "{pending} en espera, {failed} fallidos", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} de {total} buckets completados, {failed} fallidos. {objects} objetos replicados, {failedObjects} fallidos.", + "Site removal": "Eliminación del sitio", + "Endpoint update": "Actualización del endpoint", + "Group": "Grupo", + "ILM Expiry Rule": "Regla de expiración ILM", + "Marked for deletion": "Marcado para eliminación", + "Quota": "Cuota", + "CORS": "CORS", + "Policy document": "Documento de política", + "Service account rotation": "Rotación de la cuenta de servicio" } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 80d72f17..26ff8034 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -2241,5 +2241,45 @@ "Operations": "Opérations", "Resources": "Ressources", "Bucket size distribution": "Répartition de la taille des buckets", - "Other buckets": "Autres buckets" + "Other buckets": "Autres buckets", + "Active workers": "Workers actifs", + "Configuration deliveries": "Livraisons de configuration", + "Connection": "Connexion", + "Consistency": "Cohérence", + "Consistency cannot be checked until every peer site is reachable.": "La cohérence ne peut pas être vérifiée tant que tous les sites pairs ne sont pas joignables.", + "Differs: {settings}": "Diffère : {settings}", + "Downtime": "Durée d'indisponibilité", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Tous les sites pairs sont joignables et rien n'attend d'être répliqué ou réparé.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Tous les sites contiennent les mêmes buckets, entités IAM et règles d'expiration.", + "Failed in the last hour": "Échecs au cours de la dernière heure", + "Failed since start": "Échecs depuis le démarrage", + "Failures and Backlog": "Échecs et file d'attente", + "Missing on {sites}": "Absent sur {sites}", + "No resync has been started for this site.": "Aucune resynchronisation n'a été lancée pour ce site.", + "Object counters come from the node that answered this request and reset when it restarts.": "Les compteurs d'objets proviennent du nœud qui a répondu à cette requête et sont remis à zéro à son redémarrage.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Ce serveur ne signale pas la joignabilité des pairs ; l'état de la réplication ne peut donc pas être confirmé.", + "Queued now": "En file actuellement", + "Queued peak": "Pic de file", + "Refreshes every 30 seconds": "Actualisé toutes les 30 secondes", + "Replication Health": "État de la réplication", + "Resync Status": "État de la resynchronisation", + "Site {site} is unreachable, so nothing replicates to it.": "Le site {site} est injoignable ; rien n'y est répliqué.", + "Sync Setting": "Paramètre de synchronisation", + "and {count} more": "et {count} de plus", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} buckets, entités IAM ou règles d'expiration diffèrent entre les sites.", + "{count} object replication failures in the last hour.": "{count} échecs de réplication d'objets au cours de la dernière heure.", + "{operation} has not finished.": "{operation} n'est pas terminée.", + "{operation} has not finished; waiting for {sites}.": "{operation} n'est pas terminée ; en attente de {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} modifications de configuration attendent d'être livrées aux sites pairs ; {failed} ont échoué.", + "{pending} waiting, {failed} failed": "{pending} en attente, {failed} en échec", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state} : {completed} buckets sur {total} terminés, {failed} en échec. {objects} objets répliqués, {failedObjects} en échec.", + "Site removal": "Suppression du site", + "Endpoint update": "Mise à jour du point de terminaison", + "Group": "Groupe", + "ILM Expiry Rule": "Règle d'expiration ILM", + "Marked for deletion": "Marqué pour suppression", + "Quota": "Quota", + "CORS": "CORS", + "Policy document": "Document de stratégie", + "Service account rotation": "Rotation du compte de service" } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 141b8939..40e50b33 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -2241,5 +2241,45 @@ "Operations": "Operasi", "Resources": "Sumber daya", "Bucket size distribution": "Distribusi ukuran bucket", - "Other buckets": "Bucket lainnya" + "Other buckets": "Bucket lainnya", + "Active workers": "Worker aktif", + "Configuration deliveries": "Pengiriman konfigurasi", + "Connection": "Koneksi", + "Consistency": "Konsistensi", + "Consistency cannot be checked until every peer site is reachable.": "Konsistensi tidak dapat diperiksa sampai semua situs peer dapat dijangkau.", + "Differs: {settings}": "Berbeda: {settings}", + "Downtime": "Waktu henti", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Semua situs peer dapat dijangkau dan tidak ada yang menunggu untuk direplikasi atau diperbaiki.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Semua situs memiliki bucket, entitas IAM, dan aturan kedaluwarsa yang sama.", + "Failed in the last hour": "Gagal dalam satu jam terakhir", + "Failed since start": "Gagal sejak mulai", + "Failures and Backlog": "Kegagalan dan antrean", + "Missing on {sites}": "Tidak ada di {sites}", + "No resync has been started for this site.": "Belum ada sinkronisasi ulang yang dimulai untuk situs ini.", + "Object counters come from the node that answered this request and reset when it restarts.": "Penghitung objek berasal dari node yang menjawab permintaan ini dan direset saat node dimulai ulang.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Server ini tidak melaporkan keterjangkauan peer, sehingga kesehatan replikasi tidak dapat dipastikan.", + "Queued now": "Antre saat ini", + "Queued peak": "Puncak antrean", + "Refreshes every 30 seconds": "Diperbarui setiap 30 detik", + "Replication Health": "Kesehatan replikasi", + "Resync Status": "Status sinkronisasi ulang", + "Site {site} is unreachable, so nothing replicates to it.": "Situs {site} tidak dapat dijangkau, sehingga tidak ada yang direplikasi ke sana.", + "Sync Setting": "Pengaturan sinkronisasi", + "and {count} more": "dan {count} lainnya", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} bucket, entitas IAM, atau aturan kedaluwarsa berbeda antar situs.", + "{count} object replication failures in the last hour.": "{count} kegagalan replikasi objek dalam satu jam terakhir.", + "{operation} has not finished.": "{operation} belum selesai.", + "{operation} has not finished; waiting for {sites}.": "{operation} belum selesai; menunggu {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} perubahan konfigurasi menunggu dikirim ke situs peer, {failed} gagal.", + "{pending} waiting, {failed} failed": "{pending} menunggu, {failed} gagal", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} dari {total} bucket selesai, {failed} gagal. {objects} objek direplikasi, {failedObjects} gagal.", + "Site removal": "Penghapusan situs", + "Endpoint update": "Pembaruan endpoint", + "Group": "Grup", + "ILM Expiry Rule": "Aturan kedaluwarsa ILM", + "Marked for deletion": "Ditandai untuk dihapus", + "Quota": "Kuota", + "CORS": "CORS", + "Policy document": "Dokumen kebijakan", + "Service account rotation": "Rotasi akun layanan" } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index dad754da..89f2100e 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -2241,5 +2241,45 @@ "Operations": "Operazioni", "Resources": "Risorse", "Bucket size distribution": "Distribuzione delle dimensioni dei bucket", - "Other buckets": "Altri bucket" + "Other buckets": "Altri bucket", + "Active workers": "Worker attivi", + "Configuration deliveries": "Consegne di configurazione", + "Connection": "Connessione", + "Consistency": "Coerenza", + "Consistency cannot be checked until every peer site is reachable.": "La coerenza non può essere verificata finché tutti i siti peer non sono raggiungibili.", + "Differs: {settings}": "Diverso: {settings}", + "Downtime": "Tempo di inattività", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Tutti i siti peer sono raggiungibili e nulla è in attesa di replica o riparazione.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Tutti i siti contengono gli stessi bucket, entità IAM e regole di scadenza.", + "Failed in the last hour": "Non riusciti nell'ultima ora", + "Failed since start": "Non riusciti dall'avvio", + "Failures and Backlog": "Errori e arretrato", + "Missing on {sites}": "Mancante su {sites}", + "No resync has been started for this site.": "Non è stata avviata alcuna risincronizzazione per questo sito.", + "Object counters come from the node that answered this request and reset when it restarts.": "I contatori degli oggetti provengono dal nodo che ha risposto a questa richiesta e si azzerano al suo riavvio.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Questo server non segnala la raggiungibilità dei peer, quindi lo stato della replica non può essere confermato.", + "Queued now": "In coda ora", + "Queued peak": "Picco in coda", + "Refreshes every 30 seconds": "Aggiornamento ogni 30 secondi", + "Replication Health": "Stato della replica", + "Resync Status": "Stato della risincronizzazione", + "Site {site} is unreachable, so nothing replicates to it.": "Il sito {site} non è raggiungibile, quindi non viene replicato nulla verso di esso.", + "Sync Setting": "Impostazione di sincronizzazione", + "and {count} more": "e altri {count}", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} bucket, entità IAM o regole di scadenza differiscono tra i siti.", + "{count} object replication failures in the last hour.": "{count} errori di replica degli oggetti nell'ultima ora.", + "{operation} has not finished.": "{operation} non è terminata.", + "{operation} has not finished; waiting for {sites}.": "{operation} non è terminata; in attesa di {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} modifiche di configurazione attendono la consegna ai siti peer, {failed} non riuscite.", + "{pending} waiting, {failed} failed": "{pending} in attesa, {failed} non riusciti", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} di {total} bucket completati, {failed} non riusciti. {objects} oggetti replicati, {failedObjects} non riusciti.", + "Site removal": "Rimozione del sito", + "Endpoint update": "Aggiornamento dell'endpoint", + "Group": "Gruppo", + "ILM Expiry Rule": "Regola di scadenza ILM", + "Marked for deletion": "Contrassegnato per l'eliminazione", + "Quota": "Quota", + "CORS": "CORS", + "Policy document": "Documento della policy", + "Service account rotation": "Rotazione dell'account di servizio" } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 7ee516f2..8e3b6f25 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -2241,5 +2241,45 @@ "Operations": "運用", "Resources": "リソース", "Bucket size distribution": "バケット容量の内訳", - "Other buckets": "その他のバケット" + "Other buckets": "その他のバケット", + "Active workers": "アクティブなワーカー", + "Configuration deliveries": "設定の配信", + "Connection": "接続", + "Consistency": "整合性", + "Consistency cannot be checked until every peer site is reachable.": "すべてのピアサイトに到達できるまで整合性を確認できません。", + "Differs: {settings}": "相違: {settings}", + "Downtime": "ダウンタイム", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "すべてのピアサイトに到達でき、レプリケーションや修復を待つものはありません。", + "Every site holds the same buckets, IAM entities, and expiry rules.": "すべてのサイトが同じバケット、IAM エンティティ、有効期限ルールを保持しています。", + "Failed in the last hour": "直近 1 時間の失敗", + "Failed since start": "起動以降の失敗", + "Failures and Backlog": "失敗とバックログ", + "Missing on {sites}": "{sites} に存在しません", + "No resync has been started for this site.": "このサイトの再同期はまだ開始されていません。", + "Object counters come from the node that answered this request and reset when it restarts.": "オブジェクトのカウンターはこのリクエストに応答したノードのもので、ノードの再起動時にリセットされます。", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "このサーバーはピアへの到達性を報告しないため、レプリケーションの健全性を確認できません。", + "Queued now": "現在のキュー", + "Queued peak": "キューのピーク", + "Refreshes every 30 seconds": "30 秒ごとに更新", + "Replication Health": "レプリケーションの健全性", + "Resync Status": "再同期の状態", + "Site {site} is unreachable, so nothing replicates to it.": "サイト {site} に到達できないため、何もレプリケートされません。", + "Sync Setting": "同期設定", + "and {count} more": "ほか {count} 件", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} 件のバケット、IAM エンティティ、有効期限ルールがサイト間で異なります。", + "{count} object replication failures in the last hour.": "直近 1 時間にオブジェクトのレプリケーションが {count} 件失敗しました。", + "{operation} has not finished.": "{operation} が完了していません。", + "{operation} has not finished; waiting for {sites}.": "{operation} が完了していません。{sites} を待っています。", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} 件の設定変更がピアサイトへの配信を待っており、{failed} 件が失敗しました。", + "{pending} waiting, {failed} failed": "待機中 {pending} 件、失敗 {failed} 件", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {total} 個中 {completed} 個のバケットが完了、{failed} 個が失敗。{objects} 個のオブジェクトをレプリケート、{failedObjects} 個が失敗。", + "Site removal": "サイトの削除", + "Endpoint update": "エンドポイントの更新", + "Group": "グループ", + "ILM Expiry Rule": "ILM 有効期限ルール", + "Marked for deletion": "削除予定", + "Quota": "クォータ", + "CORS": "CORS", + "Policy document": "ポリシードキュメント", + "Service account rotation": "サービスアカウントのローテーション" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 239c7d0b..e1d462ac 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -2241,5 +2241,45 @@ "Operations": "운영", "Resources": "리소스", "Bucket size distribution": "버킷 용량 비율", - "Other buckets": "기타 버킷" + "Other buckets": "기타 버킷", + "Active workers": "활성 워커", + "Configuration deliveries": "구성 전달", + "Connection": "연결", + "Consistency": "일관성", + "Consistency cannot be checked until every peer site is reachable.": "모든 피어 사이트에 연결할 수 있어야 일관성을 확인할 수 있습니다.", + "Differs: {settings}": "차이: {settings}", + "Downtime": "중단 시간", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "모든 피어 사이트에 연결할 수 있으며 복제나 복구를 기다리는 항목이 없습니다.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "모든 사이트가 동일한 버킷, IAM 엔터티, 만료 규칙을 가지고 있습니다.", + "Failed in the last hour": "최근 1시간 실패", + "Failed since start": "시작 이후 실패", + "Failures and Backlog": "실패 및 적체", + "Missing on {sites}": "{sites}에 없음", + "No resync has been started for this site.": "이 사이트에 대해 시작된 재동기화가 없습니다.", + "Object counters come from the node that answered this request and reset when it restarts.": "객체 카운터는 이 요청에 응답한 노드의 값이며 해당 노드가 다시 시작되면 초기화됩니다.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "이 서버는 피어 연결 상태를 보고하지 않으므로 복제 상태를 확인할 수 없습니다.", + "Queued now": "현재 대기열", + "Queued peak": "대기열 최대치", + "Refreshes every 30 seconds": "30초마다 새로 고침", + "Replication Health": "복제 상태", + "Resync Status": "재동기화 상태", + "Site {site} is unreachable, so nothing replicates to it.": "사이트 {site}에 연결할 수 없어 아무것도 복제되지 않습니다.", + "Sync Setting": "동기화 설정", + "and {count} more": "외 {count}개", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count}개의 버킷, IAM 엔터티 또는 만료 규칙이 사이트 간에 다릅니다.", + "{count} object replication failures in the last hour.": "최근 1시간 동안 객체 복제가 {count}건 실패했습니다.", + "{operation} has not finished.": "{operation}이(가) 완료되지 않았습니다.", + "{operation} has not finished; waiting for {sites}.": "{operation}이(가) 완료되지 않았습니다. {sites}을(를) 기다리는 중입니다.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending}개의 구성 변경이 피어 사이트로 전달을 기다리고 있으며 {failed}개가 실패했습니다.", + "{pending} waiting, {failed} failed": "대기 {pending}개, 실패 {failed}개", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: 버킷 {total}개 중 {completed}개 완료, {failed}개 실패. 객체 {objects}개 복제, {failedObjects}개 실패.", + "Site removal": "사이트 제거", + "Endpoint update": "엔드포인트 업데이트", + "Group": "그룹", + "ILM Expiry Rule": "ILM 만료 규칙", + "Marked for deletion": "삭제 표시됨", + "Quota": "할당량", + "CORS": "CORS", + "Policy document": "정책 문서", + "Service account rotation": "서비스 계정 교체" } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index e03b09be..6ac31b56 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -2241,5 +2241,45 @@ "Operations": "Operações", "Resources": "Recursos", "Bucket size distribution": "Distribuição do tamanho dos buckets", - "Other buckets": "Outros buckets" + "Other buckets": "Outros buckets", + "Active workers": "Workers ativos", + "Configuration deliveries": "Entregas de configuração", + "Connection": "Conexão", + "Consistency": "Consistência", + "Consistency cannot be checked until every peer site is reachable.": "A consistência só pode ser verificada quando todos os sites pares estiverem acessíveis.", + "Differs: {settings}": "Difere: {settings}", + "Downtime": "Tempo de inatividade", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Todos os sites pares estão acessíveis e nada aguarda replicação ou reparo.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Todos os sites têm os mesmos buckets, entidades IAM e regras de expiração.", + "Failed in the last hour": "Falhas na última hora", + "Failed since start": "Falhas desde o início", + "Failures and Backlog": "Falhas e pendências", + "Missing on {sites}": "Ausente em {sites}", + "No resync has been started for this site.": "Nenhuma ressincronização foi iniciada para este site.", + "Object counters come from the node that answered this request and reset when it restarts.": "Os contadores de objetos vêm do nó que respondeu a esta solicitação e são zerados quando ele reinicia.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Este servidor não informa a acessibilidade dos pares, portanto a saúde da replicação não pode ser confirmada.", + "Queued now": "Na fila agora", + "Queued peak": "Pico na fila", + "Refreshes every 30 seconds": "Atualiza a cada 30 segundos", + "Replication Health": "Saúde da replicação", + "Resync Status": "Status da ressincronização", + "Site {site} is unreachable, so nothing replicates to it.": "O site {site} está inacessível, então nada é replicado para ele.", + "Sync Setting": "Configuração de sincronização", + "and {count} more": "e mais {count}", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} buckets, entidades IAM ou regras de expiração diferem entre os sites.", + "{count} object replication failures in the last hour.": "{count} falhas de replicação de objetos na última hora.", + "{operation} has not finished.": "{operation} não foi concluída.", + "{operation} has not finished; waiting for {sites}.": "{operation} não foi concluída; aguardando {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} alterações de configuração aguardam entrega aos sites pares; {failed} falharam.", + "{pending} waiting, {failed} failed": "{pending} aguardando, {failed} com falha", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed} de {total} buckets concluídos, {failed} com falha. {objects} objetos replicados, {failedObjects} com falha.", + "Site removal": "Remoção do site", + "Endpoint update": "Atualização do endpoint", + "Group": "Grupo", + "ILM Expiry Rule": "Regra de expiração ILM", + "Marked for deletion": "Marcado para exclusão", + "Quota": "Cota", + "CORS": "CORS", + "Policy document": "Documento da política", + "Service account rotation": "Rotação da conta de serviço" } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 221e475e..16e56d35 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -2241,5 +2241,45 @@ "Operations": "Операции", "Resources": "Ресурсы", "Bucket size distribution": "Распределение объёма бакетов", - "Other buckets": "Другие бакеты" + "Other buckets": "Другие бакеты", + "Active workers": "Активные обработчики", + "Configuration deliveries": "Доставка конфигурации", + "Connection": "Подключение", + "Consistency": "Согласованность", + "Consistency cannot be checked until every peer site is reachable.": "Согласованность нельзя проверить, пока все одноранговые сайты не станут доступны.", + "Differs: {settings}": "Отличается: {settings}", + "Downtime": "Время простоя", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Все одноранговые сайты доступны, и ничто не ожидает репликации или восстановления.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Все сайты содержат одинаковые бакеты, сущности IAM и правила истечения срока.", + "Failed in the last hour": "Сбоев за последний час", + "Failed since start": "Сбоев с момента запуска", + "Failures and Backlog": "Сбои и очередь", + "Missing on {sites}": "Отсутствует на {sites}", + "No resync has been started for this site.": "Для этого сайта повторная синхронизация не запускалась.", + "Object counters come from the node that answered this request and reset when it restarts.": "Счётчики объектов берутся с узла, ответившего на этот запрос, и сбрасываются при его перезапуске.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Этот сервер не сообщает о доступности одноранговых узлов, поэтому состояние репликации подтвердить нельзя.", + "Queued now": "Сейчас в очереди", + "Queued peak": "Пик очереди", + "Refreshes every 30 seconds": "Обновляется каждые 30 секунд", + "Replication Health": "Состояние репликации", + "Resync Status": "Статус повторной синхронизации", + "Site {site} is unreachable, so nothing replicates to it.": "Сайт {site} недоступен, поэтому на него ничего не реплицируется.", + "Sync Setting": "Настройка синхронизации", + "and {count} more": "и ещё {count}", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} бакетов, сущностей IAM или правил истечения срока различаются между сайтами.", + "{count} object replication failures in the last hour.": "{count} сбоев репликации объектов за последний час.", + "{operation} has not finished.": "{operation} не завершена.", + "{operation} has not finished; waiting for {sites}.": "{operation} не завершена; ожидание {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} изменений конфигурации ожидают доставки на одноранговые сайты, {failed} завершились сбоем.", + "{pending} waiting, {failed} failed": "{pending} ожидают, {failed} со сбоем", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: завершено {completed} из {total} бакетов, {failed} со сбоем. Реплицировано объектов: {objects}, со сбоем: {failedObjects}.", + "Site removal": "Удаление сайта", + "Endpoint update": "Обновление конечной точки", + "Group": "Группа", + "ILM Expiry Rule": "Правило истечения ILM", + "Marked for deletion": "Помечен на удаление", + "Quota": "Квота", + "CORS": "CORS", + "Policy document": "Документ политики", + "Service account rotation": "Смена сервисной учётной записи" } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 96bac012..6dfe3504 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -2241,5 +2241,45 @@ "Operations": "İşlemler", "Resources": "Kaynaklar", "Bucket size distribution": "Kova boyutu dağılımı", - "Other buckets": "Diğer kovalar" + "Other buckets": "Diğer kovalar", + "Active workers": "Etkin çalışanlar", + "Configuration deliveries": "Yapılandırma teslimatları", + "Connection": "Bağlantı", + "Consistency": "Tutarlılık", + "Consistency cannot be checked until every peer site is reachable.": "Tüm eş sitelere ulaşılabilir olana kadar tutarlılık denetlenemez.", + "Differs: {settings}": "Farklı: {settings}", + "Downtime": "Kesinti süresi", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Tüm eş sitelere ulaşılabiliyor ve çoğaltılmayı ya da onarılmayı bekleyen bir şey yok.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Tüm siteler aynı bucket'ları, IAM varlıklarını ve süre sonu kurallarını içeriyor.", + "Failed in the last hour": "Son bir saatte başarısız", + "Failed since start": "Başlangıçtan beri başarısız", + "Failures and Backlog": "Hatalar ve birikim", + "Missing on {sites}": "{sites} üzerinde eksik", + "No resync has been started for this site.": "Bu site için yeniden eşitleme başlatılmadı.", + "Object counters come from the node that answered this request and reset when it restarts.": "Nesne sayaçları bu isteği yanıtlayan düğümden gelir ve düğüm yeniden başlatıldığında sıfırlanır.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Bu sunucu eşlere erişilebilirliği bildirmiyor, bu yüzden çoğaltma durumu doğrulanamıyor.", + "Queued now": "Şu an kuyrukta", + "Queued peak": "Kuyruk zirvesi", + "Refreshes every 30 seconds": "30 saniyede bir yenilenir", + "Replication Health": "Çoğaltma durumu", + "Resync Status": "Yeniden eşitleme durumu", + "Site {site} is unreachable, so nothing replicates to it.": "{site} sitesine ulaşılamıyor, bu yüzden oraya hiçbir şey çoğaltılmıyor.", + "Sync Setting": "Eşitleme ayarı", + "and {count} more": "ve {count} tane daha", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} bucket, IAM varlığı veya süre sonu kuralı siteler arasında farklı.", + "{count} object replication failures in the last hour.": "Son bir saatte {count} nesne çoğaltma hatası.", + "{operation} has not finished.": "{operation} tamamlanmadı.", + "{operation} has not finished; waiting for {sites}.": "{operation} tamamlanmadı; {sites} bekleniyor.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} yapılandırma değişikliği eş sitelere teslim edilmeyi bekliyor, {failed} başarısız.", + "{pending} waiting, {failed} failed": "{pending} bekliyor, {failed} başarısız", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {total} bucket'tan {completed} tamamlandı, {failed} başarısız. {objects} nesne çoğaltıldı, {failedObjects} başarısız.", + "Site removal": "Site kaldırma", + "Endpoint update": "Uç nokta güncellemesi", + "Group": "Grup", + "ILM Expiry Rule": "ILM süre sonu kuralı", + "Marked for deletion": "Silinmek üzere işaretli", + "Quota": "Kota", + "CORS": "CORS", + "Policy document": "İlke belgesi", + "Service account rotation": "Hizmet hesabı döndürme" } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 1c16b3cb..4ad23a25 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -2241,5 +2241,45 @@ "Operations": "Vận hành", "Resources": "Tài nguyên", "Bucket size distribution": "Phân bố dung lượng bucket", - "Other buckets": "Các bucket khác" + "Other buckets": "Các bucket khác", + "Active workers": "Worker đang hoạt động", + "Configuration deliveries": "Phân phối cấu hình", + "Connection": "Kết nối", + "Consistency": "Tính nhất quán", + "Consistency cannot be checked until every peer site is reachable.": "Không thể kiểm tra tính nhất quán cho đến khi mọi site ngang hàng đều truy cập được.", + "Differs: {settings}": "Khác nhau: {settings}", + "Downtime": "Thời gian ngừng", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "Mọi site ngang hàng đều truy cập được và không có gì đang chờ sao chép hoặc sửa chữa.", + "Every site holds the same buckets, IAM entities, and expiry rules.": "Mọi site đều có cùng bucket, thực thể IAM và quy tắc hết hạn.", + "Failed in the last hour": "Thất bại trong giờ qua", + "Failed since start": "Thất bại kể từ khi khởi động", + "Failures and Backlog": "Lỗi và tồn đọng", + "Missing on {sites}": "Thiếu trên {sites}", + "No resync has been started for this site.": "Chưa có lần đồng bộ lại nào được bắt đầu cho site này.", + "Object counters come from the node that answered this request and reset when it restarts.": "Bộ đếm đối tượng lấy từ nút đã trả lời yêu cầu này và được đặt lại khi nút khởi động lại.", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "Máy chủ này không báo cáo khả năng truy cập peer nên không thể xác nhận tình trạng sao chép.", + "Queued now": "Đang xếp hàng", + "Queued peak": "Đỉnh hàng đợi", + "Refreshes every 30 seconds": "Làm mới mỗi 30 giây", + "Replication Health": "Tình trạng sao chép", + "Resync Status": "Trạng thái đồng bộ lại", + "Site {site} is unreachable, so nothing replicates to it.": "Không truy cập được site {site} nên không có gì được sao chép tới đó.", + "Sync Setting": "Cài đặt đồng bộ", + "and {count} more": "và {count} mục khác", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "{count} bucket, thực thể IAM hoặc quy tắc hết hạn khác nhau giữa các site.", + "{count} object replication failures in the last hour.": "{count} lỗi sao chép đối tượng trong giờ qua.", + "{operation} has not finished.": "{operation} chưa hoàn tất.", + "{operation} has not finished; waiting for {sites}.": "{operation} chưa hoàn tất; đang chờ {sites}.", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "{pending} thay đổi cấu hình đang chờ gửi tới các site ngang hàng, {failed} thất bại.", + "{pending} waiting, {failed} failed": "{pending} đang chờ, {failed} thất bại", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}: {completed}/{total} bucket hoàn tất, {failed} thất bại. Đã sao chép {objects} đối tượng, {failedObjects} thất bại.", + "Site removal": "Gỡ site", + "Endpoint update": "Cập nhật endpoint", + "Group": "Nhóm", + "ILM Expiry Rule": "Quy tắc hết hạn ILM", + "Marked for deletion": "Đã đánh dấu xóa", + "Quota": "Hạn mức", + "CORS": "CORS", + "Policy document": "Tài liệu chính sách", + "Service account rotation": "Xoay vòng tài khoản dịch vụ" } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index c4b932e2..b7b1983c 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -2241,5 +2241,45 @@ "Operations": "运行管理", "Resources": "资源", "Bucket size distribution": "桶容量占比", - "Other buckets": "其他桶" + "Other buckets": "其他桶", + "Active workers": "活跃工作线程", + "Configuration deliveries": "配置下发", + "Connection": "连接状态", + "Consistency": "一致性", + "Consistency cannot be checked until every peer site is reachable.": "需等所有对等站点都可达后才能检查一致性。", + "Differs: {settings}": "不一致:{settings}", + "Downtime": "累计离线时长", + "Every peer site is reachable and nothing is waiting to be replicated or repaired.": "所有对等站点均可达,没有等待复制或修复的内容。", + "Every site holds the same buckets, IAM entities, and expiry rules.": "各站点的存储桶、IAM 实体和过期规则完全一致。", + "Failed in the last hour": "近 1 小时失败", + "Failed since start": "启动以来失败", + "Failures and Backlog": "失败与积压", + "Missing on {sites}": "{sites} 缺失", + "No resync has been started for this site.": "该站点尚未发起过重同步。", + "Object counters come from the node that answered this request and reset when it restarts.": "对象计数来自响应本次请求的节点,该节点重启后清零。", + "Peer reachability is not reported by this server, so replication health cannot be confirmed.": "该服务器未上报对等站点的可达性,无法确认复制是否健康。", + "Queued now": "当前排队", + "Queued peak": "排队峰值", + "Refreshes every 30 seconds": "每 30 秒自动刷新", + "Replication Health": "复制健康状态", + "Resync Status": "重同步进度", + "Site {site} is unreachable, so nothing replicates to it.": "站点 {site} 不可达,任何内容都无法复制到该站点。", + "Sync Setting": "同步开关", + "and {count} more": "以及另外 {count} 项", + "{count} buckets, IAM entities, or expiry rules differ between sites.": "有 {count} 个存储桶、IAM 实体或过期规则在各站点间不一致。", + "{count} object replication failures in the last hour.": "近 1 小时有 {count} 次对象复制失败。", + "{operation} has not finished.": "{operation}尚未完成。", + "{operation} has not finished; waiting for {sites}.": "{operation}尚未完成,正在等待 {sites} 确认。", + "{pending} configuration changes are waiting to be delivered to peer sites, {failed} failed.": "有 {pending} 项配置变更等待下发到对等站点,{failed} 项失败。", + "{pending} waiting, {failed} failed": "等待 {pending},失败 {failed}", + "{state}: {completed} of {total} buckets completed, {failed} failed. {objects} objects replicated, {failedObjects} failed.": "{state}:{total} 个存储桶中已完成 {completed} 个,失败 {failed} 个;已复制 {objects} 个对象,失败 {failedObjects} 个。", + "Site removal": "站点移除", + "Endpoint update": "终端节点变更", + "Group": "用户组", + "ILM Expiry Rule": "ILM 过期规则", + "Marked for deletion": "已标记删除", + "Quota": "配额", + "CORS": "CORS", + "Policy document": "策略内容", + "Service account rotation": "服务账户轮换" } diff --git a/lib/functions.ts b/lib/functions.ts index 0ea77e8c..13386cbf 100644 --- a/lib/functions.ts +++ b/lib/functions.ts @@ -33,6 +33,21 @@ export function formatInteger(value?: number | null, fallback = "--"): string { return new Intl.NumberFormat(getRuntimeLocale(), { maximumFractionDigits: 0 }).format(value) } +/** Whole days, hours, and minutes, at most two units, e.g. "2 Days 3 Hours". */ +export function formatDuration(seconds: number | undefined, t: (key: string) => string): string { + if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown") + const totalMinutes = Math.floor(seconds / 60) + const days = Math.floor(totalMinutes / 1440) + const hours = Math.floor((totalMinutes % 1440) / 60) + const minutes = totalMinutes % 60 + const parts = [ + days ? `${days} ${t("Days")}` : "", + hours ? `${hours} ${t("Hours")}` : "", + !days && minutes ? `${minutes} ${t("Minutes")}` : "", + ].filter(Boolean) + return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}` +} + function parseDateTime(value?: string | number | Date | null): Date | null { if (value == null || value === "") return null diff --git a/lib/site-replication-health.ts b/lib/site-replication-health.ts new file mode 100644 index 00000000..b33f5390 --- /dev/null +++ b/lib/site-replication-health.ts @@ -0,0 +1,238 @@ +import type { + SiteReplicationBucketStats, + SiteReplicationInfo, + SiteReplicationPendingOperation, + SiteReplicationRetryStats, + SiteReplicationStatus, + SiteReplicationTimedErrors, + SiteReplicationVolume, +} from "@/types/site-replication" + +export type SiteReplicationHealthLevel = "healthy" | "degraded" | "critical" | "unknown" + +export type SiteReplicationConnectionState = "online" | "offline" | "unknown" + +export interface SiteReplicationConnection { + state: SiteReplicationConnectionState + lastOnline?: string + downtimeNs: number + error?: string +} + +export type SiteReplicationHealthIssue = + | { kind: "peer-offline"; deploymentId: string; error?: string } + | { kind: "pending-operation"; operation: SiteReplicationPendingOperation } + | { kind: "retry-backlog"; retryStats: SiteReplicationRetryStats } + | { kind: "recent-failures"; count: number } + | { kind: "drift"; count: number } + +export type SiteReplicationDriftEntity = "bucket" | "policy" | "user" | "group" | "ilmExpiryRule" + +export type SiteReplicationBucketMismatch = + | "markedDeleted" + | "tags" + | "versioning" + | "objectLock" + | "policy" + | "encryption" + | "replication" + | "quota" + | "cors" + +export interface SiteReplicationDriftItem { + entity: SiteReplicationDriftEntity + name: string + /** Deployment IDs of the sites that do not hold the entity. */ + missingOn: string[] + /** Settings that differ between sites; `policy` entities report "content". */ + mismatches: (SiteReplicationBucketMismatch | "content")[] +} + +export interface SiteReplicationHealth { + level: SiteReplicationHealthLevel + issues: SiteReplicationHealthIssue[] + connections: Record + /** Null when an unreachable peer makes the comparison meaningless. */ + drift: SiteReplicationDriftItem[] | null +} + +const BUCKET_MISMATCH_FLAGS: [keyof SiteReplicationBucketStats, SiteReplicationBucketMismatch][] = [ + ["bucketMarkedDeleted", "markedDeleted"], + ["tagMismatch", "tags"], + ["versioningConfigMismatch", "versioning"], + ["objectLockConfigMismatch", "objectLock"], + ["policyMismatch", "policy"], + ["sseConfigMismatch", "encryption"], + ["replicationCfgMismatch", "replication"], + ["quotaCfgMismatch", "quota"], + ["corsCfgMismatch", "cors"], +] + +function resolveConnection( + status: SiteReplicationStatus | null, + deploymentId: string, + isLocal: boolean, +): SiteReplicationConnection { + if (isLocal) { + return { state: "online", downtimeNs: 0 } + } + + const peerError = status?.peerErrors[deploymentId] + const metric = status?.metrics.metrics[deploymentId] + if (peerError || metric?.online === false) { + return { + state: "offline", + lastOnline: metric?.lastOnline, + downtimeNs: metric?.totalDowntimeNs ?? 0, + error: peerError?.error || undefined, + } + } + if (metric) { + return { state: "online", lastOnline: metric.lastOnline, downtimeNs: metric.totalDowntimeNs } + } + + // Servers before rustfs/rustfs#7024 only report the local metric entry. + return { state: "unknown", downtimeNs: 0 } +} + +/** + * Object replication failures counted by the node that answered the status + * request. Only the local metric entry carries them; summing every entry keeps + * the result independent of whether the local site could be identified. + */ +export function siteReplicationFailures(status: SiteReplicationStatus | null): SiteReplicationTimedErrors { + const add = (left: SiteReplicationVolume, right: SiteReplicationVolume) => ({ + count: left.count + right.count, + bytes: left.bytes + right.bytes, + }) + const empty = { count: 0, bytes: 0 } + + return Object.values(status?.metrics.metrics ?? {}).reduce( + (total, metric) => ({ + lastMinute: add(total.lastMinute, metric.failed.lastMinute), + lastHour: add(total.lastHour, metric.failed.lastHour), + totals: add(total.totals, metric.failed.totals), + }), + { lastMinute: empty, lastHour: empty, totals: empty }, + ) +} + +function missingSites(siteIds: string[], holders: Iterable) { + const held = new Set(holders) + return siteIds.filter((deploymentId) => !held.has(deploymentId)) +} + +function collectDrift(status: SiteReplicationStatus, siteIds: string[]): SiteReplicationDriftItem[] { + const drift: SiteReplicationDriftItem[] = [] + + // The server already prunes in-sync buckets and policies from these maps. + for (const [name, perSite] of Object.entries(status.bucketStats)) { + const stats = Object.values(perSite) + const missingOn = missingSites( + siteIds, + Object.entries(perSite) + .filter(([, item]) => item.hasBucket) + .map(([deploymentId]) => deploymentId), + ) + const mismatches = BUCKET_MISMATCH_FLAGS.filter(([flag]) => stats.some((item) => item[flag])).map( + ([, mismatch]) => mismatch, + ) + if (missingOn.length || mismatches.length) { + drift.push({ entity: "bucket", name, missingOn, mismatches }) + } + } + + for (const [name, perSite] of Object.entries(status.policyStats)) { + const missingOn = missingSites( + siteIds, + Object.entries(perSite) + .filter(([, item]) => item.hasPolicy) + .map(([deploymentId]) => deploymentId), + ) + const mismatched = Object.values(perSite).some((item) => item.policyMismatch) + if (missingOn.length || mismatched) { + drift.push({ entity: "policy", name, missingOn, mismatches: mismatched ? ["content"] : [] }) + } + } + + const presence: [SiteReplicationDriftEntity, Record][] = [ + ["user", status.userSites], + ["group", status.groupSites], + ["ilmExpiryRule", status.ilmExpiryRuleSites], + ] + for (const [entity, sitesByName] of presence) { + for (const [name, holders] of Object.entries(sitesByName)) { + const missingOn = missingSites(siteIds, holders) + if (missingOn.length) { + drift.push({ entity, name, missingOn, mismatches: [] }) + } + } + } + + return drift +} + +/** + * Turn the site replication info and status payloads into the answer an + * operator needs first: is replication working, and if not, why. + * + * `critical` means a peer cannot be reached, so nothing replicates to it. + * `degraded` means replication runs but something needs attention: an + * unfinished lifecycle operation, failing peer deliveries, object + * replication failures in the last hour, or configuration drift. + */ +export function assessSiteReplicationHealth(input: { + info: SiteReplicationInfo | null + status: SiteReplicationStatus | null + localDeploymentId: string +}): SiteReplicationHealth { + const { info, status, localDeploymentId } = input + const siteIds = (info?.sites ?? []).map((peer) => peer.deploymentId).filter(Boolean) + const connections = Object.fromEntries( + siteIds.map((deploymentId) => [ + deploymentId, + resolveConnection(status, deploymentId, deploymentId === localDeploymentId), + ]), + ) + + const issues: SiteReplicationHealthIssue[] = [] + for (const [deploymentId, connection] of Object.entries(connections)) { + if (connection.state === "offline") { + issues.push({ kind: "peer-offline", deploymentId, error: connection.error }) + } + } + + const pendingOperation = status?.pendingOperation ?? info?.pendingOperation + if (pendingOperation) { + issues.push({ kind: "pending-operation", operation: pendingOperation }) + } + + const retryStats = status?.retryStats ?? info?.retryStats + if (retryStats && (retryStats.pending > 0 || retryStats.failed > 0)) { + issues.push({ kind: "retry-backlog", retryStats }) + } + + const recentFailures = siteReplicationFailures(status).lastHour.count + if (recentFailures > 0) { + issues.push({ kind: "recent-failures", count: recentFailures }) + } + + // An unreachable peer reports empty metadata, which would flag every + // bucket and user as missing there. + const allPeersReachable = Object.values(connections).every((connection) => connection.state === "online") + const drift = status && allPeersReachable ? collectDrift(status, siteIds) : null + if (drift?.length) { + issues.push({ kind: "drift", count: drift.length }) + } + + let level: SiteReplicationHealthLevel + if (issues.some((issue) => issue.kind === "peer-offline")) { + level = "critical" + } else if (issues.length) { + level = "degraded" + } else { + level = status && allPeersReachable ? "healthy" : "unknown" + } + + return { level, issues, connections, drift } +} diff --git a/tests/lib/functions.test.ts b/tests/lib/functions.test.ts index ebec5ca3..c266a268 100644 --- a/tests/lib/functions.test.ts +++ b/tests/lib/functions.test.ts @@ -107,3 +107,13 @@ test("date formatting preserves half-hour timezone offsets", () => { ["2026-09-24 05:30:00", "2026-09-24 05:30:00 UTC+05:30"], ]) }) + +test("formatDuration keeps the two largest whole units", async () => { + const { formatDuration } = await import("../../lib/functions") + const t = (key: string) => key + + assert.equal(formatDuration(90, t), "1 Minutes") + assert.equal(formatDuration(3 * 86400 + 5 * 3600 + 7 * 60, t), "3 Days 5 Hours") + assert.equal(formatDuration(20, t), "0 Minutes") + assert.equal(formatDuration(-1, t), "Unknown") +}) diff --git a/tests/lib/site-replication-health.test.ts b/tests/lib/site-replication-health.test.ts new file mode 100644 index 00000000..497fc56b --- /dev/null +++ b/tests/lib/site-replication-health.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict" +import test from "node:test" + +const loadHealth = () => import(new URL("../../lib/site-replication-health.ts", import.meta.url).href) + +const LOCAL = "dep-local" +const PEER = "dep-peer" + +function volume(count = 0) { + return { count, bytes: count * 1024 } +} + +function metric(deploymentId: string, overrides: Record = {}) { + return { + deploymentId, + endpoint: `http://${deploymentId}:9000`, + totalDowntimeNs: 0, + lastOnline: "2026-10-23T10:00:00Z", + online: true, + replicatedSize: 0, + replicatedCount: 0, + failed: { lastMinute: volume(), lastHour: volume(), totals: volume() }, + ...overrides, + } +} + +function info(overrides: Record = {}) { + return { + enabled: true, + name: "site-local", + sites: [ + { deploymentId: LOCAL, name: "site-local", endpoint: "http://local:9000" }, + { deploymentId: PEER, name: "site-peer", endpoint: "http://peer:9000" }, + ], + serviceAccountAccessKey: "site-replicator-0", + ...overrides, + } +} + +function status(overrides: Record = {}, metrics?: Record) { + return { + enabled: true, + peerErrors: {}, + metrics: { + activeWorkers: { curr: 0, avg: 0, max: 0 }, + replicaSize: 0, + replicaCount: 0, + queued: { curr: volume(), avg: volume(), max: volume() }, + metrics: metrics ?? { [LOCAL]: metric(LOCAL), [PEER]: metric(PEER) }, + }, + bucketStats: {}, + policyStats: {}, + userSites: {}, + groupSites: {}, + ilmExpiryRuleSites: {}, + ...overrides, + } +} + +async function assess(input: { info?: unknown; status?: unknown; localDeploymentId?: string }) { + const { assessSiteReplicationHealth } = await loadHealth() + return assessSiteReplicationHealth({ + info: "info" in input ? input.info : info(), + status: "status" in input ? input.status : status(), + localDeploymentId: input.localDeploymentId ?? LOCAL, + }) +} + +test("a reachable group with nothing outstanding is healthy", async () => { + const health = await assess({}) + + assert.equal(health.level, "healthy") + assert.deepEqual(health.issues, []) + assert.deepEqual(health.drift, []) + assert.equal(health.connections[PEER].state, "online") +}) + +test("an offline peer is critical and keeps its last-online time and downtime", async () => { + const offline = metric(PEER, { online: false, totalDowntimeNs: 90e9, lastOnline: "2026-10-23T09:58:30Z" }) + const health = await assess({ status: status({}, { [LOCAL]: metric(LOCAL), [PEER]: offline }) }) + + assert.equal(health.level, "critical") + assert.deepEqual(health.connections[PEER], { + state: "offline", + lastOnline: "2026-10-23T09:58:30Z", + downtimeNs: 90e9, + error: undefined, + }) + assert.deepEqual(health.issues, [{ kind: "peer-offline", deploymentId: PEER, error: undefined }]) +}) + +test("a peer whose metainfo fetch failed is offline with the reported error", async () => { + const peerErrors = { [PEER]: { name: "site-peer", endpoint: "http://peer:9000", error: "connection refused" } } + const health = await assess({ status: status({ peerErrors }, { [LOCAL]: metric(LOCAL) }) }) + + assert.equal(health.connections[PEER].state, "offline") + assert.equal(health.connections[PEER].error, "connection refused") + assert.equal(health.level, "critical") +}) + +test("an unreachable peer suppresses the drift comparison instead of flagging everything missing", async () => { + const bucketStats = { photos: { [LOCAL]: { hasBucket: true }, [PEER]: { hasBucket: false } } } + const offline = metric(PEER, { online: false }) + const health = await assess({ status: status({ bucketStats }, { [LOCAL]: metric(LOCAL), [PEER]: offline }) }) + + assert.equal(health.drift, null) + assert.ok(!health.issues.some((issue: { kind: string }) => issue.kind === "drift")) +}) + +test("peers without a metric entry on older servers are unknown, never reported healthy", async () => { + const health = await assess({ status: status({}, { [LOCAL]: metric(LOCAL) }) }) + + assert.equal(health.connections[PEER].state, "unknown") + assert.equal(health.level, "unknown") + assert.equal(health.drift, null) +}) + +test("an unfinished lifecycle operation degrades health", async () => { + const pendingOperation = { operation: "remove", id: "op-1", pendingPeers: [PEER], ackedPeers: [] } + const health = await assess({ status: status({ pendingOperation }) }) + + assert.equal(health.level, "degraded") + assert.deepEqual(health.issues, [{ kind: "pending-operation", operation: pendingOperation }]) +}) + +test("failing peer deliveries degrade health while an empty retry queue does not", async () => { + const failing = await assess({ status: status({ retryStats: { pending: 2, failed: 1, lastError: "timeout" } }) }) + const idle = await assess({ status: status({ retryStats: { pending: 0, failed: 0, lastError: "" } }) }) + + assert.equal(failing.level, "degraded") + assert.equal(failing.issues[0].kind, "retry-backlog") + assert.equal(idle.level, "healthy") +}) + +test("object replication failures in the last hour degrade health but older ones do not", async () => { + const recent = metric(LOCAL, { failed: { lastMinute: volume(), lastHour: volume(3), totals: volume(3) } }) + const old = metric(LOCAL, { failed: { lastMinute: volume(), lastHour: volume(), totals: volume(7) } }) + + const recentHealth = await assess({ status: status({}, { [LOCAL]: recent, [PEER]: metric(PEER) }) }) + const oldHealth = await assess({ status: status({}, { [LOCAL]: old, [PEER]: metric(PEER) }) }) + + assert.deepEqual(recentHealth.issues, [{ kind: "recent-failures", count: 3 }]) + assert.equal(oldHealth.level, "healthy") +}) + +test("drift lists missing and mismatched buckets, policies, and users per site", async () => { + const bucketStats = { + photos: { + [LOCAL]: { hasBucket: true, policyMismatch: true, tagMismatch: false }, + [PEER]: { hasBucket: true, policyMismatch: true, tagMismatch: false }, + }, + logs: { [LOCAL]: { hasBucket: true } }, + } + const policyStats = { readonly: { [LOCAL]: { hasPolicy: true, policyMismatch: true }, [PEER]: { hasPolicy: true } } } + const userSites = { alice: [LOCAL, PEER], bob: [LOCAL] } + + const health = await assess({ status: status({ bucketStats, policyStats, userSites }) }) + + assert.equal(health.level, "degraded") + assert.deepEqual(health.drift, [ + { entity: "bucket", name: "photos", missingOn: [], mismatches: ["policy"] }, + { entity: "bucket", name: "logs", missingOn: [PEER], mismatches: [] }, + { entity: "policy", name: "readonly", missingOn: [], mismatches: ["content"] }, + { entity: "user", name: "bob", missingOn: [PEER], mismatches: [] }, + ]) + assert.deepEqual(health.issues, [{ kind: "drift", count: 4 }]) +}) + +test("without the status endpoint the info payload still surfaces outstanding work", async () => { + const pendingOperation = { operation: "remove", id: "op-1", pendingPeers: [PEER], ackedPeers: [] } + const withoutWork = await assess({ status: null }) + const withWork = await assess({ status: null, info: info({ pendingOperation }) }) + + assert.equal(withoutWork.level, "unknown") + assert.equal(withWork.level, "degraded") +}) + +test("the local site is online even when its own metric entry is missing", async () => { + const health = await assess({ status: status({}, { [PEER]: metric(PEER) }) }) + + assert.equal(health.connections[LOCAL].state, "online") + assert.equal(health.level, "healthy") +}) diff --git a/types/site-replication.ts b/types/site-replication.ts index abdef5e1..753da746 100644 --- a/types/site-replication.ts +++ b/types/site-replication.ts @@ -19,11 +19,27 @@ export interface SiteReplicationPeerInfo { apiVersion?: string } +export interface SiteReplicationPendingOperation { + operation: string + id: string + pendingPeers: string[] + ackedPeers: string[] + updatedAt?: string +} + +export interface SiteReplicationRetryStats { + pending: number + failed: number + lastError: string +} + export interface SiteReplicationInfo { enabled: boolean name: string sites: SiteReplicationPeerInfo[] serviceAccountAccessKey: string + pendingOperation?: SiteReplicationPendingOperation + retryStats?: SiteReplicationRetryStats apiVersion?: string } @@ -66,6 +82,17 @@ export interface SiteReplicationStateInfo { apiVersion?: string } +export interface SiteReplicationVolume { + count: number + bytes: number +} + +export interface SiteReplicationTimedErrors { + lastMinute: SiteReplicationVolume + lastHour: SiteReplicationVolume + totals: SiteReplicationVolume +} + export interface SiteReplicationMetric { deploymentId: string endpoint: string @@ -74,6 +101,7 @@ export interface SiteReplicationMetric { online: boolean replicatedSize: number replicatedCount: number + failed: SiteReplicationTimedErrors } export interface SiteReplicationWorkerStat { @@ -82,13 +110,47 @@ export interface SiteReplicationWorkerStat { max: number } +export interface SiteReplicationQueueStat { + curr: SiteReplicationVolume + avg: SiteReplicationVolume + max: SiteReplicationVolume +} + export interface SiteReplicationMetricsSummary { activeWorkers: SiteReplicationWorkerStat replicaSize: number replicaCount: number + queued: SiteReplicationQueueStat metrics: Record } +export interface SiteReplicationPeerError { + name: string + endpoint: string + error: string +} + +export interface SiteReplicationBucketStats { + hasBucket: boolean + bucketMarkedDeleted: boolean + tagMismatch: boolean + versioningConfigMismatch: boolean + objectLockConfigMismatch: boolean + policyMismatch: boolean + sseConfigMismatch: boolean + replicationCfgMismatch: boolean + quotaCfgMismatch: boolean + corsCfgMismatch: boolean +} + +export interface SiteReplicationPolicyStats { + hasPolicy: boolean + policyMismatch: boolean +} + +/** Per-entity details keyed by entity name, then by deployment ID. */ +export type SiteReplicationEntityStats = Record> + export interface SiteReplicationStatus { enabled: boolean maxBuckets: number @@ -99,7 +161,16 @@ export interface SiteReplicationStatus { sites: Record statsSummary: Record peerStates: Record + peerErrors: Record + pendingOperation?: SiteReplicationPendingOperation + retryStats?: SiteReplicationRetryStats metrics: SiteReplicationMetricsSummary + bucketStats: SiteReplicationEntityStats + policyStats: SiteReplicationEntityStats + /** Deployment IDs that hold each user, group, or ILM expiry rule. */ + userSites: Record + groupSites: Record + ilmExpiryRuleSites: Record apiVersion?: string } @@ -138,7 +209,7 @@ export interface SiteReplicationRemoveResponse { apiVersion?: string } -export type SiteReplicationResyncOperation = "start" | "cancel" +export type SiteReplicationResyncOperation = "start" | "cancel" | "status" export interface SiteReplicationResyncBucketStatus { bucket: string @@ -150,6 +221,13 @@ export interface SiteReplicationResyncResponse { opType: string resyncId: string status: string + state: string buckets: SiteReplicationResyncBucketStatus[] + totalBuckets: number + completedBuckets: number + failedBuckets: number + replicatedObjects: number + failedObjects: number + updatedAt?: string errorDetail: string }