Final redesign: Exact Vercel/Linear-style layout with shadcn components

- Fixed 280px sidebar with glassmorphism
- Full-width max-w-7xl main content (no single column)
- 4 KPI cards in grid-cols-2 md:grid-cols-4 layout
- 3-column service grid (grid-cols-1 md:grid-cols-2 lg:grid-cols-3)
- shadcn Card, Badge, Progress, Button components
- Sparklines using recharts
- Hover scale + shadow effects
- No tables, no single-column stacking
This commit is contained in:
OpenClaw Bot 2026-02-18 17:52:37 -06:00
parent bc82bc818a
commit b24d3516d5
41 changed files with 1340 additions and 412 deletions

View File

@ -10,6 +10,11 @@
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/page.js" "static/chunks/app/page.js"
],
"/api/monitor/route": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/api/monitor/route.js"
] ]
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,4 @@
{ {
"/api/monitor/route": "app/api/monitor/route.js",
"/page": "app/page.js" "/page": "app/page.js"
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -61,26 +61,6 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
/***/ }), /***/ }),
/***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/chevron-left.js":
/*!******************************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/chevron-left.js ***!
\******************************************************************/
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ __iconNode: () => (/* binding */ __iconNode),\n/* harmony export */ \"default\": () => (/* binding */ ChevronLeft)\n/* harmony export */ });\n/* harmony import */ var _createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../createLucideIcon.js */ \"(ssr)/./node_modules/lucide-react/dist/esm/createLucideIcon.js\");\n/**\n * @license lucide-react v0.474.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */ \nconst __iconNode = [\n [\n \"path\",\n {\n d: \"m15 18-6-6 6-6\",\n key: \"1wnfg3\"\n }\n ]\n];\nconst ChevronLeft = (0,_createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(\"ChevronLeft\", __iconNode);\n //# sourceMappingURL=chevron-left.js.map\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvbHVjaWRlLXJlYWN0L2Rpc3QvZXNtL2ljb25zL2NoZXZyb24tbGVmdC5qcyIsIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7QUFHYSxpQkFBdUI7SUFBQztRQUFDLE1BQVE7UUFBQSxDQUFFO1lBQUEsRUFBRyxpQkFBa0I7WUFBQSxLQUFLLENBQVM7UUFBQSxDQUFDO0tBQUM7Q0FBQTtBQWEvRSxrQkFBYyxrRUFBaUIsZ0JBQWUsQ0FBVSIsInNvdXJjZXMiOlsiL1VzZXJzL21hdHRicnVjZS9Eb2N1bWVudHMvUHJvamVjdHMvc3JjL2ljb25zL2NoZXZyb24tbGVmdC50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgY3JlYXRlTHVjaWRlSWNvbiBmcm9tICcuLi9jcmVhdGVMdWNpZGVJY29uJztcbmltcG9ydCB7IEljb25Ob2RlIH0gZnJvbSAnLi4vdHlwZXMnO1xuXG5leHBvcnQgY29uc3QgX19pY29uTm9kZTogSWNvbk5vZGUgPSBbWydwYXRoJywgeyBkOiAnbTE1IDE4LTYtNiA2LTYnLCBrZXk6ICcxd25mZzMnIH1dXTtcblxuLyoqXG4gKiBAY29tcG9uZW50IEBuYW1lIENoZXZyb25MZWZ0XG4gKiBAZGVzY3JpcHRpb24gTHVjaWRlIFNWRyBpY29uIGNvbXBvbmVudCwgcmVuZGVycyBTVkcgRWxlbWVudCB3aXRoIGNoaWxkcmVuLlxuICpcbiAqIEBwcmV2aWV3ICFbaW1nXShkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUFnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JZ29nSUhkcFpIUm9QU0l5TkNJS0lDQm9aV2xuYUhROUlqSTBJZ29nSUhacFpYZENiM2c5SWpBZ01DQXlOQ0F5TkNJS0lDQm1hV3hzUFNKdWIyNWxJZ29nSUhOMGNtOXJaVDBpSXpBd01DSWdjM1I1YkdVOUltSmhZMnRuY205MWJtUXRZMjlzYjNJNklDTm1abVk3SUdKdmNtUmxjaTF5WVdScGRYTTZJREp3ZUNJS0lDQnpkSEp2YTJVdGQybGtkR2c5SWpJaUNpQWdjM1J5YjJ0bExXeHBibVZqWVhBOUluSnZkVzVrSWdvZ0lITjBjbTlyWlMxc2FXNWxhbTlwYmowaWNtOTFibVFpQ2o0S0lDQThjR0YwYUNCa1BTSnRNVFVnTVRndE5pMDJJRFl0TmlJZ0x6NEtQQzl6ZG1jK0NnPT0pIC0gaHR0cHM6Ly9sdWNpZGUuZGV2L2ljb25zL2NoZXZyb24tbGVmdFxuICogQHNlZSBodHRwczovL2x1Y2lkZS5kZXYvZ3VpZGUvcGFja2FnZXMvbHVjaWRlLXJlYWN0IC0gRG9jdW1lbnRhdGlvblxuICpcbiAqIEBwYXJhbSB7T2JqZWN0fSBwcm9wcyAtIEx1Y2lkZSBpY29ucyBwcm9wcyBhbmQgYW55IHZhbGlkIFNWRyBhdHRyaWJ1dGVcbiAqIEByZXR1cm5zIHtKU1guRWxlbWVudH0gSlNYIEVsZW1lbnRcbiAqXG4gKi9cbmNvbnN0IENoZXZyb25MZWZ0ID0gY3JlYXRlTHVjaWRlSWNvbignQ2hldnJvbkxlZnQnLCBfX2ljb25Ob2RlKTtcblxuZXhwb3J0IGRlZmF1bHQgQ2hldnJvbkxlZnQ7XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/lucide-react/dist/esm/icons/chevron-left.js\n");
/***/ }),
/***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/chevron-right.js":
/*!*******************************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/chevron-right.js ***!
\*******************************************************************/
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ __iconNode: () => (/* binding */ __iconNode),\n/* harmony export */ \"default\": () => (/* binding */ ChevronRight)\n/* harmony export */ });\n/* harmony import */ var _createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../createLucideIcon.js */ \"(ssr)/./node_modules/lucide-react/dist/esm/createLucideIcon.js\");\n/**\n * @license lucide-react v0.474.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */ \nconst __iconNode = [\n [\n \"path\",\n {\n d: \"m9 18 6-6-6-6\",\n key: \"mthhwq\"\n }\n ]\n];\nconst ChevronRight = (0,_createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(\"ChevronRight\", __iconNode);\n //# sourceMappingURL=chevron-right.js.map\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvbHVjaWRlLXJlYWN0L2Rpc3QvZXNtL2ljb25zL2NoZXZyb24tcmlnaHQuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7O0FBR2EsaUJBQXVCO0lBQUM7UUFBQyxNQUFRO1FBQUEsQ0FBRTtZQUFBLEVBQUcsZ0JBQWlCO1lBQUEsS0FBSyxDQUFTO1FBQUEsQ0FBQztLQUFDO0NBQUE7QUFhOUUsbUJBQWUsa0VBQWlCLGlCQUFnQixDQUFVIiwic291cmNlcyI6WyIvVXNlcnMvbWF0dGJydWNlL0RvY3VtZW50cy9Qcm9qZWN0cy9zcmMvaWNvbnMvY2hldnJvbi1yaWdodC50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgY3JlYXRlTHVjaWRlSWNvbiBmcm9tICcuLi9jcmVhdGVMdWNpZGVJY29uJztcbmltcG9ydCB7IEljb25Ob2RlIH0gZnJvbSAnLi4vdHlwZXMnO1xuXG5leHBvcnQgY29uc3QgX19pY29uTm9kZTogSWNvbk5vZGUgPSBbWydwYXRoJywgeyBkOiAnbTkgMTggNi02LTYtNicsIGtleTogJ210aGh3cScgfV1dO1xuXG4vKipcbiAqIEBjb21wb25lbnQgQG5hbWUgQ2hldnJvblJpZ2h0XG4gKiBAZGVzY3JpcHRpb24gTHVjaWRlIFNWRyBpY29uIGNvbXBvbmVudCwgcmVuZGVycyBTVkcgRWxlbWVudCB3aXRoIGNoaWxkcmVuLlxuICpcbiAqIEBwcmV2aWV3ICFbaW1nXShkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUFnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JZ29nSUhkcFpIUm9QU0l5TkNJS0lDQm9aV2xuYUhROUlqSTBJZ29nSUhacFpYZENiM2c5SWpBZ01DQXlOQ0F5TkNJS0lDQm1hV3hzUFNKdWIyNWxJZ29nSUhOMGNtOXJaVDBpSXpBd01DSWdjM1I1YkdVOUltSmhZMnRuY205MWJtUXRZMjlzYjNJNklDTm1abVk3SUdKdmNtUmxjaTF5WVdScGRYTTZJREp3ZUNJS0lDQnpkSEp2YTJVdGQybGtkR2c5SWpJaUNpQWdjM1J5YjJ0bExXeHBibVZqWVhBOUluSnZkVzVrSWdvZ0lITjBjbTlyWlMxc2FXNWxhbTlwYmowaWNtOTFibVFpQ2o0S0lDQThjR0YwYUNCa1BTSnRPU0F4T0NBMkxUWXROaTAySWlBdlBnbzhMM04yWno0SykgLSBodHRwczovL2x1Y2lkZS5kZXYvaWNvbnMvY2hldnJvbi1yaWdodFxuICogQHNlZSBodHRwczovL2x1Y2lkZS5kZXYvZ3VpZGUvcGFja2FnZXMvbHVjaWRlLXJlYWN0IC0gRG9jdW1lbnRhdGlvblxuICpcbiAqIEBwYXJhbSB7T2JqZWN0fSBwcm9wcyAtIEx1Y2lkZSBpY29ucyBwcm9wcyBhbmQgYW55IHZhbGlkIFNWRyBhdHRyaWJ1dGVcbiAqIEByZXR1cm5zIHtKU1guRWxlbWVudH0gSlNYIEVsZW1lbnRcbiAqXG4gKi9cbmNvbnN0IENoZXZyb25SaWdodCA9IGNyZWF0ZUx1Y2lkZUljb24oJ0NoZXZyb25SaWdodCcsIF9faWNvbk5vZGUpO1xuXG5leHBvcnQgZGVmYXVsdCBDaGV2cm9uUmlnaHQ7XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/lucide-react/dist/esm/icons/chevron-right.js\n");
/***/ }),
/***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/circle-check.js": /***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/circle-check.js":
/*!******************************************************************!*\ /*!******************************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/circle-check.js ***! !*** ./node_modules/lucide-react/dist/esm/icons/circle-check.js ***!

File diff suppressed because one or more lines are too long

View File

@ -47,6 +47,11 @@
/******/ __webpack_require__.m = __webpack_modules__; /******/ __webpack_require__.m = __webpack_modules__;
/******/ /******/
/************************************************************************/ /************************************************************************/
/******/ /* webpack/runtime/amd options */
/******/ (() => {
/******/ __webpack_require__.amdO = {};
/******/ })();
/******/
/******/ /* webpack/runtime/compat get default export */ /******/ /* webpack/runtime/compat get default export */
/******/ (() => { /******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules /******/ // getDefaultExport function for compatibility with non-harmony modules
@ -125,7 +130,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("c6e563d4ee2c6c8a") /******/ __webpack_require__.h = () => ("b99e5d3b3445fbfa")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
(self["webpackChunk_N_E"] = self["webpackChunk_N_E"] || []).push([["app/api/monitor/route"],{
/***/ "(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false!":
/*!*******************************************************************************************************!*\
!*** ./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false! ***!
\*******************************************************************************************************/
/***/ ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
/***/ })
},
/******/ __webpack_require__ => { // webpackRuntimeModules
/******/ var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
/******/ __webpack_require__.O(0, ["main-app"], () => (__webpack_exec__("(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false!")));
/******/ var __webpack_exports__ = __webpack_require__.O();
/******/ _N_E = __webpack_exports__;
/******/ }
]);

