@kyro-cms/admin 0.12.4 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/index.cjs +64 -26
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +64 -26
  5. package/package.json +9 -3
  6. package/src/components/ActionBar.tsx +16 -16
  7. package/src/components/ApiHealth.tsx +522 -0
  8. package/src/components/ApiKeysManager.tsx +6 -4
  9. package/src/components/AuditLogsPage.tsx +4 -2
  10. package/src/components/AutoForm.tsx +10 -7
  11. package/src/components/BrandingHub.tsx +3 -1
  12. package/src/components/Dashboard.tsx +44 -37
  13. package/src/components/DashboardMetrics.tsx +168 -58
  14. package/src/components/DetailView.tsx +41 -38
  15. package/src/components/DeveloperCenter.tsx +5 -3
  16. package/src/components/GraphQLPlayground.tsx +10 -8
  17. package/src/components/ListView.tsx +66 -45
  18. package/src/components/LoginPage.tsx +21 -19
  19. package/src/components/MarketplaceManager.tsx +3 -1
  20. package/src/components/MediaGallery.tsx +12 -9
  21. package/src/components/PluginsManager.tsx +6 -4
  22. package/src/components/RestPlayground.tsx +8 -6
  23. package/src/components/SessionsManager.tsx +3 -1
  24. package/src/components/Sidebar.astro +180 -92
  25. package/src/components/UserManagement.tsx +8 -6
  26. package/src/components/UserMenu.tsx +19 -17
  27. package/src/components/VersionHistoryPanel.tsx +6 -4
  28. package/src/components/WebhookManager.tsx +11 -10
  29. package/src/components/autoform/AutoFormApiView.tsx +3 -1
  30. package/src/components/autoform/AutoFormEditView.tsx +3 -1
  31. package/src/components/autoform/AutoFormHeader.tsx +6 -6
  32. package/src/components/blocks/AccordionBlock.tsx +5 -3
  33. package/src/components/blocks/ArrayBlock.tsx +5 -3
  34. package/src/components/blocks/BlockWrapper.tsx +5 -3
  35. package/src/components/blocks/CodeBlock.tsx +4 -2
  36. package/src/components/blocks/FileBlock.tsx +5 -3
  37. package/src/components/blocks/HeroBlock.tsx +5 -3
  38. package/src/components/blocks/ImageBlock.tsx +6 -4
  39. package/src/components/blocks/ListBlock.tsx +5 -3
  40. package/src/components/blocks/ParagraphBlock.tsx +3 -1
  41. package/src/components/blocks/RelationshipBlock.tsx +5 -3
  42. package/src/components/blocks/RichTextBlock.tsx +5 -3
  43. package/src/components/blocks/VideoBlock.tsx +5 -3
  44. package/src/components/fields/AccordionField.tsx +10 -51
  45. package/src/components/fields/ArrayField.tsx +9 -7
  46. package/src/components/fields/ArrayLayout.tsx +3 -1
  47. package/src/components/fields/BlocksField.tsx +24 -75
  48. package/src/components/fields/ButtonField.tsx +3 -1
  49. package/src/components/fields/CardField.tsx +6 -4
  50. package/src/components/fields/GroupLayout.tsx +2 -49
  51. package/src/components/fields/HeadingField.tsx +3 -1
  52. package/src/components/fields/HeadingSubheadingField.tsx +4 -2
  53. package/src/components/fields/HeroField.tsx +8 -6
  54. package/src/components/fields/JSONField.tsx +9 -10
  55. package/src/components/fields/LinkField.tsx +4 -2
  56. package/src/components/fields/ListField.tsx +3 -1
  57. package/src/components/fields/MarkdownField.tsx +3 -17
  58. package/src/components/fields/RelationshipBlockField.tsx +7 -5
  59. package/src/components/fields/RelationshipField.tsx +3 -1
  60. package/src/components/fields/RichTextField.tsx +19 -16
  61. package/src/components/fields/SecretField.tsx +9 -69
  62. package/src/components/fields/SelectField.tsx +11 -6
  63. package/src/components/fields/TabsLayout.tsx +2 -48
  64. package/src/components/fields/TextField.tsx +9 -7
  65. package/src/components/fields/UploadField.tsx +3 -1
  66. package/src/components/fields/VideoField.tsx +6 -4
  67. package/src/components/ui/BlockDrawer.tsx +3 -1
  68. package/src/components/ui/CommandPalette.tsx +9 -7
  69. package/src/components/ui/CommandPaletteWrapper.tsx +3 -1
  70. package/src/components/ui/IconPickerModal.tsx +4 -2
  71. package/src/components/ui/PageHeader.tsx +48 -19
  72. package/src/components/users/UserDetail.tsx +3 -1
  73. package/src/components/users/UserForm.tsx +5 -3
  74. package/src/fields/examples/sample-field-2.tsx +3 -1
  75. package/src/fields/examples/sample-field.tsx +3 -1
  76. package/src/hooks/useAutoFormState.ts +19 -11
  77. package/src/integration.ts +21 -0
  78. package/src/layouts/AdminLayout.astro +252 -189
  79. package/src/layouts/AuthLayout.astro +52 -31
  80. package/src/lib/api.ts +7 -7
  81. package/src/lib/deep-equal.ts +3 -3
  82. package/src/lib/i18n.ts +44 -0
  83. package/src/lib/vite-shim-plugin.ts +3 -0
  84. package/src/locales/de.json +163 -0
  85. package/src/locales/en.json +163 -0
  86. package/src/locales/es.json +163 -0
  87. package/src/locales/fr.json +163 -0
  88. package/src/locales/pt.json +163 -0
  89. package/src/pages/403.astro +2 -1
  90. package/src/pages/[collection]/[id].astro +23 -10
  91. package/src/pages/[collection]/index.astro +48 -34
  92. package/src/pages/audit/index.astro +2 -1
  93. package/src/pages/graphql-explorer.astro +2 -1
  94. package/src/pages/graphql.astro +2 -1
  95. package/src/pages/health.astro +9 -0
  96. package/src/pages/index.astro +6 -5
  97. package/src/pages/keys.astro +2 -1
  98. package/src/pages/marketplace.astro +2 -1
  99. package/src/pages/media.astro +2 -1
  100. package/src/pages/plugins.astro +2 -1
  101. package/src/pages/rest-playground.astro +2 -1
  102. package/src/pages/roles/index.astro +16 -52
  103. package/src/pages/sessions.astro +2 -1
  104. package/src/pages/settings/[slug].astro +45 -30
  105. package/src/pages/users/[id].astro +1 -0
  106. package/src/pages/users/index.astro +2 -1
  107. package/src/pages/users/new.astro +2 -1
  108. package/src/pages/webhooks.astro +2 -1
  109. package/src/pages/index_ALT.astro +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -59,6 +59,7 @@
