@littlebearapps/platform-admin-sdk 1.4.2 → 2.0.0
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/templates.d.ts +1 -1
- package/dist/templates.js +232 -2
- package/package.json +1 -1
- package/templates/full/config/audit-targets.yaml +72 -0
- package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
- package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
- package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
- package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/dashboard/src/pages/notifications.astro +11 -0
- package/templates/full/migrations/008_auditor.sql +99 -0
- package/templates/full/migrations/010_pricing_versions.sql +110 -0
- package/templates/full/migrations/011_multi_account.sql +51 -0
- package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
- package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
- package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
- package/templates/full/workers/lib/auditor/index.ts +9 -0
- package/templates/full/workers/lib/auditor/types.ts +167 -0
- package/templates/full/workers/platform-auditor.ts +1071 -0
- package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/config/observability.yaml.hbs +276 -0
- package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
- package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
- package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
- package/templates/shared/dashboard/astro.config.mjs +21 -0
- package/templates/shared/dashboard/package.json.hbs +29 -0
- package/templates/shared/dashboard/src/components/Header.astro +29 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
- package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
- package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
- package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
- package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
- package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
- package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
- package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
- package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
- package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
- package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
- package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
- package/templates/shared/dashboard/src/lib/types.ts +72 -0
- package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
- package/templates/shared/dashboard/src/middleware/index.ts +1 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
- package/templates/shared/dashboard/src/pages/index.astro +3 -0
- package/templates/shared/dashboard/src/pages/resources.astro +11 -0
- package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/dashboard/src/styles/global.css +29 -0
- package/templates/shared/dashboard/tailwind.config.mjs +9 -0
- package/templates/shared/dashboard/tsconfig.json +9 -0
- package/templates/shared/dashboard/wrangler.json.hbs +47 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +17 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
- package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
- package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
- package/templates/shared/scripts/validate-schemas.js +61 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
- package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
- package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
- package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
- package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
- package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
- package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
- package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
- package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
- package/templates/shared/workers/platform-usage.ts +98 -8
- package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
- package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
- package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
- package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
- package/templates/standard/dashboard/src/components/health/index.ts +4 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
- package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/dashboard/src/pages/errors.astro +13 -0
- package/templates/standard/dashboard/src/pages/health.astro +11 -0
- package/templates/standard/migrations/009_topology_mapper.sql +65 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
- package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
- package/templates/standard/workers/lib/mapper/index.ts +7 -0
- package/templates/standard/workers/platform-mapper.ts +482 -0
- package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
- package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
- package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry Envelope Types
|
|
3
|
+
*
|
|
4
|
+
* TypeScript interfaces for Platform SDK telemetry messages and error reports.
|
|
5
|
+
* These types mirror the JSON schemas in contracts/schemas/ and provide
|
|
6
|
+
* compile-time type safety.
|
|
7
|
+
*
|
|
8
|
+
* @see contracts/schemas/envelope.v1.schema.json
|
|
9
|
+
* @see contracts/schemas/error_report.v1.schema.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Telemetry Message Types
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Feature metrics payload — maps to FeatureMetrics from consumer SDK.
|
|
18
|
+
* Each field corresponds to a Cloudflare service metric.
|
|
19
|
+
*/
|
|
20
|
+
export interface TelemetryMetrics {
|
|
21
|
+
// D1 Database
|
|
22
|
+
d1Writes?: number;
|
|
23
|
+
d1Reads?: number;
|
|
24
|
+
d1RowsRead?: number;
|
|
25
|
+
d1RowsWritten?: number;
|
|
26
|
+
|
|
27
|
+
// KV Namespace
|
|
28
|
+
kvReads?: number;
|
|
29
|
+
kvWrites?: number;
|
|
30
|
+
kvDeletes?: number;
|
|
31
|
+
kvLists?: number;
|
|
32
|
+
|
|
33
|
+
// Workers AI
|
|
34
|
+
aiRequests?: number;
|
|
35
|
+
aiNeurons?: number;
|
|
36
|
+
|
|
37
|
+
// Vectorize
|
|
38
|
+
vectorizeQueries?: number;
|
|
39
|
+
vectorizeInserts?: number;
|
|
40
|
+
|
|
41
|
+
// Durable Objects
|
|
42
|
+
doRequests?: number;
|
|
43
|
+
doGbSeconds?: number;
|
|
44
|
+
|
|
45
|
+
// R2 Storage
|
|
46
|
+
r2ClassA?: number;
|
|
47
|
+
r2ClassB?: number;
|
|
48
|
+
|
|
49
|
+
// Queues
|
|
50
|
+
queueMessages?: number;
|
|
51
|
+
|
|
52
|
+
// General
|
|
53
|
+
requests?: number;
|
|
54
|
+
cpuMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Telemetry message envelope — the standard format for SDK telemetry
|
|
59
|
+
* sent through the platform-telemetry queue.
|
|
60
|
+
*/
|
|
61
|
+
export interface TelemetryEnvelope {
|
|
62
|
+
/** Fully qualified feature key (project:category:feature) */
|
|
63
|
+
feature_key: string;
|
|
64
|
+
/** Project identifier */
|
|
65
|
+
project: string;
|
|
66
|
+
/** Feature category within the project */
|
|
67
|
+
category: string;
|
|
68
|
+
/** Feature name within the category */
|
|
69
|
+
feature: string;
|
|
70
|
+
/** Feature metrics payload */
|
|
71
|
+
metrics: TelemetryMetrics;
|
|
72
|
+
/** Unix timestamp in milliseconds when the event occurred */
|
|
73
|
+
timestamp: number;
|
|
74
|
+
/** If true, this is a heartbeat message (no metrics, just liveness) */
|
|
75
|
+
is_heartbeat?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Error Report Types
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
/** Worker outcome types captured by the error collector */
|
|
83
|
+
export type ErrorOutcome =
|
|
84
|
+
| 'exception'
|
|
85
|
+
| 'exceededCpu'
|
|
86
|
+
| 'exceededMemory'
|
|
87
|
+
| 'canceled'
|
|
88
|
+
| 'responseStreamDisconnected'
|
|
89
|
+
| 'scriptNotFound'
|
|
90
|
+
| 'soft_error'
|
|
91
|
+
| 'warning';
|
|
92
|
+
|
|
93
|
+
/** Priority levels for error reports */
|
|
94
|
+
export type ErrorPriority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Error report structure — captured by the error-collector tail worker
|
|
98
|
+
* and optionally escalated to GitHub issues.
|
|
99
|
+
*/
|
|
100
|
+
export interface ErrorReport {
|
|
101
|
+
/** Name of the worker that produced the error */
|
|
102
|
+
script_name: string;
|
|
103
|
+
/** Unique error identifier for deduplication */
|
|
104
|
+
fingerprint: string;
|
|
105
|
+
/** Error message text */
|
|
106
|
+
message: string;
|
|
107
|
+
/** Full stack trace if available */
|
|
108
|
+
stack_trace?: string;
|
|
109
|
+
/** Worker outcome type */
|
|
110
|
+
outcome?: ErrorOutcome;
|
|
111
|
+
/** Assigned priority level */
|
|
112
|
+
priority?: ErrorPriority;
|
|
113
|
+
/** Error category (e.g., unhandled-exception, quota-exhausted) */
|
|
114
|
+
category?: string;
|
|
115
|
+
/** Project the error belongs to */
|
|
116
|
+
project?: string;
|
|
117
|
+
/** When the error occurred (ISO 8601 UTC) */
|
|
118
|
+
timestamp?: string;
|
|
119
|
+
/** Unix timestamp in milliseconds from the tail event */
|
|
120
|
+
event_timestamp?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Circuit Breaker Types
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
/** Circuit breaker states stored in KV */
|
|
128
|
+
export type CircuitBreakerState = 'active' | 'warning' | 'paused';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Circuit breaker KV entry — stored at cb:{project}:{feature} key.
|
|
132
|
+
*/
|
|
133
|
+
export interface CircuitBreakerEntry {
|
|
134
|
+
state: CircuitBreakerState;
|
|
135
|
+
tripped_at?: string;
|
|
136
|
+
reason?: string;
|
|
137
|
+
d1_writes_24h?: number;
|
|
138
|
+
d1_limit?: number;
|
|
139
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from 'astro/config';
|
|
2
|
+
import cloudflare from '@astrojs/cloudflare';
|
|
3
|
+
import tailwind from '@astrojs/tailwind';
|
|
4
|
+
import react from '@astrojs/react';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
output: 'server',
|
|
8
|
+
adapter: cloudflare({
|
|
9
|
+
mode: 'directory',
|
|
10
|
+
imageService: 'passthrough',
|
|
11
|
+
}),
|
|
12
|
+
integrations: [tailwind(), react()],
|
|
13
|
+
vite: {
|
|
14
|
+
optimizeDeps: {
|
|
15
|
+
include: ['react', 'react-dom', 'react-dom/client'],
|
|
16
|
+
},
|
|
17
|
+
resolve: {
|
|
18
|
+
conditions: ['workerd', 'browser', 'import', 'module', 'default'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectSlug}}-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "astro dev",
|
|
8
|
+
"build": "astro build",
|
|
9
|
+
"preview": "astro preview",
|
|
10
|
+
"check": "astro check"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"astro": "^5.0.0",
|
|
14
|
+
"@astrojs/cloudflare": "^12.0.0",
|
|
15
|
+
"@astrojs/react": "^4.0.0",
|
|
16
|
+
"@astrojs/tailwind": "^6.0.0",
|
|
17
|
+
"clsx": "^2.1.0",
|
|
18
|
+
"jose": "^5.2.0",
|
|
19
|
+
"lucide-react": "^0.300.0",
|
|
20
|
+
"react": "^19.0.0",
|
|
21
|
+
"react-dom": "^19.0.0",
|
|
22
|
+
"tailwindcss": "^3.4.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.0.0",
|
|
26
|
+
"@types/react-dom": "^19.0.0",
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
<header class="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 lg:px-6 py-3">
|
|
5
|
+
<div class="flex items-center justify-between">
|
|
6
|
+
<div class="lg:hidden">
|
|
7
|
+
<h1 class="text-lg font-bold text-gray-900 dark:text-white">Platform</h1>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="flex items-center gap-3 ml-auto">
|
|
11
|
+
<!-- Dark mode toggle -->
|
|
12
|
+
<button
|
|
13
|
+
id="theme-toggle"
|
|
14
|
+
class="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
15
|
+
aria-label="Toggle dark mode"
|
|
16
|
+
>
|
|
17
|
+
<span id="theme-light" class="hidden dark:inline">Sun</span>
|
|
18
|
+
<span id="theme-dark" class="dark:hidden">Moon</span>
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<script is:inline>
|
|
25
|
+
document.getElementById('theme-toggle')?.addEventListener('click', () => {
|
|
26
|
+
const isDark = document.documentElement.classList.toggle('dark');
|
|
27
|
+
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
const pathname = Astro.url.pathname;
|
|
3
|
+
|
|
4
|
+
interface NavItem {
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const navItems: NavItem[] = [
|
|
11
|
+
{ href: '/dashboard', label: 'Overview', icon: 'home' },
|
|
12
|
+
{ href: '/resources', label: 'Resources', icon: 'server' },
|
|
13
|
+
{{#if isStandard}}
|
|
14
|
+
{ href: '/health', label: 'Health', icon: 'activity' },
|
|
15
|
+
{ href: '/errors', label: 'Errors', icon: 'alert-triangle' },
|
|
16
|
+
{ href: '/circuit-breakers', label: 'Breakers', icon: 'zap' },
|
|
17
|
+
{{/if}}
|
|
18
|
+
{{#if isFull}}
|
|
19
|
+
{ href: '/notifications', label: 'Notifications', icon: 'bell' },
|
|
20
|
+
{{/if}}
|
|
21
|
+
{ href: '/settings', label: 'Settings', icon: 'settings' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function isActive(href: string): boolean {
|
|
25
|
+
return pathname === href || pathname.startsWith(href + '/');
|
|
26
|
+
}
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<aside class="hidden lg:flex lg:flex-col lg:w-56 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
|
30
|
+
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
|
31
|
+
<h1 class="text-lg font-bold text-gray-900 dark:text-white">Platform</h1>
|
|
32
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">Dashboard</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<nav class="flex-1 p-3 space-y-1">
|
|
36
|
+
{navItems.map((item) => (
|
|
37
|
+
<a
|
|
38
|
+
href={item.href}
|
|
39
|
+
class:list={[
|
|
40
|
+
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
|
|
41
|
+
isActive(item.href)
|
|
42
|
+
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 font-medium'
|
|
43
|
+
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
|
44
|
+
]}
|
|
45
|
+
>
|
|
46
|
+
<span class="w-5 h-5 flex items-center justify-center text-current opacity-60">
|
|
47
|
+
{item.icon === 'home' && '\u2302'}
|
|
48
|
+
{item.icon === 'server' && '\u229E'}
|
|
49
|
+
{item.icon === 'activity' && '\u2661'}
|
|
50
|
+
{item.icon === 'alert-triangle' && '\u25B3'}
|
|
51
|
+
{item.icon === 'zap' && '\u26A1'}
|
|
52
|
+
{item.icon === 'bell' && '\uD83D\uDD14'}
|
|
53
|
+
{item.icon === 'settings' && '\u2699'}
|
|
54
|
+
</span>
|
|
55
|
+
{item.label}
|
|
56
|
+
</a>
|
|
57
|
+
))}
|
|
58
|
+
</nav>
|
|
59
|
+
</aside>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
import { EmptyState } from '../ui/EmptyState';
|
|
4
|
+
|
|
5
|
+
interface Alert {
|
|
6
|
+
id: number;
|
|
7
|
+
title: string;
|
|
8
|
+
severity: string;
|
|
9
|
+
source: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
resolved_at?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AlertHistory() {
|
|
15
|
+
const [alerts, setAlerts] = useState<Alert[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
fetch('/api/usage/anomalies?limit=20')
|
|
20
|
+
.then((r) => r.json())
|
|
21
|
+
.then((data: { anomalies: Alert[] }) => {
|
|
22
|
+
setAlerts(data.anomalies ?? []);
|
|
23
|
+
setLoading(false);
|
|
24
|
+
})
|
|
25
|
+
.catch(() => setLoading(false));
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
29
|
+
if (alerts.length === 0) return <EmptyState title="No alerts" description="No recent alerts or anomalies detected." />;
|
|
30
|
+
|
|
31
|
+
const severityColour: Record<string, string> = {
|
|
32
|
+
critical: 'text-red-600 dark:text-red-400',
|
|
33
|
+
warning: 'text-yellow-600 dark:text-yellow-400',
|
|
34
|
+
info: 'text-blue-600 dark:text-blue-400',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
{alerts.map((a) => (
|
|
40
|
+
<div
|
|
41
|
+
key={a.id}
|
|
42
|
+
className="flex items-start justify-between gap-3 py-2 px-3 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
|
43
|
+
>
|
|
44
|
+
<div className="min-w-0">
|
|
45
|
+
<p className={`text-sm font-medium ${severityColour[a.severity] ?? 'text-gray-900 dark:text-white'}`}>
|
|
46
|
+
{a.title}
|
|
47
|
+
</p>
|
|
48
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{a.source}</p>
|
|
49
|
+
</div>
|
|
50
|
+
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
51
|
+
{new Date(a.created_at).toLocaleDateString()}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface Stats {
|
|
5
|
+
services: {
|
|
6
|
+
total: number;
|
|
7
|
+
byStatus: Record<string, number>;
|
|
8
|
+
byType: Record<string, number>;
|
|
9
|
+
byProject: Record<string, number>;
|
|
10
|
+
};
|
|
11
|
+
alerts: {
|
|
12
|
+
total: number;
|
|
13
|
+
unacknowledged: number;
|
|
14
|
+
critical: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function InfrastructureStats() {
|
|
19
|
+
const [data, setData] = useState<Stats | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch('/api/infrastructure/stats')
|
|
24
|
+
.then(res => res.json())
|
|
25
|
+
.then((stats: Stats) => { setData(stats); setLoading(false); })
|
|
26
|
+
.catch(() => setLoading(false));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
if (loading) return <LoadingSkeleton lines={4} />;
|
|
30
|
+
|
|
31
|
+
if (!data) {
|
|
32
|
+
return <p className="text-sm text-gray-500 dark:text-gray-400">No infrastructure data available.</p>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cards = [
|
|
36
|
+
{ label: 'Total Services', value: data.services.total, icon: '\u229E' },
|
|
37
|
+
{ label: 'Deployed', value: data.services.byStatus.deployed ?? 0, icon: '\u2713' },
|
|
38
|
+
{ label: 'Alerts', value: data.alerts.total, icon: '\u25B3' },
|
|
39
|
+
{ label: 'Critical', value: data.alerts.critical, icon: '\u26A0' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-4">
|
|
44
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
45
|
+
{cards.map(card => (
|
|
46
|
+
<div key={card.label} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
47
|
+
<div className="flex items-center gap-2 mb-1">
|
|
48
|
+
<span className="opacity-50">{card.icon}</span>
|
|
49
|
+
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{card.label}</span>
|
|
50
|
+
</div>
|
|
51
|
+
<p className="text-2xl font-bold text-gray-900 dark:text-white">{card.value}</p>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{Object.keys(data.services.byType).length > 0 && (
|
|
57
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
58
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
59
|
+
By Resource Type
|
|
60
|
+
</h3>
|
|
61
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
62
|
+
{Object.entries(data.services.byType).map(([type, count]) => (
|
|
63
|
+
<div key={type} className="flex items-center justify-between py-1">
|
|
64
|
+
<span className="text-sm text-gray-600 dark:text-gray-300">{type}</span>
|
|
65
|
+
<span className="text-sm font-medium text-gray-900 dark:text-white">{count}</span>
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { EmptyState } from '../ui/EmptyState';
|
|
3
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
4
|
+
|
|
5
|
+
interface Service {
|
|
6
|
+
script_name: string;
|
|
7
|
+
project: string;
|
|
8
|
+
resource_type: string;
|
|
9
|
+
last_seen_at?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ServiceRegistry() {
|
|
13
|
+
const [services, setServices] = useState<Service[]>([]);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
fetch('/api/infrastructure/services')
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((data: { services: Service[] }) => {
|
|
20
|
+
setServices(data.services ?? []);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
})
|
|
23
|
+
.catch(() => setLoading(false));
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (loading) return <LoadingSkeleton lines={5} />;
|
|
27
|
+
if (services.length === 0) return <EmptyState title="No services" description="Register services via services.yaml." />;
|
|
28
|
+
|
|
29
|
+
const grouped = services.reduce<Record<string, Service[]>>((acc, s) => {
|
|
30
|
+
const key = s.project || 'unassigned';
|
|
31
|
+
(acc[key] ??= []).push(s);
|
|
32
|
+
return acc;
|
|
33
|
+
}, {});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-4">
|
|
37
|
+
{Object.entries(grouped).map(([project, svcList]) => (
|
|
38
|
+
<div key={project}>
|
|
39
|
+
<h4 className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 mb-1">{project}</h4>
|
|
40
|
+
<div className="space-y-1">
|
|
41
|
+
{svcList.map((s) => (
|
|
42
|
+
<div
|
|
43
|
+
key={s.script_name}
|
|
44
|
+
className="flex items-center justify-between py-1 px-2 text-sm bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
|
45
|
+
>
|
|
46
|
+
<span className="font-mono text-gray-900 dark:text-white">{s.script_name}</span>
|
|
47
|
+
<span className="text-xs text-gray-400 dark:text-gray-500">{s.resource_type}</span>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface ServiceStatus {
|
|
5
|
+
name: string;
|
|
6
|
+
status: 'healthy' | 'degraded' | 'down' | 'unknown';
|
|
7
|
+
lastCheck: string;
|
|
8
|
+
uptimePct?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function UptimeStatus() {
|
|
12
|
+
const [services, setServices] = useState<ServiceStatus[]>([]);
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
fetch('/api/infrastructure/services')
|
|
17
|
+
.then((r) => r.json())
|
|
18
|
+
.then((data: { services: ServiceStatus[] }) => {
|
|
19
|
+
setServices(data.services ?? []);
|
|
20
|
+
setLoading(false);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => setLoading(false));
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
26
|
+
|
|
27
|
+
const statusColour: Record<string, string> = {
|
|
28
|
+
healthy: 'bg-green-500',
|
|
29
|
+
degraded: 'bg-yellow-500',
|
|
30
|
+
down: 'bg-red-500',
|
|
31
|
+
unknown: 'bg-gray-400',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
{services.length === 0 ? (
|
|
37
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">No service data available.</p>
|
|
38
|
+
) : (
|
|
39
|
+
services.map((s) => (
|
|
40
|
+
<div
|
|
41
|
+
key={s.name}
|
|
42
|
+
className="flex items-center justify-between py-1.5 px-3 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
|
43
|
+
>
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
<span className={`w-2 h-2 rounded-full ${statusColour[s.status] ?? statusColour.unknown}`} />
|
|
46
|
+
<span className="text-sm text-gray-900 dark:text-white">{s.name}</span>
|
|
47
|
+
</div>
|
|
48
|
+
{s.uptimePct !== undefined && (
|
|
49
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">{s.uptimePct.toFixed(1)}%</span>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity Feed Quadrant - Mission Control
|
|
3
|
+
* Shows last 5 notifications, pending patterns count, recent alerts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface ActivityData {
|
|
7
|
+
notifications: Array<{
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
category: string;
|
|
11
|
+
priority: string;
|
|
12
|
+
source: string;
|
|
13
|
+
created_at: number;
|
|
14
|
+
action_url: string | null;
|
|
15
|
+
}>;
|
|
16
|
+
pendingPatterns: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
data: ActivityData;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const categoryLabels: Record<string, string> = {
|
|
25
|
+
error: '[ERR]',
|
|
26
|
+
warning: '[WARN]',
|
|
27
|
+
info: '[INFO]',
|
|
28
|
+
success: '[OK]',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const sourceLabels: Record<string, string> = {
|
|
32
|
+
'error-collector': 'Error',
|
|
33
|
+
'pattern-discovery': 'Pattern',
|
|
34
|
+
'circuit-breaker': 'CB',
|
|
35
|
+
usage: 'Usage',
|
|
36
|
+
gatus: 'Monitor',
|
|
37
|
+
system: 'System',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function formatTimestamp(ts: number): string {
|
|
41
|
+
if (!ts) return '';
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const diffMs = now - ts * 1000;
|
|
44
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
45
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
46
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
47
|
+
|
|
48
|
+
if (diffMins < 1) return 'Just now';
|
|
49
|
+
if (diffMins < 60) return `${diffMins}m`;
|
|
50
|
+
if (diffHours < 24) return `${diffHours}h`;
|
|
51
|
+
if (diffDays < 7) return `${diffDays}d`;
|
|
52
|
+
return new Date(ts * 1000).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ActivityFeed({ data, loading }: Props) {
|
|
56
|
+
if (loading) {
|
|
57
|
+
return <QuadrantSkeleton />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-5">
|
|
62
|
+
<div className="flex items-center justify-between mb-4">
|
|
63
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
64
|
+
Activity
|
|
65
|
+
</h3>
|
|
66
|
+
{data.pendingPatterns > 0 && (
|
|
67
|
+
<span className="text-xs px-2 py-1 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
|
68
|
+
{data.pendingPatterns} pattern{data.pendingPatterns !== 1 ? 's' : ''} pending
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{data.notifications.length > 0 ? (
|
|
74
|
+
<div className="space-y-3">
|
|
75
|
+
{data.notifications.map((n) => (
|
|
76
|
+
<div key={n.id} className="flex items-start gap-2">
|
|
77
|
+
<span className="text-xs font-mono flex-shrink-0 mt-0.5 text-gray-500 dark:text-gray-400">
|
|
78
|
+
{categoryLabels[n.category] ?? '[INFO]'}
|
|
79
|
+
</span>
|
|
80
|
+
<div className="flex-1 min-w-0">
|
|
81
|
+
<div className="text-sm text-gray-900 dark:text-white truncate">
|
|
82
|
+
{n.action_url ? (
|
|
83
|
+
<a
|
|
84
|
+
href={n.action_url}
|
|
85
|
+
className="hover:text-blue-600 dark:hover:text-blue-400"
|
|
86
|
+
onClick={(e) => e.stopPropagation()}
|
|
87
|
+
>
|
|
88
|
+
{n.title}
|
|
89
|
+
</a>
|
|
90
|
+
) : (
|
|
91
|
+
n.title
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
95
|
+
<span>{sourceLabels[n.source] ?? n.source}</span>
|
|
96
|
+
<span>·</span>
|
|
97
|
+
<span>{formatTimestamp(n.created_at)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
105
|
+
No recent activity
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
|
110
|
+
<a
|
|
111
|
+
href="/notifications"
|
|
112
|
+
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
113
|
+
>
|
|
114
|
+
View all notifications →
|
|
115
|
+
</a>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function QuadrantSkeleton() {
|
|
122
|
+
return (
|
|
123
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-5">
|
|
124
|
+
<div className="animate-pulse space-y-3">
|
|
125
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
|
|
126
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
|
127
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
|
128
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
|
129
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
|
|
130
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|