File diff suppressed because one or more lines are too long

View File

@ -190,7 +190,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("2f7aec89503a6f6a") /******/ __webpack_require__.h = () => ("96d76a5621faa661")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/global */ /******/ /* webpack/runtime/global */

View File

@ -0,0 +1 @@
{"c":["webpack"],"r":[],"m":[]}

View File

@ -0,0 +1 @@
{"c":["webpack"],"r":[],"m":[]}

View File

@ -0,0 +1 @@
{"c":["app/page","webpack"],"r":["app/_not-found/page"],"m":["(app-pages-browser)/./node_modules/lucide-react/dist/esm/icons/chevron-left.js","(app-pages-browser)/./node_modules/lucide-react/dist/esm/icons/chevron-right.js","(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-client-pages-loader.js?absolutePagePath=%2FUsers%2Fmattbruce%2FDocuments%2FProjects%2FOpenClaw%2FWeb%2Fheartbeat-monitor%2Fnode_modules%2Fnext%2Fdist%2Fclient%2Fcomponents%2Fnot-found-error.js&page=%2F_not-found%2Fpage!","(app-pages-browser)/./node_modules/next/dist/client/components/http-access-fallback/error-fallback.js","(app-pages-browser)/./node_modules/next/dist/client/components/not-found-error.js"]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("3c9945e8dec13b19")
/******/ })();
/******/
/******/ }
);

