@kyro-cms/admin 0.12.3 → 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.
- package/dist/index.cjs +64 -26
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +64 -26
- package/package.json +8 -2
- package/src/components/ActionBar.tsx +16 -16
- package/src/components/ApiHealth.tsx +522 -0
- package/src/components/ApiKeysManager.tsx +6 -4
- package/src/components/AuditLogsPage.tsx +4 -2
- package/src/components/AutoForm.tsx +10 -7
- package/src/components/BrandingHub.tsx +3 -1
- package/src/components/Dashboard.tsx +44 -37
- package/src/components/DashboardMetrics.tsx +168 -58
- package/src/components/DetailView.tsx +125 -50
- package/src/components/DeveloperCenter.tsx +5 -3
- package/src/components/GraphQLPlayground.tsx +10 -8
- package/src/components/ListView.tsx +66 -45
- package/src/components/LoginPage.tsx +21 -19
- package/src/components/MarketplaceManager.tsx +3 -1
- package/src/components/MediaGallery.tsx +12 -9
- package/src/components/PluginsManager.tsx +6 -4
- package/src/components/RestPlayground.tsx +8 -6
- package/src/components/SessionsManager.tsx +3 -1
- package/src/components/Sidebar.astro +180 -92
- package/src/components/UserManagement.tsx +8 -6
- package/src/components/UserMenu.tsx +19 -17
- package/src/components/VersionHistoryPanel.tsx +6 -4
- package/src/components/WebhookManager.tsx +143 -15
- package/src/components/autoform/AutoFormApiView.tsx +3 -1
- package/src/components/autoform/AutoFormEditView.tsx +5 -3
- package/src/components/autoform/AutoFormHeader.tsx +6 -6
- package/src/components/blocks/AccordionBlock.tsx +5 -3
- package/src/components/blocks/ArrayBlock.tsx +5 -3
- package/src/components/blocks/BlockWrapper.tsx +5 -3
- package/src/components/blocks/CodeBlock.tsx +4 -2
- package/src/components/blocks/FileBlock.tsx +5 -3
- package/src/components/blocks/HeroBlock.tsx +5 -3
- package/src/components/blocks/ImageBlock.tsx +6 -4
- package/src/components/blocks/ListBlock.tsx +5 -3
- package/src/components/blocks/ParagraphBlock.tsx +3 -1
- package/src/components/blocks/RelationshipBlock.tsx +5 -3
- package/src/components/blocks/RichTextBlock.tsx +5 -3
- package/src/components/blocks/VideoBlock.tsx +5 -3
- package/src/components/fields/AccordionField.tsx +10 -51
- package/src/components/fields/ArrayField.tsx +9 -7
- package/src/components/fields/ArrayLayout.tsx +3 -1
- package/src/components/fields/BlocksField.tsx +24 -75
- package/src/components/fields/ButtonField.tsx +3 -1
- package/src/components/fields/CardField.tsx +6 -4
- package/src/components/fields/GroupLayout.tsx +2 -49
- package/src/components/fields/HeadingField.tsx +3 -1
- package/src/components/fields/HeadingSubheadingField.tsx +4 -2
- package/src/components/fields/HeroField.tsx +8 -6
- package/src/components/fields/JSONField.tsx +9 -10
- package/src/components/fields/LinkField.tsx +4 -2
- package/src/components/fields/ListField.tsx +3 -1
- package/src/components/fields/MarkdownField.tsx +3 -17
- package/src/components/fields/RelationshipBlockField.tsx +7 -5
- package/src/components/fields/RelationshipField.tsx +3 -1
- package/src/components/fields/RichTextField.tsx +19 -16
- package/src/components/fields/SecretField.tsx +9 -69
- package/src/components/fields/SelectField.tsx +11 -6
- package/src/components/fields/TabsLayout.tsx +2 -48
- package/src/components/fields/TextField.tsx +9 -7
- package/src/components/fields/UploadField.tsx +3 -1
- package/src/components/fields/VideoField.tsx +6 -4
- package/src/components/ui/BlockDrawer.tsx +3 -1
- package/src/components/ui/CommandPalette.tsx +9 -7
- package/src/components/ui/CommandPaletteWrapper.tsx +3 -1
- package/src/components/ui/IconPickerModal.tsx +4 -2
- package/src/components/ui/PageHeader.tsx +48 -20
- package/src/components/users/UserDetail.tsx +3 -1
- package/src/components/users/UserForm.tsx +5 -3
- package/src/fields/examples/sample-field-2.tsx +3 -1
- package/src/fields/examples/sample-field.tsx +3 -1
- package/src/hooks/useAutoFormState.ts +19 -11
- package/src/integration.ts +21 -0
- package/src/layouts/AdminLayout.astro +252 -189
- package/src/layouts/AuthLayout.astro +52 -31
- package/src/lib/api.ts +7 -7
- package/src/lib/autoform-store.ts +4 -0
- package/src/lib/deep-equal.ts +3 -3
- package/src/lib/i18n.ts +44 -0
- package/src/lib/vite-shim-plugin.ts +3 -0
- package/src/locales/de.json +163 -0
- package/src/locales/en.json +163 -0
- package/src/locales/es.json +163 -0
- package/src/locales/fr.json +163 -0
- package/src/locales/pt.json +163 -0
- package/src/pages/403.astro +2 -1
- package/src/pages/[collection]/[id].astro +23 -10
- package/src/pages/[collection]/index.astro +48 -34
- package/src/pages/audit/index.astro +2 -1
- package/src/pages/graphql-explorer.astro +2 -1
- package/src/pages/graphql.astro +2 -1
- package/src/pages/health.astro +9 -0
- package/src/pages/index.astro +6 -5
- package/src/pages/keys.astro +2 -1
- package/src/pages/marketplace.astro +2 -1
- package/src/pages/media.astro +2 -1
- package/src/pages/plugins.astro +2 -1
- package/src/pages/rest-playground.astro +2 -1
- package/src/pages/roles/index.astro +16 -52
- package/src/pages/sessions.astro +2 -1
- package/src/pages/settings/[slug].astro +45 -30
- package/src/pages/users/[id].astro +1 -0
- package/src/pages/users/index.astro +2 -1
- package/src/pages/users/new.astro +2 -1
- package/src/pages/webhooks.astro +2 -1
- 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.
|
|
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": "
|
|
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"
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
+
}
|