59
59
  "dependencies": {
60
60
  "@astrojs/node": "^10.1.0",
61
61
  "@astrojs/react": "^5.0.4",
62
+ "@codemirror/autocomplete": "^6.20.3",
62
63
  "@codemirror/lang-cpp": "^6.0.3",
63
64
  "@codemirror/lang-css": "^6.3.1",
64
65
  "@codemirror/lang-html": "^6.4.11",
@@ -76,7 +77,7 @@
76
77
  "@dnd-kit/sortable": "^10.0.0",
77
78
  "@dnd-kit/utilities": "^3.2.2",
78
79
  "@graphiql/react": "^0.37.3",
79
- "@kyro-cms/core": "file:..",
80
+ "@kyro-cms/core": "workspace:*",
80
81
  "@tailwindcss/vite": "^4.0.0",
81
82
  "@tiptap/extension-color": "^3.23.6",
82
83
  "@tiptap/extension-highlight": "^3.23.6",
@@ -99,11 +100,15 @@
99
100
  "astro-loading-indicator": "^0.8.0",
100
101
  "dotenv": "^17.4.2",
101
102
  "graphiql": "^5.2.2",
103
+ "i18next": "^26.3.5",
104
+ "i18next-browser-languagedetector": "^8.2.1",
102
105
  "idb-keyval": "^6.2.2",
103
106
  "lucide-react": "^0.475.0",
104
107
  "react": "^19.0.0",
105
108
  "react-dom": "^19.0.0",
109
+ "react-i18next": "^17.0.8",
106
110
  "react-image-crop": "^11.0.10",
111
+ "recharts": "^3.9.2",
107
112
  "swup": "^4.9.0",
108
113
  "tailwindcss": "^4.0.0",
109
114
  "unstorage": "^1.17.5",
@@ -115,6 +120,7 @@
115
120
  "@types/react": "^19.0.0",
116
121
  "@types/react-dom": "^19.0.0",
117
122
  "dotenv-cli": "^11.0.0",
123
+ "i18next-parser": "^9.4.0",
118
124
  "tsup": "^8.5.1",
119
125
  "typescript": "^5.7.3",
120
126
  "vitest": "^4.1.4"
@@ -128,4 +134,4 @@
128
134
  "type": "git",
129
135
  "url": "https://github.com/danielDozie/kyro-cms"
130
136
  }