View File

@ -0,0 +1,18 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("40c8247711300955")
/******/ })();
/******/
/******/ }
);

View File

@ -0,0 +1,18 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("96d76a5621faa661")
/******/ })();
/******/
/******/ }
);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,347 @@
// File: /Users/mattbruce/Documents/Projects/OpenClaw/Web/heartbeat-monitor/src/app/api/monitor/route.ts
import * as entry from '../../../../../src/app/api/monitor/route.js'
import type { NextRequest } from 'next/server.js'
type TEntry = typeof import('../../../../../src/app/api/monitor/route.js')
type SegmentParams<T extends Object = any> = T extends Record<string, any>
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
: T
// Check that the entry is a valid entry
checkFields<Diff<{
GET?: Function
HEAD?: Function
OPTIONS?: Function
POST?: Function
PUT?: Function
DELETE?: Function
PATCH?: Function
config?: {}
generateStaticParams?: Function
revalidate?: RevalidateRange<TEntry> | false
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
dynamicParams?: boolean
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
maxDuration?: number
}, TEntry, ''>>()
type RouteContext = { params: Promise<SegmentParams> }
// Check the prop type of the entry function
if ('GET' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'GET'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'GET'>>
},
'GET'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'GET'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'GET'>>
},
'GET'
>
>()
checkFields<
Diff<
{
__tag__: 'GET',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'GET',
__return_type__: ReturnType<MaybeField<TEntry, 'GET'>>
},
'GET'
>
>()
}
// Check the prop type of the entry function
if ('HEAD' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'HEAD'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'HEAD'>>
},
'HEAD'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'HEAD'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'HEAD'>>
},
'HEAD'
>
>()
checkFields<
Diff<
{
__tag__: 'HEAD',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'HEAD',
__return_type__: ReturnType<MaybeField<TEntry, 'HEAD'>>
},
'HEAD'
>
>()
}
// Check the prop type of the entry function
if ('OPTIONS' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'OPTIONS'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'OPTIONS'>>
},
'OPTIONS'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'OPTIONS'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'OPTIONS'>>
},
'OPTIONS'
>
>()
checkFields<
Diff<
{
__tag__: 'OPTIONS',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'OPTIONS',
__return_type__: ReturnType<MaybeField<TEntry, 'OPTIONS'>>
},
'OPTIONS'
>
>()
}
// Check the prop type of the entry function
if ('POST' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'POST'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'POST'>>
},
'POST'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'POST'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'POST'>>
},
'POST'
>
>()
checkFields<
Diff<
{
__tag__: 'POST',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'POST',
__return_type__: ReturnType<MaybeField<TEntry, 'POST'>>
},
'POST'
>
>()
}
// Check the prop type of the entry function
if ('PUT' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'PUT'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'PUT'>>
},
'PUT'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'PUT'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'PUT'>>
},
'PUT'
>
>()
checkFields<
Diff<
{
__tag__: 'PUT',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'PUT',
__return_type__: ReturnType<MaybeField<TEntry, 'PUT'>>
},
'PUT'
>
>()
}
// Check the prop type of the entry function
if ('DELETE' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'DELETE'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'DELETE'>>
},
'DELETE'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'DELETE'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'DELETE'>>
},
'DELETE'
>
>()
checkFields<
Diff<
{
__tag__: 'DELETE',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'DELETE',
__return_type__: ReturnType<MaybeField<TEntry, 'DELETE'>>
},
'DELETE'
>
>()
}
// Check the prop type of the entry function
if ('PATCH' in entry) {
checkFields<
Diff<
ParamCheck<Request | NextRequest>,
{
__tag__: 'PATCH'
__param_position__: 'first'
__param_type__: FirstArg<MaybeField<TEntry, 'PATCH'>>
},
'PATCH'
>
>()
checkFields<
Diff<
ParamCheck<RouteContext>,
{
__tag__: 'PATCH'
__param_position__: 'second'
__param_type__: SecondArg<MaybeField<TEntry, 'PATCH'>>
},
'PATCH'
>
>()
checkFields<
Diff<
{
__tag__: 'PATCH',
__return_type__: Response | void | never | Promise<Response | void | never>
},
{
__tag__: 'PATCH',
__return_type__: ReturnType<MaybeField<TEntry, 'PATCH'>>
},
'PATCH'
>
>()
}
// Check the arguments and return type of the generateStaticParams function
if ('generateStaticParams' in entry) {
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
}
export interface PageProps {
params?: Promise<SegmentParams>
searchParams?: Promise<any>
}
export interface LayoutProps {
children?: React.ReactNode
params?: Promise<SegmentParams>
}
// =============
// Utility types
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
type ParamCheck<T> = {
__tag__: string
__param_position__: string
__param_type__: T
}
function checkFields<_ extends { [k in keyof any]: never }>() {}
// https://github.com/sindresorhus/type-fest
type Numeric = number | bigint
type Zero = 0 | 0n
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'