131
- }
137
+ }
@@ -1,9 +1,10 @@
1
- import React, { useState, useRef, useEffect } from "react";
2
- import { IconPlus, IconSend, IconClock, IconArchive, IconUndo, IconCopy, IconEye, IconTrash2, IconMoreVertical, ClipboardPaste, ClipboardCopy } from "./ui/icons";
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { IconSend, IconClock, IconUndo, IconCopy, IconEye, IconTrash2, IconMoreVertical, ClipboardPaste, ClipboardCopy } from "./ui/icons";
3
3
  import { DropdownItem, DropdownSeparator } from "./ui/Dropdown";
4
4
  import { SplitButton } from "./ui/SplitButton";
5
5
  import { Spinner } from "./ui/Spinner";
6
6
  import { useAutoFormStore } from "../lib/autoform-store";
7
+ import { useTranslation } from "react-i18next";
7
8
 
8
9
  export type DocumentStatus = "draft" | "published" | "scheduled" | "archived";
9
10
  export type SaveStatus = "idle" | "saving" | "saved" | "error";
@@ -59,6 +60,7 @@ export function ActionBar({
59
60
  onCopyData,
60
61
  onPasteData,
61
62
  }: ActionBarProps) {
63
+ const { t } = useTranslation();
62
64
  const view = useAutoFormStore((s) => s.view) || "edit";
63
65
  const setView = useAutoFormStore((s) => s.setView);
64
66
  const [moreOpen, setMoreOpen] = useState(false);
@@ -107,11 +109,10 @@ export function ActionBar({
107
109
  <div className="flex items-center gap-0.5 bg-[var(--kyro-bg-secondary)] p-0.5 rounded-lg border border-[var(--kyro-border)] shrink-0 max-md:hidden">
108
110
  {tabs.map((v) => (
109
111
  <button key={v} type="button" onClick={() => setView(v)}
110
- className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all ${
111
- view === v
112
+ className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all ${view === v
112
113
  ? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]"
113
114
  : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
114
- }`}
115
+ }`}
115
116
  >
116
117
  {v === "edit" ? "Edit" : v === "version" ? "Ver" : "API"}
117
118
  </button>
@@ -120,37 +121,37 @@ export function ActionBar({
120
121
 
121
122
  <div className="flex items-center gap-1 ml-auto shrink-0">
122
123
  {onPreview && (
123
- <button type="button" onClick={onPreview} className={`${iconBtnClass} max-md:hidden`} title="Preview">
124
+ <button type="button" onClick={onPreview} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.preview", { defaultValue: "Preview" })}>
124
125
  <IconEye className="w-3.5 h-3.5" />
125
126
  </button>
126
127
  )}
127
128
  {onViewHistory && (
128
- <button type="button" onClick={onViewHistory} className={`${iconBtnClass} max-md:hidden`} title="View History">
129
+ <button type="button" onClick={onViewHistory} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.viewHistory", { defaultValue: "View History" })}>
129
130
  <IconClock className="w-3.5 h-3.5" />
130
131
  </button>
131
132
  )}
132
133
  {onDuplicate && (
133
- <button type="button" onClick={onDuplicate} className={`${iconBtnClass} max-md:hidden`} title="Duplicate">
134
+ <button type="button" onClick={onDuplicate} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.duplicate", { defaultValue: "Duplicate" })}>
134
135
  <IconCopy className="w-3.5 h-3.5" />
135
136
  </button>
136
137
  )}
137
138
  {onCopyData && (
138
- <button type="button" onClick={onCopyData} className={`${iconBtnClass} max-md:hidden`} title="Copy Data">
139
+ <button type="button" onClick={onCopyData} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.copyData", { defaultValue: "Copy Data" })}>
139
140
  <ClipboardCopy className="w-3.5 h-3.5" />
140
141
  </button>
141
142
  )}
142
143
  {onPasteData && (
143
- <button type="button" onClick={onPasteData} className={`${iconBtnClass} max-md:hidden`} title="Paste Data">
144
+ <button type="button" onClick={onPasteData} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.pasteData", { defaultValue: "Paste Data" })}>
144
145
  <ClipboardPaste className="w-3.5 h-3.5" />
145
146
  </button>
146
147
  )}
147
148
  {onDelete && (
148
- <button type="button" onClick={onDelete} className={`${iconBtnClass} hover:bg-[var(--kyro-danger-bg)] hover:text-[var(--kyro-danger)] max-md:hidden`} title="Delete">
149
+ <button type="button" onClick={onDelete} className={`${iconBtnClass} hover:bg-[var(--kyro-danger-bg)] hover:text-[var(--kyro-danger)] max-md:hidden`} title={t("tooltips.delete", { defaultValue: "Delete" })}>
149
150
  <IconTrash2 className="w-3.5 h-3.5" />
150
151
  </button>
151
152
  )}
152
153
  {onToggleSidebar && (
153
- <button type="button" onClick={onToggleSidebar} className={`${iconBtnClass} max-md:hidden`} title="Toggle Sidebar">
154
+ <button type="button" onClick={onToggleSidebar} className={`${iconBtnClass} max-md:hidden`} title={t("tooltips.toggleSidebar", { defaultValue: "Toggle Sidebar" })}>
154
155
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
155
156
  <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
156
157
  <line x1="9" y1="3" x2="9" y2="21" />
@@ -160,7 +161,7 @@ export function ActionBar({
160
161
 
161
162
  {/* Mobile overflow menu */}
162
163
  <div ref={moreRef} className="relative md:hidden">
163
- <button type="button" onClick={() => setMoreOpen(!moreOpen)} className={iconBtnClass} title="More">
164
+ <button type="button" onClick={() => setMoreOpen(!moreOpen)} className={iconBtnClass} title={t("tooltips.more", { defaultValue: "More" })}>
164
165
  <IconMoreVertical className="w-3.5 h-3.5" />
165
166
  </button>
166
167
  {moreOpen && (
@@ -177,11 +178,10 @@ export function ActionBar({
177
178
  <div className="flex gap-1 px-2 py-2 border-b border-[var(--kyro-border)]">
178
179
  {tabs.map((v) => (
179
180
  <button key={v} type="button" onClick={() => { setView(v); setMoreOpen(false); }}
180
- className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all flex-1 ${
181
- view === v
181
+ className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all flex-1 ${view === v
182
182
  ? "bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)]"
183
183
  : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
184
- }`}
184
+ }`}
185
185
  >
186
186
  {v === "edit" ? "Edit" : v === "version" ? "Ver" : "API"}
187
187
  </button>
@@ -0,0 +1,522 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { apiGet } from "../lib/api";
3
+ import {
4
+ Activity,
5
+ CheckCircle2,
6
+ XCircle,
7
+ RefreshCcw,
8
+ Server,
9
+ Clock,
10
+ Database,
11
+ Shield,
12
+ Zap,
13
+ AlertTriangle,
14
+ Globe,
15
+ Box,
16
+ } from "./ui/icons";
17
+ import { PageHeader } from "./ui/PageHeader";
18
+ import { Badge } from "./ui/Badge";
19
+ import { Shimmer } from "./ui/Shimmer";
20
+ import { useTranslation } from "react-i18next";
21
+
22
+ interface HealthData {
23
+ status: string;
24
+ version: string;
25
+ collections: string[];
26
+ timestamp: string;
27
+ }
28
+
29
+ interface MetricsData {
30
+ totalDocuments: number;
31
+ totalMedia?: number;
32
+ totalUsers?: number;
33
+ totalWebhooks?: number;
34
+ totalApiKeys?: number;
35
+ totalStoredRecords: number;
36
+ collectionCounts: Record<string, number>;
37
+ collections: number;
38
+ timestamp: string;
39
+ }
40
+
41
+ function formatTimestamp(iso: string): string {
42
+ return new Date(iso).toLocaleString("en-US", {
43
+ dateStyle: "medium",
44
+ timeStyle: "medium",
45
+ });
46
+ }
47
+
48
+ function timeSince(iso: string): string {
49
+ const diff = Date.now() - new Date(iso).getTime();
50
+ const secs = Math.floor(diff / 1000);
51
+ if (secs < 60) return `${secs}s ago`;
52
+ const mins = Math.floor(secs / 60);
53
+ if (mins < 60) return `${mins}m ago`;
54
+ const hrs = Math.floor(mins / 60);
55
+ return `${hrs}h ago`;
56
+ }
57
+
58
+ interface StatCardProps {
59
+ icon: React.ReactNode;
60
+ label: string;
61
+ value: string | number;
62
+ sub?: string;
63
+ color: string;
64
+ loading?: boolean;
65
+ }
66
+
67
+ function StatCard({ icon, label, value, sub, color, loading }: StatCardProps) {
68
+ return (
69
+ <div
70
+ className="relative overflow-hidden rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] p-5 flex flex-col gap-3 hover:border-[var(--kyro-border-hover,var(--kyro-border))] transition-colors"
71
+ >
72
+ <div
73
+ className={`w-9 h-9 rounded-lg flex items-center justify-center ${color}`}
74
+ >
75
+ {icon}
76
+ </div>
77
+ {loading ? (
78
+ <div className="space-y-1.5">
79
+ <Shimmer variant="text" className="w-16" />
80
+ <Shimmer variant="text" className="w-24" />
81
+ </div>
82
+ ) : (
83
+ <>
84
+ <div>
85
+ <p className="text-2xl font-bold text-[var(--kyro-text-primary)] leading-none tracking-tight">
86
+ {value}
87
+ </p>
88
+ {sub && (
89
+ <p className="text-[11px] text-[var(--kyro-text-muted)] mt-1">{sub}</p>
90
+ )}
91
+ </div>
92
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] uppercase tracking-wider">
93
+ {label}
94
+ </p>
95
+ </>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ interface ServiceRowProps {
102
+ name: string;
103
+ description: string;
104
+ status: "online" | "offline" | "unknown";
105
+ detail?: string;
106
+ icon: React.ReactNode;
107
+ loading?: boolean;
108
+ }
109
+
110
+ function ServiceRow({ name, description, status, detail, icon, loading }: ServiceRowProps) {
111
+ const statusConfig = {
112
+ online: {
113
+ dot: "bg-emerald-500",
114
+ badge: "success" as const,
115
+ label: "Online",
116
+ },
117
+ offline: {
118
+ dot: "bg-red-500",
119
+ badge: "danger" as const,
120
+ label: "Offline",
121
+ },
122
+ unknown: {
123
+ dot: "bg-yellow-500",
124
+ badge: "warning" as const,
125
+ label: "Unknown",
126
+ },
127
+ };
128
+ const cfg = statusConfig[status];
129
+
130
+ return (
131
+ <div className="flex items-center gap-4 py-3.5 px-4 rounded-xl hover:bg-[var(--kyro-surface-accent)] transition-colors group">
132
+ <div className="w-9 h-9 rounded-lg bg-[var(--kyro-surface-accent)] group-hover:bg-[var(--kyro-surface)] flex items-center justify-center text-[var(--kyro-text-secondary)] transition-colors flex-shrink-0">
133
+ {icon}
134
+ </div>
135
+ <div className="flex-1 min-w-0">
136
+ <p className="text-sm font-semibold text-[var(--kyro-text-primary)]">{name}</p>
137
+ <p className="text-xs text-[var(--kyro-text-muted)] truncate">{description}</p>
138
+ </div>
139
+ {loading ? (
140
+ <Shimmer variant="text" className="w-16" />
141
+ ) : (
142
+ <div className="flex items-center gap-2.5 flex-shrink-0">
143
+ {detail && (
144
+ <span className="text-xs text-[var(--kyro-text-muted)] hidden sm:block">{detail}</span>
145
+ )}
146
+ <div className="flex items-center gap-1.5">
147
+ <span className={`w-2 h-2 rounded-full ${cfg.dot} animate-pulse`} />
148
+ <Badge variant={cfg.badge}>{cfg.label}</Badge>
149
+ </div>
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ export function ApiHealth() {
157
+ const { t } = useTranslation();
158
+ const [health, setHealth] = useState<HealthData | null>(null);
159
+ const [metrics, setMetrics] = useState<MetricsData | null>(null);
160
+ const [healthLoading, setHealthLoading] = useState(true);
161
+ const [metricsLoading, setMetricsLoading] = useState(true);
162
+ const [healthError, setHealthError] = useState(false);
163
+ const [lastRefreshed, setLastRefreshed] = useState<Date>(new Date());
164
+ const [refreshing, setRefreshing] = useState(false);
165
+
166
+ const fetchHealth = useCallback(async () => {
167
+ try {
168
+ const data = await apiGet<HealthData>("/api/health");
169
+ setHealth(data);
170
+ setHealthError(false);
171
+ } catch {
172
+ setHealthError(true);
173
+ } finally {
174
+ setHealthLoading(false);
175
+ }
176
+ }, []);
177
+
178
+ const fetchMetrics = useCallback(async () => {
179
+ try {
180
+ const data = await apiGet<MetricsData>("/api/metrics", { autoToast: false });
181
+ setMetrics(data);
182
+ } catch {
183
+ // metrics may not be available for non-admins — silently ignore
184
+ } finally {
185
+ setMetricsLoading(false);
186
+ }
187
+ }, []);
188
+
189
+ const refresh = useCallback(async () => {
190
+ setRefreshing(true);
191
+ setHealthLoading(true);
192
+ setMetricsLoading(true);
193
+ await Promise.all([fetchHealth(), fetchMetrics()]);
194
+ setLastRefreshed(new Date());
195
+ setRefreshing(false);
196
+ }, [fetchHealth, fetchMetrics]);
197
+
198
+ useEffect(() => {
199
+ fetchHealth();
200
+ fetchMetrics();
201
+ // Auto-refresh every 60 seconds
202
+ const interval = setInterval(() => {
203
+ fetchHealth();
204
+ fetchMetrics();
205
+ setLastRefreshed(new Date());
206
+ }, 60_000);
207
+ return () => clearInterval(interval);
208
+ }, []);
209
+
210
+ const isHealthy = !healthError && health?.status === "ok";
211
+ const overallStatus: "online" | "offline" | "unknown" = healthLoading
212
+ ? "unknown"
213
+ : isHealthy
214
+ ? "online"
215
+ : "offline";
216
+
217
+ const collectionEntries = Object.entries(metrics?.collectionCounts ?? {}).sort(
218
+ (a, b) => b[1] - a[1]
219
+ );
220
+
221
+ return (
222
+ <div className="flex-1 overflow-y-auto">
223
+ <PageHeader
224
+ title={t("tooltips.apiHealth", { defaultValue: "API Health" })}
225
+ description="Real-time status and diagnostics for the Kyro CMS API"
226
+ icon={Activity}
227
+ actions={
228
+ <button
229
+ onClick={refresh}
230
+ disabled={refreshing}
231
+ className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-border)] border border-[var(--kyro-border)] text-sm font-medium text-[var(--kyro-text-primary)] transition-all active:scale-95 disabled:opacity-60"
232
+ >
233
+ <RefreshCcw
234
+ className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
235
+ />
236
+ Refresh
237
+ </button>
238
+ }
239
+ />
240
+
241
+ <div className="space-y-6">
242
+ {/* Overall status banner */}
243
+ <div
244
+ className={`relative overflow-hidden rounded-2xl border p-6 flex items-center gap-5 ${healthLoading
245
+ ? "border-[var(--kyro-border)] bg-[var(--kyro-surface)]"
246
+ : isHealthy
247
+ ? "border-emerald-500/20 bg-[var(--kyro-surface)]"
248
+ : "border-red-500/20 bg-[var(--kyro-surface)]"
249
+ }`}
250
+ >
251
+ <div
252
+ className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${healthLoading
253
+ ? "bg-[var(--kyro-surface-accent)]"
254
+ : isHealthy
255
+ ? "bg-emerald-500/10 text-emerald-500"
256
+ : "bg-red-500/10 text-red-500"
257
+ }`}
258
+ >
259
+ {healthLoading ? (
260
+ <Activity className="w-6 h-6 text-[var(--kyro-text-muted)]" />
261
+ ) : isHealthy ? (
262
+ <CheckCircle2 className="w-6 h-6" />
263
+ ) : (
264
+ <XCircle className="w-6 h-6" />
265
+ )}
266
+ </div>
267
+
268
+ <div className="flex-1 min-w-0">
269
+ {healthLoading ? (
270
+ <div className="space-y-2">
271
+ <Shimmer variant="text" className="w-40" />
272
+ <Shimmer variant="text" className="w-64" />
273
+ </div>
274
+ ) : (
275
+ <>
276
+ <div className="flex items-center gap-3 flex-wrap">
277
+ <h2 className="text-lg font-bold text-[var(--kyro-text-primary)] tracking-tight">
278
+ {isHealthy ? "All Systems Operational" : "Service Disruption Detected"}
279
+ </h2>
280
+ <Badge variant={isHealthy ? "success" : "danger"} dot>
281
+ {isHealthy ? "Healthy" : "Degraded"}
282
+ </Badge>
283
+ </div>
284
+ <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
285
+ {isHealthy
286
+ ? `Kyro CMS v${health?.version} is running normally. API is reachable and responding correctly.`
287
+ : "The API is not responding correctly. Check your server logs for more information."}
288
+ </p>
289
+ <p className="text-[11px] text-[var(--kyro-text-muted)] mt-2">
290
+ Last checked: {lastRefreshed.toLocaleTimeString()}
291
+ </p>
292
+ </>
293
+ )}
294
+ </div>
295
+ </div>
296
+
297
+ {/* Stats grid */}
298
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
299
+ <StatCard
300
+ icon={<Zap className="w-5 h-5" />}
301
+ color="bg-emerald-500/10 text-emerald-500"
302
+ label="API Status"
303
+ value={healthLoading ? "—" : health?.status === "ok" ? "OK" : "Error"}
304
+ sub={health?.timestamp ? timeSince(health.timestamp) : undefined}
305
+ loading={healthLoading}
306
+ />
307
+ <StatCard
308
+ icon={<Server className="w-5 h-5" />}
309
+ color="bg-blue-500/10 text-blue-500"
310
+ label="CMS Version"
311
+ value={health?.version ? `v${health.version}` : "—"}
312
+ sub="kyro-cms core"
313
+ loading={healthLoading}
314
+ />
315
+ <StatCard
316
+ icon={<Box className="w-5 h-5" />}
317
+ color="bg-violet-500/10 text-violet-500"
318
+ label="Collections"
319
+ value={health?.collections?.length ?? "—"}
320
+ sub="registered schemas"
321
+ loading={healthLoading}
322
+ />
323
+ <StatCard
324
+ icon={<Database className="w-5 h-5" />}
325
+ color="bg-orange-500/10 text-orange-500"
326
+ label="Total Records"
327
+ value={metrics ? metrics.totalStoredRecords.toLocaleString() : "—"}
328
+ sub="across all collections"
329
+ loading={metricsLoading}
330
+ />
331
+ </div>
332
+
333
+ {/* Two-column detail section */}
334
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
335
+ {/* Services */}
336
+ <div className="rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] overflow-hidden">
337
+ <div className="px-5 py-4 border-b border-[var(--kyro-border)] flex items-center gap-2">
338
+ <Shield className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
339
+ <h3 className="text-sm font-semibold text-[var(--kyro-text-primary)]">
340
+ Service Status
341
+ </h3>
342
+ </div>
343
+ <div className="p-2 divide-y divide-[var(--kyro-border)]">
344
+ <ServiceRow
345
+ name="REST API"
346
+ description="Core HTTP endpoints for all collection operations"
347
+ status={overallStatus}
348
+ detail={health?.timestamp ? formatTimestamp(health.timestamp) : undefined}
349
+ icon={<Globe className="w-4 h-4" />}
350
+ loading={healthLoading}
351
+ />
352
+ <ServiceRow
353
+ name="Database"
354
+ description="Primary data store connection"
355
+ status={metrics ? "online" : healthLoading || metricsLoading ? "unknown" : "offline"}
356
+ detail={metrics ? `${metrics.totalStoredRecords.toLocaleString()} records` : undefined}
357
+ icon={<Database className="w-4 h-4" />}
358
+ loading={metricsLoading}
359
+ />
360
+ <ServiceRow
361
+ name="Auth Service"
362
+ description="Authentication and session management"
363
+ status={overallStatus}
364
+ detail={metrics?.totalUsers !== undefined ? `${metrics.totalUsers} users` : undefined}
365
+ icon={<Shield className="w-4 h-4" />}
366
+ loading={healthLoading}
367
+ />
368
+ <ServiceRow
369
+ name="Webhooks"
370
+ description="Event delivery and outbound notifications"
371
+ status={overallStatus}
372
+ detail={metrics?.totalWebhooks !== undefined ? `${metrics.totalWebhooks} configured` : undefined}
373
+ icon={<Zap className="w-4 h-4" />}
374
+ loading={metricsLoading}
375
+ />
376
+ </div>
377
+ </div>
378
+
379
+ {/* System info */}
380
+ <div className="rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] overflow-hidden">
381
+ <div className="px-5 py-4 border-b border-[var(--kyro-border)] flex items-center gap-2">
382
+ <Clock className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
383
+ <h3 className="text-sm font-semibold text-[var(--kyro-text-primary)]">
384
+ System Info
385
+ </h3>
386
+ </div>
387
+ <div className="p-4 space-y-1">
388
+ {healthLoading ? (
389
+ <div className="space-y-3 py-2">
390
+ {[1, 2, 3].map((i) => (
391
+ <div key={i} className="flex justify-between items-center">
392
+ <Shimmer variant="text" className="w-28" />
393
+ <Shimmer variant="text" className="w-36" />
394
+ </div>
395
+ ))}
396
+ </div>
397
+ ) : (
398
+ <>
399
+ {[
400
+ {
401
+ label: "Version",
402
+ value: health?.version ? `v${health.version}` : "—",
403
+ },
404
+ {
405
+ label: "Server Time",
406
+ value: health?.timestamp ? formatTimestamp(health.timestamp) : "—",
407
+ },
408
+ {
409
+ label: "Collections",
410
+ value: health?.collections?.length ?? "—",
411
+ },
412
+ {
413
+ label: "API Keys",
414
+ value: metrics?.totalApiKeys !== undefined ? metrics.totalApiKeys : "—",
415
+ },
416
+ {
417
+ label: "Media Files",
418
+ value: metrics?.totalMedia !== undefined ? metrics.totalMedia.toLocaleString() : "—",
419
+ },
420
+ {
421
+ label: "Total Users",
422
+ value: metrics?.totalUsers !== undefined ? metrics.totalUsers : "—",
423
+ },
424
+ {
425
+ label: "Webhooks",
426
+ value: metrics?.totalWebhooks !== undefined ? metrics.totalWebhooks : "—",
427
+ },
428
+ ].map(({ label, value }) => (
429
+ <div
430
+ key={label}
431
+ className="flex items-center justify-between py-2.5 border-b border-[var(--kyro-border)] last:border-0"
432
+ >
433
+ <span className="text-sm text-[var(--kyro-text-secondary)]">{label}</span>
434
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)]">
435
+ {String(value)}
436
+ </span>
437
+ </div>
438
+ ))}
439
+ </>
440
+ )}
441
+ </div>
442
+ </div>
443
+ </div>
444
+
445
+ {/* Collection breakdown */}
446
+ {(metricsLoading || collectionEntries.length > 0) && (
447
+ <div className="rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] overflow-hidden">
448
+ <div className="px-5 py-4 border-b border-[var(--kyro-border)] flex items-center gap-2">
449
+ <Box className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
450
+ <h3 className="text-sm font-semibold text-[var(--kyro-text-primary)]">
451
+ Collection Breakdown
452
+ </h3>
453
+ <span className="ml-auto text-xs text-[var(--kyro-text-muted)]">
454
+ Document counts
455
+ </span>
456
+ </div>
457
+ <div className="p-5">
458
+ {metricsLoading ? (
459
+ <div className="space-y-3">
460
+ {[1, 2, 3].map((i) => (
461
+ <div key={i} className="flex items-center gap-3">
462
+ <Shimmer variant="text" className="w-28" />
463
+ <div className="flex-1">
464
+ <Shimmer variant="text" className="w-full" />
465
+ </div>
466
+ <Shimmer variant="text" className="w-10" />
467
+ </div>
468
+ ))}
469
+ </div>
470
+ ) : (
471
+ <div className="space-y-2.5">
472
+ {collectionEntries.map(([slug, count]) => {
473
+ const maxCount = collectionEntries[0]?.[1] || 1;
474
+ const pct = Math.max(4, (count / maxCount) * 100);
475
+ return (
476
+ <div key={slug} className="flex items-center gap-3">
477
+ <span className="text-sm text-[var(--kyro-text-secondary)] w-36 shrink-0 truncate capitalize">
478
+ {slug.replace(/-/g, " ")}
479
+ </span>
480
+ <div className="flex-1 h-1.5 rounded-full bg-[var(--kyro-surface-accent)] overflow-hidden">
481
+ <div
482
+ className="h-full rounded-full bg-[var(--kyro-primary)] opacity-70 transition-all duration-700"
483
+ style={{ width: `${pct}%` }}
484
+ />
485
+ </div>
486
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)] w-12 text-right shrink-0">
487
+ {count.toLocaleString()}
488
+ </span>
489
+ </div>
490
+ );
491
+ })}
492
+ </div>
493
+ )}
494
+ </div>
495
+ </div>
496
+ )}
497
+
498
+ {/* Raw endpoint callout */}
499
+ <div className="rounded-2xl border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface)] p-5 flex items-start gap-3">
500
+ <AlertTriangle className="w-4 h-4 text-[var(--kyro-text-muted)] mt-0.5 flex-shrink-0" />
501
+ <div>
502
+ <p className="text-sm font-medium text-[var(--kyro-text-primary)]">
503
+ Raw Health Endpoint
504
+ </p>
505
+ <p className="text-xs text-[var(--kyro-text-muted)] mt-0.5">
506
+ The raw JSON response is available at{" "}
507
+ <a
508
+ href="/api/health"
509
+ target="_blank"
510
+ rel="noopener noreferrer"
511
+ className="underline text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
512
+ >
513
+ /api/health
514
+ </a>{" "}
515
+ — useful for external monitoring tools and uptime checks.
516
+ </p>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ );
522
+ }