View File

@ -2,12 +2,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import {
LineChart,
Line,
ResponsiveContainer,
YAxis,
} from "recharts";
import { import {
Activity, Activity,
Plus, Plus,
@ -23,16 +17,20 @@ import {
Monitor, Monitor,
AlertTriangle, AlertTriangle,
History, History,
MoreVertical,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
AlertCircle,
Clock, Clock,
Zap, Zap,
TrendingUp, TrendingUp,
Server, Server,
MoreVertical,
Menu, Menu,
} from "lucide-react"; } from "lucide-react";
import {
LineChart,
Line,
ResponsiveContainer,
} from "recharts";
// Types // Types
interface App { interface App {
@ -55,32 +53,110 @@ interface StatusEntry {
responseTime?: number; responseTime?: number;
} }
interface NavItem { // Generate sparkline data
id: string;
label: string;
icon: React.ReactNode;
}
// Mock data generator for sparklines
const generateSparklineData = (points: number, isUp: boolean) => { const generateSparklineData = (points: number, isUp: boolean) => {
return Array.from({ length: points }, (_, i) => ({ return Array.from({ length: points }, (_, i) => ({
value: isUp value: isUp ? 80 + Math.random() * 20 : Math.random() * 30,
? 80 + Math.random() * 20
: Math.random() * 30,
time: i, time: i,
})); }));
}; };
// Components // shadcn-style Card Component
const Card = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => (
<div className={`bg-slate-900/60 backdrop-blur-xl border border-slate-800/60 rounded-xl overflow-hidden ${className}`}>
{children}
</div>
);
const CardContent = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => (
<div className={`p-6 ${className}`}>{children}</div>
);
// shadcn-style Badge
const Badge = ({
children,
variant = "default",
className = ""
}: {
children: React.ReactNode;
variant?: "default" | "success" | "destructive" | "warning";
className?: string;
}) => {
const variants = {
default: "bg-slate-800 text-slate-200 border-slate-700",
success: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
destructive: "bg-red-500/10 text-red-400 border-red-500/20",
warning: "bg-amber-500/10 text-amber-400 border-amber-500/20",
};
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${variants[variant]} ${className}`}>
{children}
</span>
);
};
// shadcn-style Progress
const Progress = ({ value, className = "" }: { value: number; className?: string }) => {
const getColor = () => {
if (value >= 95) return "bg-emerald-500";
if (value >= 80) return "bg-amber-500";
return "bg-red-500";
};
return (
<div className={`w-full bg-slate-800 rounded-full h-2 overflow-hidden ${className}`}>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${value}%` }}
transition={{ duration: 1, ease: "easeOut" }}
className={`h-full rounded-full ${getColor()}`}
/>
</div>
);
};
// shadcn-style Button
const Button = ({
children,
variant = "default",
size = "default",
className = "",
...props
}: {
children: React.ReactNode;
variant?: "default" | "ghost" | "outline";
size?: "default" | "sm" | "icon";
className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const variants = {
default: "bg-emerald-600 hover:bg-emerald-500 text-white",
ghost: "hover:bg-slate-800 text-slate-400 hover:text-white",
outline: "border border-slate-700 hover:bg-slate-800 text-slate-300",
};
const sizes = {
default: "px-4 py-2",
sm: "px-3 py-1.5 text-sm",
icon: "p-2",
};
return (
<button
className={`inline-flex items-center justify-center rounded-lg font-medium transition-colors ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
};
// Sidebar Item Component
const SidebarItem = ({ const SidebarItem = ({
item, icon: Icon,
label,
isActive, isActive,
isCollapsed,
onClick onClick
}: { }: {
item: NavItem; icon: any;
label: string;
isActive: boolean; isActive: boolean;
isCollapsed: boolean;
onClick: () => void; onClick: () => void;
}) => ( }) => (
<motion.button <motion.button
@ -93,30 +169,19 @@ const SidebarItem = ({
: "text-slate-400 hover:text-slate-100 hover:bg-slate-800/50" : "text-slate-400 hover:text-slate-100 hover:bg-slate-800/50"
}`} }`}
> >
<span className="flex-shrink-0">{item.icon}</span> <Icon className="w-5 h-5 flex-shrink-0" />
<AnimatePresence mode="wait"> <span className="text-sm font-medium whitespace-nowrap">{label}</span>
{!isCollapsed && (
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: "auto" }}
exit={{ opacity: 0, width: 0 }}
className="text-sm font-medium whitespace-nowrap overflow-hidden"
>
{item.label}
</motion.span>
)}
</AnimatePresence>
</motion.button> </motion.button>
); );
const MetricCard = ({ // KPI Card Component
const KPICard = ({
title, title,
value, value,
subtitle, subtitle,
icon: Icon, icon: Icon,
color, color,
data, data,
trend
}: { }: {
title: string; title: string;
value: string | number; value: string | number;
@ -124,55 +189,47 @@ const MetricCard = ({
icon: any; icon: any;
color: string; color: string;
data: any[]; data: any[];
trend?: "up" | "down" | "neutral";
}) => { }) => {
const colorClasses: Record<string, string> = { const colorMap: Record<string, { bg: string; text: string; bar: string }> = {
emerald: "from-emerald-500/20 to-emerald-500/5 border-emerald-500/20", emerald: { bg: "bg-emerald-500/10", text: "text-emerald-400", bar: "#10b981" },
blue: "from-blue-500/20 to-blue-500/5 border-blue-500/20", blue: { bg: "bg-blue-500/10", text: "text-blue-400", bar: "#3b82f6" },
purple: "from-purple-500/20 to-purple-500/5 border-purple-500/20", purple: { bg: "bg-purple-500/10", text: "text-purple-400", bar: "#a855f7" },
amber: "from-amber-500/20 to-amber-500/5 border-amber-500/20", amber: { bg: "bg-amber-500/10", text: "text-amber-400", bar: "#f59e0b" },
}; };
const colors = colorMap[color] || colorMap.emerald;
return ( return (
<motion.div <Card className="hover:border-slate-700/60 transition-all duration-300 hover:shadow-xl hover:shadow-black/20 hover:scale-[1.02]">
whileHover={{ y: -4, transition: { duration: 0.2 } }} <CardContent>
className={`relative overflow-hidden rounded-xl border bg-gradient-to-br p-5 ${colorClasses[color]} backdrop-blur-sm`} <div className="flex items-start justify-between">
> <div>
<div className="flex items-start justify-between"> <p className="text-slate-400 text-sm font-medium mb-1">{title}</p>
<div> <p className="text-3xl font-bold text-white">{value}</p>
<p className="text-slate-400 text-sm font-medium mb-1">{title}</p> <p className="text-slate-500 text-xs mt-1">{subtitle}</p>
<p className="text-3xl font-bold text-white">{value}</p> </div>
<div className="flex items-center gap-2 mt-2"> <div className={`p-2.5 rounded-lg ${colors.bg}`}>
{trend && ( <Icon className={`w-5 h-5 ${colors.text}`} />
<span className={`text-xs ${trend === "up" ? "text-emerald-400" : trend === "down" ? "text-red-400" : "text-slate-400"}`}>
{trend === "up" ? "↑" : trend === "down" ? "↓" : "→"}
</span>
)}
<span className="text-slate-500 text-xs">{subtitle}</span>
</div> </div>
</div> </div>
<div className={`p-2.5 rounded-lg bg-${color}-500/10`}> <div className="mt-4 h-12">
<Icon className={`w-5 h-5 text-${color}-400`} style={{ color: color === "emerald" ? "#34d399" : color === "blue" ? "#60a5fa" : color === "purple" ? "#a78bfa" : "#fbbf24" }} /> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
stroke={colors.bar}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div> </div>
</div> </CardContent>
</Card>
<div className="mt-4 h-12">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
stroke={color === "emerald" ? "#34d399" : color === "blue" ? "#60a5fa" : color === "purple" ? "#a78bfa" : "#fbbf24"}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</motion.div>
); );
}; };
// Service Card Component
const ServiceCard = ({ const ServiceCard = ({
app, app,
status, status,
@ -183,7 +240,7 @@ const ServiceCard = ({
onDelete: () => void; onDelete: () => void;
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const sparklineData = generateSparklineData(12, status.isUp); const sparklineData = generateSparklineData(10, status.isUp);
return ( return (
<motion.div <motion.div
@ -191,117 +248,114 @@ const ServiceCard = ({
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
whileHover={{ y: -2 }} whileHover={{ scale: 1.02, transition: { duration: 0.2 } }}
onHoverStart={() => setIsHovered(true)} onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)} onHoverEnd={() => setIsHovered(false)}
className="group relative bg-slate-900/50 border border-slate-800 rounded-xl p-5 hover:border-slate-700 transition-all duration-300" className="group"
> >
{/* Status Indicator Line */} <Card className="h-full hover:border-slate-700/60 transition-all duration-300 hover:shadow-xl hover:shadow-black/20">
<div className={`absolute top-0 left-4 right-4 h-0.5 rounded-full ${status.isUp ? "bg-emerald-500" : "bg-red-500"}`} /> {/* Top colored line */}
<div className={`h-1 w-full ${status.isUp ? "bg-emerald-500" : "bg-red-500"}`} />
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${status.isUp ? "bg-emerald-500/10" : "bg-red-500/10"}`}>
<Server className={`w-5 h-5 ${status.isUp ? "text-emerald-400" : "text-red-400"}`} />
</div>
<div>
<h3 className="font-semibold text-white flex items-center gap-2">
{app.name}
<a href={app.url} target="_blank" rel="noopener noreferrer" className="text-slate-500 hover:text-emerald-400 transition-colors">
<ExternalLink className="w-3.5 h-3.5" />
</a>
</h3>
<p className="text-xs text-slate-500">Port {app.port}</p>
</div>
</div>
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${ <CardContent className="space-y-4">
status.isUp {/* Header */}
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400" <div className="flex items-start justify-between">
: "bg-red-500/10 border-red-500/20 text-red-400" <div className="flex items-center gap-3">
}`}> <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${status.isUp ? "bg-emerald-500/10" : "bg-red-500/10"}`}>
<span className="relative flex h-1.5 w-1.5"> <Server className={`w-5 h-5 ${status.isUp ? "text-emerald-400" : "text-red-400"}`} />
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status.isUp ? "bg-emerald-400" : "bg-red-400"}`}></span> </div>
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${status.isUp ? "bg-emerald-500" : "bg-red-500"}`}></span> <div>
</span> <h3 className="font-semibold text-white flex items-center gap-2">
{status.isUp ? "Operational" : "Down"} {app.name}
</div> <a
</div> href={app.url}
target="_blank"
{/* Metrics */} rel="noopener noreferrer"
<div className="space-y-4"> className="text-slate-500 hover:text-emerald-400 transition-colors"
{/* Uptime */} >
<div> <ExternalLink className="w-3.5 h-3.5" />
<div className="flex items-center justify-between mb-1.5"> </a>
<span className="text-xs text-slate-400">Uptime</span> </h3>
<span className="text-sm font-semibold text-white">{status.uptime}%</span> <p className="text-xs text-slate-500">Port {app.port}</p>
</div>
</div>
<Badge variant={status.isUp ? "success" : "destructive"}>
<span className="relative flex h-1.5 w-1.5 mr-1">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status.isUp ? "bg-emerald-400" : "bg-red-400"}`} />
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${status.isUp ? "bg-emerald-500" : "bg-red-500"}`} />
</span>
{status.isUp ? "Operational" : "Down"}
</Badge>
</div> </div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
<motion.div {/* Uptime */}
initial={{ width: 0 }} <div>
animate={{ width: `${status.uptime}%` }} <div className="flex items-center justify-between mb-2">
transition={{ duration: 1, delay: 0.2 }} <span className="text-sm text-slate-400">Uptime</span>
className={`h-full rounded-full ${status.uptime > 95 ? "bg-emerald-500" : status.uptime > 80 ? "bg-amber-500" : "bg-red-500"}`} <span className="text-lg font-semibold text-white">{status.uptime}%</span>
/> </div>
<Progress value={status.uptime} />
</div> </div>
</div>
{/* Response Time */} {/* Response Time */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-slate-400">Response Time</span> <span className="text-sm text-slate-400">Response Time</span>
<span className="text-lg font-semibold text-white"> <span className="text-2xl font-bold text-white">
{status.avgResponseTime > 0 ? `${status.avgResponseTime}ms` : "—"} {status.avgResponseTime > 0 ? `${status.avgResponseTime}ms` : "—"}
</span> </span>
</div> </div>
{/* Sparkline */} {/* Sparkline */}
<div className="h-10 -mx-1"> <div className="h-10 -mx-2">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={sparklineData}> <LineChart data={sparklineData}>
<Line <Line
type="monotone" type="monotone"
dataKey="value" dataKey="value"
stroke={status.isUp ? "#34d399" : "#f87171"} stroke={status.isUp ? "#10b981" : "#ef4444"}
strokeWidth={1.5} strokeWidth={1.5}
dot={false} dot={false}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Last Checked */} {/* Footer */}
<div className="flex items-center justify-between text-xs text-slate-500"> <div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
<span className="flex items-center gap-1"> <span className="text-xs text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{status.latest ? new Date(status.latest.timestamp).toLocaleTimeString() : "Never"} {status.latest ? new Date(status.latest.timestamp).toLocaleTimeString() : "Never"}
</span> </span>
{/* Hover Actions */} <AnimatePresence>
<AnimatePresence> {isHovered && (
{isHovered && ( <motion.div
<motion.div initial={{ opacity: 0, x: 10 }}
initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }}
animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }}
exit={{ opacity: 0, x: 10 }} className="flex items-center gap-1"
className="flex items-center gap-1"
>
<button className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
<Settings className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
> >
<Trash2 className="w-3.5 h-3.5" /> <Button variant="ghost" size="icon" className="h-8 w-8">
</button> <RefreshCw className="w-4 h-4" />
</motion.div> </Button>
)} <Button variant="ghost" size="icon" className="h-8 w-8">
</AnimatePresence> <Settings className="w-4 h-4" />
</div> </Button>
</div> <Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:text-red-400 hover:bg-red-500/10"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</CardContent>
</Card>
</motion.div> </motion.div>
); );
}; };
@ -310,7 +364,6 @@ export default function Dashboard() {
const [apps, setApps] = useState<App[]>([]); const [apps, setApps] = useState<App[]>([]);
const [status, setStatus] = useState<StatusEntry[]>([]); const [status, setStatus] = useState<StatusEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [activeNav, setActiveNav] = useState("overview"); const [activeNav, setActiveNav] = useState("overview");
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [newApp, setNewApp] = useState<Partial<App>>({ const [newApp, setNewApp] = useState<Partial<App>>({
@ -320,14 +373,6 @@ export default function Dashboard() {
enabled: true, enabled: true,
}); });
const navItems: NavItem[] = [
{ id: "overview", label: "Overview", icon: <LayoutDashboard className="w-5 h-5" /> },
{ id: "services", label: "Monitored Services", icon: <Monitor className="w-5 h-5" /> },
{ id: "incidents", label: "Incidents", icon: <AlertTriangle className="w-5 h-5" /> },
{ id: "history", label: "History / Logs", icon: <History className="w-5 h-5" /> },
{ id: "settings", label: "Settings", icon: <Settings className="w-5 h-5" /> },
];
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const interval = setInterval(fetchData, 30000); const interval = setInterval(fetchData, 30000);
@ -398,7 +443,7 @@ export default function Dashboard() {
const allUp = stats.online === stats.total && stats.total > 0; const allUp = stats.online === stats.total && stats.total > 0;
// Generate sparkline data for metrics // Sparkline data for KPI cards
const servicesData = generateSparklineData(20, true); const servicesData = generateSparklineData(20, true);
const uptimeData = generateSparklineData(20, true); const uptimeData = generateSparklineData(20, true);
const responseData = generateSparklineData(20, true).map(d => ({ ...d, value: d.value * 2 })); const responseData = generateSparklineData(20, true).map(d => ({ ...d, value: d.value * 2 }));
@ -428,118 +473,98 @@ export default function Dashboard() {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex"> <div className="min-h-screen bg-slate-950 text-slate-100 flex">
{/* Collapsible Sidebar */} {/* Fixed Sidebar - 280px */}
<motion.aside <aside className="fixed left-0 top-0 bottom-0 w-[280px] bg-slate-900/80 backdrop-blur-xl border-r border-slate-800/60 z-50 flex flex-col">
initial={false} {/* Logo */}
animate={{ width: sidebarCollapsed ? 72 : 240 }} <div className="h-16 flex items-center px-4 border-b border-slate-800/60">
className="fixed left-0 top-0 bottom-0 bg-slate-900/80 backdrop-blur-xl border-r border-slate-800 z-50 flex flex-col" <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
> <Activity className="w-5 h-5 text-white" />
{/* Logo Area */}
<div className="h-16 flex items-center px-4 border-b border-slate-800">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<Activity className="w-4 h-4 text-white" />
</div> </div>
<AnimatePresence mode="wait"> <span className="ml-3 font-bold text-lg text-white">Heartbeat</span>
{!sidebarCollapsed && (
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: "auto" }}
exit={{ opacity: 0, width: 0 }}
className="ml-3 font-bold text-lg whitespace-nowrap overflow-hidden"
>
Heartbeat
</motion.span>
)}
</AnimatePresence>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 p-3 space-y-1"> <nav className="flex-1 p-3 space-y-1">
{navItems.map((item) => ( <SidebarItem
<SidebarItem icon={LayoutDashboard}
key={item.id} label="Overview"
item={item} isActive={activeNav === "overview"}
isActive={activeNav === item.id} onClick={() => setActiveNav("overview")}
isCollapsed={sidebarCollapsed} />
onClick={() => setActiveNav(item.id)} <SidebarItem
/> icon={Monitor}
))} label="Monitored Services"
isActive={activeNav === "services"}
onClick={() => setActiveNav("services")}
/>
<SidebarItem
icon={AlertTriangle}
label="Incidents"
isActive={activeNav === "incidents"}
onClick={() => setActiveNav("incidents")}
/>
<SidebarItem
icon={History}
label="History / Logs"
isActive={activeNav === "history"}
onClick={() => setActiveNav("history")}
/>
<SidebarItem
icon={Settings}
label="Settings"
isActive={activeNav === "settings"}
onClick={() => setActiveNav("settings")}
/>
</nav> </nav>
</aside>
{/* Collapse Button */}
<div className="p-3 border-t border-slate-800">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="w-full flex items-center justify-center p-2 text-slate-400 hover:text-white hover:bg-slate-800/50 rounded-lg transition-colors"
>
{sidebarCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
</button>
</div>
</motion.aside>
{/* Main Content */} {/* Main Content */}
<main <main className="flex-1 ml-[280px] min-h-screen flex flex-col">
className="flex-1 min-h-screen transition-all duration-300"
style={{ marginLeft: sidebarCollapsed ? 72 : 240 }}
>
{/* Top Navbar */} {/* Top Navbar */}
<header className="h-16 bg-slate-900/50 backdrop-blur-sm border-b border-slate-800 sticky top-0 z-40 px-6 flex items-center justify-between"> <header className="h-16 bg-slate-900/50 backdrop-blur-sm border-b border-slate-800/60 sticky top-0 z-40 px-6 flex items-center justify-between">
{/* Global Status Banner */} {/* Global Status Banner */}
<div className="flex items-center gap-4"> <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border ${
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border ${ allUp
allUp ? "bg-emerald-500/10 border-emerald-500/20"
? "bg-emerald-500/10 border-emerald-500/20" : "bg-amber-500/10 border-amber-500/20"
: "bg-amber-500/10 border-amber-500/20" }`}>
}`}> <span className="relative flex h-2 w-2">
<span className={`relative flex h-2 w-2`}> <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`}></span> <span className={`relative inline-flex rounded-full h-2 w-2 ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
<span className={`relative inline-flex rounded-full h-2 w-2 ${allUp ? "bg-emerald-500" : "bg-amber-500"}`}></span> </span>
</span> <span className={`text-sm font-medium ${allUp ? "text-emerald-400" : "text-amber-400"}`}>
<span className={`text-sm font-medium ${allUp ? "text-emerald-400" : "text-amber-400"}`}> {allUp ? "All Systems Operational" : `${stats.offline} Service${stats.offline > 1 ? 's' : ''} Down`}
{allUp ? "All Systems Operational" : `${stats.offline} Service${stats.offline > 1 ? 's' : ''} Down`} </span>
</span> <span className="text-slate-500 text-xs"> Updated {new Date().toLocaleTimeString()}</span>
<span className="text-slate-500 text-xs"> Updated {new Date().toLocaleTimeString()}</span>
</div>
</div> </div>
{/* Right Side Actions */} {/* Right Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Search */}
<div className="relative"> <div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
className="w-64 bg-slate-800/50 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none transition-colors" className="w-64 bg-slate-800/50 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none"
/> />
</div> </div>
{/* Add Monitor */} <Button onClick={() => setShowAddModal(true)}>
<button <Plus className="w-4 h-4 mr-2" />
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
Add Monitor Add Monitor
</button> </Button>
{/* Refresh */} <Button variant="ghost" size="icon" onClick={fetchData}>
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
>
<RefreshCw className="w-5 h-5" /> <RefreshCw className="w-5 h-5" />
</button> </Button>
{/* Notifications */} <Button variant="ghost" size="icon" className="relative">
<button className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors relative">
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
{stats.incidents > 0 && ( {stats.incidents > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" /> <span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)} )}
</button> </Button>
{/* Avatar */}
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center text-white font-medium text-sm"> <div className="w-9 h-9 rounded-full bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center text-white font-medium text-sm">
MB MB
</div> </div>
@ -547,92 +572,87 @@ export default function Dashboard() {
</header> </header>
{/* Dashboard Content */} {/* Dashboard Content */}
<div className="p-6 space-y-6"> <div className="flex-1 p-6 overflow-auto">
{/* KPI Row */} <div className="max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> {/* KPI Row - 4 cards */}
<MetricCard <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
title="Services Online" <KPICard
value={`${stats.online}/${stats.total}`} title="Services Online"
subtitle={allUp ? "All healthy" : `${stats.offline} down`} value={`${stats.online}/${stats.total}`}
icon={Server} subtitle={allUp ? "All healthy" : `${stats.offline} down`}
color="emerald" icon={Server}
data={servicesData} color="emerald"
trend="up" data={servicesData}
/> />
<MetricCard <KPICard
title="Average Uptime" title="Average Uptime"
value={`${stats.avgUptime}%`} value={`${stats.avgUptime}%`}
subtitle="Last 30 days" subtitle="Last 30 days"
icon={CheckCircle2} icon={CheckCircle2}
color="blue" color="blue"
data={uptimeData} data={uptimeData}
trend="up" />
/> <KPICard
<MetricCard title="Avg Response Time"
title="Avg Response Time" value={`${stats.avgResponseTime}ms`}
value={`${stats.avgResponseTime}ms`} subtitle="Across all services"
subtitle="Across all services" icon={Zap}
icon={Zap} color="purple"
color="purple" data={responseData}
data={responseData} />
trend={stats.avgResponseTime < 200 ? "up" : "down"} <KPICard
/> title="Total Incidents"
<MetricCard value={stats.incidents}
title="Total Incidents" subtitle="Last 24 hours"
value={stats.incidents} icon={AlertTriangle}
subtitle="Last 24 hours" color="amber"
icon={AlertTriangle} data={incidentsData}
color="amber" />
data={incidentsData}
trend={stats.incidents === 0 ? "up" : "down"}
/>
</div>
{/* Services Grid */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Monitored Services</h2>
<span className="text-sm text-slate-500">{stats.total} services</span>
</div> </div>
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
>
<AnimatePresence mode="popLayout">
{apps.map((app) => {
const appStatus = getAppStatus(app.id);
return (
<ServiceCard
key={app.id}
app={app}
status={appStatus}
onDelete={() => deleteApp(app.id)}
/>
);
})}
</AnimatePresence>
</motion.div>
{apps.length === 0 && ( {/* Monitored Services Section */}
<motion.div <div>
initial={{ opacity: 0, y: 20 }} <div className="flex items-center justify-between mb-4">
animate={{ opacity: 1, y: 0 }} <h2 className="text-lg font-semibold text-white">Monitored Services</h2>
className="text-center py-16 bg-slate-900/30 rounded-xl border border-dashed border-slate-800" <span className="text-sm text-slate-500">{stats.total} services</span>
</div>
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
> >
<div className="w-16 h-16 bg-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4"> <AnimatePresence mode="popLayout">
<Monitor className="w-8 h-8 text-slate-600" /> {apps.map((app) => {
</div> const appStatus = getAppStatus(app.id);
<h3 className="text-lg font-semibold text-white mb-2">No monitors yet</h3> return (
<p className="text-slate-500 mb-4">Start monitoring your services</p> <ServiceCard
<button key={app.id}
onClick={() => setShowAddModal(true)} app={app}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg transition-colors" status={appStatus}
> onDelete={() => deleteApp(app.id)}
Add Your First Monitor />
</button> );
})}
</AnimatePresence>
</motion.div> </motion.div>
)}
{apps.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16 bg-slate-900/30 rounded-xl border border-dashed border-slate-800"
>
<div className="w-16 h-16 bg-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Monitor className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">No monitors yet</h3>
<p className="text-slate-500 mb-4">Start monitoring your services</p>
<Button onClick={() => setShowAddModal(true)}>
Add Your First Monitor
</Button>
</motion.div>
)}
</div>
</div> </div>
</div> </div>
</main> </main>
@ -662,7 +682,7 @@ export default function Dashboard() {
type="text" type="text"
value={newApp.name} value={newApp.name}
onChange={(e) => setNewApp({ ...newApp, name: e.target.value })} onChange={(e) => setNewApp({ ...newApp, name: e.target.value })}
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none transition-colors" className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none"
placeholder="My Service" placeholder="My Service"
required required
/> />
@ -675,7 +695,7 @@ export default function Dashboard() {
type="url" type="url"
value={newApp.url} value={newApp.url}
onChange={(e) => setNewApp({ ...newApp, url: e.target.value })} onChange={(e) => setNewApp({ ...newApp, url: e.target.value })}
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none transition-colors" className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none"
placeholder="http://localhost:3000" placeholder="http://localhost:3000"
required required
/> />
@ -686,26 +706,24 @@ export default function Dashboard() {
type="number" type="number"
value={newApp.port} value={newApp.port}
onChange={(e) => setNewApp({ ...newApp, port: parseInt(e.target.value) })} onChange={(e) => setNewApp({ ...newApp, port: parseInt(e.target.value) })}
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none transition-colors" className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:border-emerald-500/50 focus:outline-none"
required required
/> />
</div> </div>
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<button <Button
type="button" type="button"
variant="outline"
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-slate-800 transition-colors" className="flex-1"
> >
Cancel Cancel
</button> </Button>
<button <Button type="submit" className="flex-1">
type="submit"
className="flex-1 px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition-colors"
>
Add Monitor Add Monitor
</button> </Button>
</div> </div>
</form> </form>
</motion.div> </motion.div>
@ -714,4 +732,4 @@ export default function Dashboard() {
</AnimatePresence> </AnimatePresence>
</div> </div>
); );
} }