Replace cards with simple table layout

This commit is contained in:
OpenClaw Bot 2026-02-18 13:43:44 -06:00
parent 08f1ff6af8
commit 28cd726320
24 changed files with 170 additions and 4965 deletions

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.

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/layout-grid.js":
/*!*****************************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/layout-grid.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 */ LayoutGrid)\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 \"rect\",\n {\n width: \"7\",\n height: \"7\",\n x: \"3\",\n y: \"3\",\n rx: \"1\",\n key: \"1g98yp\"\n }\n ],\n [\n \"rect\",\n {\n width: \"7\",\n height: \"7\",\n x: \"14\",\n y: \"3\",\n rx: \"1\",\n key: \"6d4xhi\"\n }\n ],\n [\n \"rect\",\n {\n width: \"7\",\n height: \"7\",\n x: \"14\",\n y: \"14\",\n rx: \"1\",\n key: \"nxv5o0\"\n }\n ],\n [\n \"rect\",\n {\n width: \"7\",\n height: \"7\",\n x: \"3\",\n y: \"14\",\n rx: \"1\",\n key: \"1bb6yr\"\n }\n ]\n];\nconst LayoutGrid = (0,_createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(\"LayoutGrid\", __iconNode);\n //# sourceMappingURL=layout-grid.js.map\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvbHVjaWRlLXJlYWN0L2Rpc3QvZXNtL2ljb25zL2xheW91dC1ncmlkLmpzIiwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7OztBQUdPLE1BQU0sVUFBdUI7SUFDbEM7UUFBQyxPQUFRO1FBQUE7WUFBRSxPQUFPO1lBQUssQ0FBUTtZQUFLLENBQUc7WUFBSyxHQUFHLENBQUs7WUFBQSxJQUFJLENBQUs7WUFBQSxLQUFLO1FBQUEsQ0FBVTtLQUFBO0lBQzVFO1FBQUMsT0FBUTtRQUFBO1lBQUUsT0FBTztZQUFLLENBQVE7WUFBSyxDQUFHO1lBQU0sR0FBRyxDQUFLO1lBQUEsSUFBSSxDQUFLO1lBQUEsS0FBSztRQUFBLENBQVU7S0FBQTtJQUM3RTtRQUFDLE9BQVE7UUFBQTtZQUFFLE9BQU87WUFBSyxDQUFRO1lBQUssQ0FBRztZQUFNLEdBQUcsQ0FBTTtZQUFBLElBQUksQ0FBSztZQUFBLEtBQUs7UUFBQSxDQUFVO0tBQUE7SUFDOUU7UUFBQyxPQUFRO1FBQUE7WUFBRSxPQUFPO1lBQUssQ0FBUTtZQUFLLENBQUc7WUFBSyxHQUFHLENBQU07WUFBQSxJQUFJLENBQUs7WUFBQSxLQUFLO1FBQUEsQ0FBVTtLQUFBO0NBQy9FO0FBYU0saUJBQWEsa0VBQWlCLGVBQWMsQ0FBVSIsInNvdXJjZXMiOlsiL1VzZXJzL21hdHRicnVjZS9Eb2N1bWVudHMvUHJvamVjdHMvc3JjL2ljb25zL2xheW91dC1ncmlkLnRzIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjcmVhdGVMdWNpZGVJY29uIGZyb20gJy4uL2NyZWF0ZUx1Y2lkZUljb24nO1xuaW1wb3J0IHsgSWNvbk5vZGUgfSBmcm9tICcuLi90eXBlcyc7XG5cbmV4cG9ydCBjb25zdCBfX2ljb25Ob2RlOiBJY29uTm9kZSA9IFtcbiAgWydyZWN0JywgeyB3aWR0aDogJzcnLCBoZWlnaHQ6ICc3JywgeDogJzMnLCB5OiAnMycsIHJ4OiAnMScsIGtleTogJzFnOTh5cCcgfV0sXG4gIFsncmVjdCcsIHsgd2lkdGg6ICc3JywgaGVpZ2h0OiAnNycsIHg6ICcxNCcsIHk6ICczJywgcng6ICcxJywga2V5OiAnNmQ0eGhpJyB9XSxcbiAgWydyZWN0JywgeyB3aWR0aDogJzcnLCBoZWlnaHQ6ICc3JywgeDogJzE0JywgeTogJzE0Jywgcng6ICcxJywga2V5OiAnbnh2NW8wJyB9XSxcbiAgWydyZWN0JywgeyB3aWR0aDogJzcnLCBoZWlnaHQ6ICc3JywgeDogJzMnLCB5OiAnMTQnLCByeDogJzEnLCBrZXk6ICcxYmI2eXInIH1dLFxuXTtcblxuLyoqXG4gKiBAY29tcG9uZW50IEBuYW1lIExheW91dEdyaWRcbiAqIEBkZXNjcmlwdGlvbiBMdWNpZGUgU1ZHIGljb24gY29tcG9uZW50LCByZW5kZXJzIFNWRyBFbGVtZW50IHdpdGggY2hpbGRyZW4uXG4gKlxuICogQHByZXZpZXcgIVtpbWddKGRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QWdlRzFzYm5NOUltaDBkSEE2THk5M2QzY3Vkek11YjNKbkx6SXdNREF2YzNabklnb2dJSGRwWkhSb1BTSXlOQ0lLSUNCb1pXbG5hSFE5SWpJMElnb2dJSFpwWlhkQ2IzZzlJakFnTUNBeU5DQXlOQ0lLSUNCbWFXeHNQU0p1YjI1bElnb2dJSE4wY205clpUMGlJekF3TUNJZ2MzUjViR1U5SW1KaFkydG5jbTkxYm1RdFkyOXNiM0k2SUNObVptWTdJR0p2Y21SbGNpMXlZV1JwZFhNNklESndlQ0lLSUNCemRISnZhMlV0ZDJsa2RHZzlJaklpQ2lBZ2MzUnliMnRsTFd4cGJtVmpZWEE5SW5KdmRXNWtJZ29nSUhOMGNtOXJaUzFzYVc1bGFtOXBiajBpY205MWJtUWlDajRLSUNBOGNtVmpkQ0IzYVdSMGFEMGlOeUlnYUdWcFoyaDBQU0kzSWlCNFBTSXpJaUI1UFNJeklpQnllRDBpTVNJZ0x6NEtJQ0E4Y21WamRDQjNhV1IwYUQwaU55SWdhR1ZwWjJoMFBTSTNJaUI0UFNJeE5DSWdlVDBpTXlJZ2NuZzlJakVpSUM4K0NpQWdQSEpsWTNRZ2QybGtkR2c5SWpjaUlHaGxhV2RvZEQwaU55SWdlRDBpTVRRaUlIazlJakUwSWlCeWVEMGlNU0lnTHo0S0lDQThjbVZqZENCM2FXUjBhRDBpTnlJZ2FHVnBaMmgwUFNJM0lpQjRQU0l6SWlCNVBTSXhOQ0lnY25nOUlqRWlJQzgrQ2p3dmMzWm5QZ289KSAtIGh0dHBzOi8vbHVjaWRlLmRldi9pY29ucy9sYXlvdXQtZ3JpZFxuICogQHNlZSBodHRwczovL2x1Y2lkZS5kZXYvZ3VpZGUvcGFja2FnZXMvbHVjaWRlLXJlYWN0IC0gRG9jdW1lbnRhdGlvblxuICpcbiAqIEBwYXJhbSB7T2JqZWN0fSBwcm9wcyAtIEx1Y2lkZSBpY29ucyBwcm9wcyBhbmQgYW55IHZhbGlkIFNWRyBhdHRyaWJ1dGVcbiAqIEByZXR1cm5zIHtKU1guRWxlbWVudH0gSlNYIEVsZW1lbnRcbiAqXG4gKi9cbmNvbnN0IExheW91dEdyaWQgPSBjcmVhdGVMdWNpZGVJY29uKCdMYXlvdXRHcmlkJywgX19pY29uTm9kZSk7XG5cbmV4cG9ydCBkZWZhdWx0IExheW91dEdyaWQ7XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/lucide-react/dist/esm/icons/layout-grid.js\n");
/***/ }),
/***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/list.js":
/*!**********************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/list.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 */ List)\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: \"M3 12h.01\",\n key: \"nlz23k\"\n }\n ],\n [\n \"path\",\n {\n d: \"M3 18h.01\",\n key: \"1tta3j\"\n }\n ],\n [\n \"path\",\n {\n d: \"M3 6h.01\",\n key: \"1rqtza\"\n }\n ],\n [\n \"path\",\n {\n d: \"M8 12h13\",\n key: \"1za7za\"\n }\n ],\n [\n \"path\",\n {\n d: \"M8 18h13\",\n key: \"1lx6n3\"\n }\n ],\n [\n \"path\",\n {\n d: \"M8 6h13\",\n key: \"ik3vkj\"\n }\n ]\n];\nconst List = (0,_createLucideIcon_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(\"List\", __iconNode);\n //# sourceMappingURL=list.js.map\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvbHVjaWRlLXJlYWN0L2Rpc3QvZXNtL2ljb25zL2xpc3QuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7O0FBR08sTUFBTSxVQUF1QjtJQUNsQztRQUFDLE1BQVE7UUFBQTtZQUFFLEdBQUcsQ0FBYTtZQUFBLEtBQUs7UUFBQSxDQUFVO0tBQUE7SUFDMUM7UUFBQyxNQUFRO1FBQUE7WUFBRSxHQUFHLENBQWE7WUFBQSxLQUFLO1FBQUEsQ0FBVTtLQUFBO0lBQzFDO1FBQUMsTUFBUTtRQUFBO1lBQUUsR0FBRyxDQUFZO1lBQUEsS0FBSztRQUFBLENBQVU7S0FBQTtJQUN6QztRQUFDLE1BQVE7UUFBQTtZQUFFLEdBQUcsQ0FBWTtZQUFBLEtBQUs7UUFBQSxDQUFVO0tBQUE7SUFDekM7UUFBQyxNQUFRO1FBQUE7WUFBRSxHQUFHLENBQVk7WUFBQSxLQUFLO1FBQUEsQ0FBVTtLQUFBO0lBQ3pDO1FBQUMsTUFBUTtRQUFBO1lBQUUsR0FBRyxDQUFXO1lBQUEsS0FBSztRQUFBLENBQVU7S0FBQTtDQUMxQztBQWFNLFdBQU8sa0VBQWlCLFNBQVEsQ0FBVSIsInNvdXJjZXMiOlsiL1VzZXJzL21hdHRicnVjZS9Eb2N1bWVudHMvUHJvamVjdHMvc3JjL2ljb25zL2xpc3QudHMiXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IGNyZWF0ZUx1Y2lkZUljb24gZnJvbSAnLi4vY3JlYXRlTHVjaWRlSWNvbic7XG5pbXBvcnQgeyBJY29uTm9kZSB9IGZyb20gJy4uL3R5cGVzJztcblxuZXhwb3J0IGNvbnN0IF9faWNvbk5vZGU6IEljb25Ob2RlID0gW1xuICBbJ3BhdGgnLCB7IGQ6ICdNMyAxMmguMDEnLCBrZXk6ICdubHoyM2snIH1dLFxuICBbJ3BhdGgnLCB7IGQ6ICdNMyAxOGguMDEnLCBrZXk6ICcxdHRhM2onIH1dLFxuICBbJ3BhdGgnLCB7IGQ6ICdNMyA2aC4wMScsIGtleTogJzFycXR6YScgfV0sXG4gIFsncGF0aCcsIHsgZDogJ004IDEyaDEzJywga2V5OiAnMXphN3phJyB9XSxcbiAgWydwYXRoJywgeyBkOiAnTTggMThoMTMnLCBrZXk6ICcxbHg2bjMnIH1dLFxuICBbJ3BhdGgnLCB7IGQ6ICdNOCA2aDEzJywga2V5OiAnaWszdmtqJyB9XSxcbl07XG5cbi8qKlxuICogQGNvbXBvbmVudCBAbmFtZSBMaXN0XG4gKiBAZGVzY3JpcHRpb24gTHVjaWRlIFNWRyBpY29uIGNvbXBvbmVudCwgcmVuZGVycyBTVkcgRWxlbWVudCB3aXRoIGNoaWxkcmVuLlxuICpcbiAqIEBwcmV2aWV3ICFbaW1nXShkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUFnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JZ29nSUhkcFpIUm9QU0l5TkNJS0lDQm9aV2xuYUhROUlqSTBJZ29nSUhacFpYZENiM2c5SWpBZ01DQXlOQ0F5TkNJS0lDQm1hV3hzUFNKdWIyNWxJZ29nSUhOMGNtOXJaVDBpSXpBd01DSWdjM1I1YkdVOUltSmhZMnRuY205MWJtUXRZMjlzYjNJNklDTm1abVk3SUdKdmNtUmxjaTF5WVdScGRYTTZJREp3ZUNJS0lDQnpkSEp2YTJVdGQybGtkR2c5SWpJaUNpQWdjM1J5YjJ0bExXeHBibVZqWVhBOUluSnZkVzVrSWdvZ0lITjBjbTlyWlMxc2FXNWxhbTlwYmowaWNtOTFibVFpQ2o0S0lDQThjR0YwYUNCa1BTSk5NeUF4TW1ndU1ERWlJQzgrQ2lBZ1BIQmhkR2dnWkQwaVRUTWdNVGhvTGpBeElpQXZQZ29nSUR4d1lYUm9JR1E5SWsweklEWm9MakF4SWlBdlBnb2dJRHh3WVhSb0lHUTlJazA0SURFeWFERXpJaUF2UGdvZ0lEeHdZWFJvSUdROUlrMDRJREU0YURFeklpQXZQZ29nSUR4d1lYUm9JR1E5SWswNElEWm9NVE1pSUM4K0Nqd3ZjM1puUGdvPSkgLSBodHRwczovL2x1Y2lkZS5kZXYvaWNvbnMvbGlzdFxuICogQHNlZSBodHRwczovL2x1Y2lkZS5kZXYvZ3VpZGUvcGFja2FnZXMvbHVjaWRlLXJlYWN0IC0gRG9jdW1lbnRhdGlvblxuICpcbiAqIEBwYXJhbSB7T2JqZWN0fSBwcm9wcyAtIEx1Y2lkZSBpY29ucyBwcm9wcyBhbmQgYW55IHZhbGlkIFNWRyBhdHRyaWJ1dGVcbiAqIEByZXR1cm5zIHtKU1guRWxlbWVudH0gSlNYIEVsZW1lbnRcbiAqXG4gKi9cbmNvbnN0IExpc3QgPSBjcmVhdGVMdWNpZGVJY29uKCdMaXN0JywgX19pY29uTm9kZSk7XG5cbmV4cG9ydCBkZWZhdWx0IExpc3Q7XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/lucide-react/dist/esm/icons/list.js\n");
/***/ }),
/***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/plus.js": /***/ "(ssr)/./node_modules/lucide-react/dist/esm/icons/plus.js":
/*!**********************************************************!*\ /*!**********************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/plus.js ***! !*** ./node_modules/lucide-react/dist/esm/icons/plus.js ***!

View File

@ -130,7 +130,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("8a3e399d90687866") /******/ __webpack_require__.h = () => ("175a446eeb5052e2")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

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 = () => ("c48572fb170ff111") /******/ __webpack_require__.h = () => ("1122623f302a8ef0")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/global */ /******/ /* webpack/runtime/global */

File diff suppressed because one or more lines are too long

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 = () => ("1122623f302a8ef0")
/******/ })();
/******/
/******/ }
);

File diff suppressed because one or more lines are too long

View File

@ -100,6 +100,11 @@
"appId": "blog-backup", "appId": "blog-backup",
"timestamp": "2026-02-18T19:39:06.827Z", "timestamp": "2026-02-18T19:39:06.827Z",
"status": "down" "status": "down"
},
{
"appId": "blog-backup",
"timestamp": "2026-02-18T19:39:48.171Z",
"status": "down"
} }
] ]
} }

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Activity, Plus, RefreshCw, Trash2, ExternalLink, LayoutGrid, List, Settings } from "lucide-react"; import { Activity, Plus, RefreshCw, Trash2, ExternalLink } from "lucide-react";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
interface App { interface App {
id: string; id: string;
@ -30,8 +29,6 @@ export default function HeartbeatMonitor() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState<string | null>(null); const [checking, setChecking] = useState<string | null>(null);
const [showAddApp, setShowAddApp] = useState(false); const [showAddApp, setShowAddApp] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedApp, setSelectedApp] = useState<App | null>(null);
const [newApp, setNewApp] = useState<Partial<App>>({ const [newApp, setNewApp] = useState<Partial<App>>({
name: "", name: "",
description: "", description: "",
@ -146,12 +143,11 @@ export default function HeartbeatMonitor() {
? Math.round((appStatus.filter(s => s.status === "up").length / appStatus.length) * 100) ? Math.round((appStatus.filter(s => s.status === "up").length / appStatus.length) * 100)
: 100; : 100;
return { latest, isUp, uptime, history: appStatus.slice(-10) }; return { latest, isUp, uptime };
} }
const totalApps = apps.length; const totalApps = apps.length;
const onlineApps = apps.filter((app) => getAppStatus(app.id).isUp).length; const onlineApps = apps.filter((app) => getAppStatus(app.id).isUp).length;
const offlineApps = totalApps - onlineApps;
if (loading) { if (loading) {
return ( return (
@ -162,213 +158,131 @@ export default function HeartbeatMonitor() {
} }
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100 p-6">
{/* Header */} {/* Header */}
<header className="bg-slate-900 border-b border-slate-800"> <div className="max-w-6xl mx-auto mb-6">
<div className="max-w-7xl mx-auto px-4 py-4"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <Activity className="w-8 h-8 text-emerald-500" />
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center"> <div>
<Activity className="w-5 h-5 text-white" /> <h1 className="text-2xl font-bold">Heartbeat Monitor</h1>
</div> <p className="text-slate-400">{onlineApps} of {totalApps} services online</p>
<div>
<h1 className="text-xl font-bold text-white">Heartbeat Monitor</h1>
<p className="text-sm text-slate-400">
{onlineApps} of {totalApps} services online
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode(viewMode === "grid" ? "list" : "grid")}
className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white"
>
{viewMode === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
</button>
<button
onClick={fetchData}
className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white"
>
<RefreshCw className="w-5 h-5" />
</button>
<button
onClick={() => setShowAddApp(true)}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add
</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white"
>
<RefreshCw className="w-5 h-5" />
</button>
<button
onClick={() => setShowAddApp(true)}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg"
>
<Plus className="w-4 h-4" />
Add App
</button>
</div>
</div> </div>
</header> </div>
{/* Main Content */} {/* Table */}
<main className="max-w-7xl mx-auto px-4 py-6"> <div className="max-w-6xl mx-auto">
{viewMode === "grid" ? ( <div className="bg-slate-900 rounded-lg border border-slate-800 overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <table className="w-full">
{apps.map((app) => { <thead>
const { isUp, uptime, history, latest } = getAppStatus(app.id); <tr className="bg-slate-800 text-left">
<th className="px-4 py-3 text-sm font-medium text-slate-400">Status</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Name</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">URL</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Port</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Uptime</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Response</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Actions</th>
</tr>
</thead>
<tbody>
{apps.map((app) => {
const { isUp, uptime, latest } = getAppStatus(app.id);
return ( return (
<div <tr key={app.id} className="border-t border-slate-800 hover:bg-slate-800/50">
key={app.id} <td className="px-4 py-3">
className={`bg-slate-900 rounded-xl border-2 p-5 transition-all hover:scale-[1.02] cursor-pointer ${ <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
isUp ? "border-emerald-500/30" : "border-red-500/30" isUp
}`} ? "bg-emerald-500/20 text-emerald-400"
onClick={() => setSelectedApp(app)} : "bg-red-500/20 text-red-400"
> }`}>
{/* Status Badge */} <span className={`w-1.5 h-1.5 rounded-full ${isUp ? "bg-emerald-400" : "bg-red-400"}`} />
<div className="flex items-center justify-between mb-4"> {isUp ? "ONLINE" : "OFFLINE"}
<span className={`px-3 py-1 rounded-full text-xs font-bold ${ </span>
isUp </td>
? "bg-emerald-500/20 text-emerald-400" <td className="px-4 py-3">
: "bg-red-500/20 text-red-400" <div>
}`}> <p className="font-medium text-white">{app.name}</p>
{isUp ? "● ONLINE" : "● OFFLINE"} <p className="text-sm text-slate-500">{app.description}</p>
</span> </div>
<span className="text-slate-500 text-sm">{app.port}</span> </td>
</div> <td className="px-4 py-3">
<a
{/* App Info */} href={app.url}
<div className="mb-4"> target="_blank"
<h3 className="text-lg font-bold text-white mb-1">{app.name}</h3> rel="noopener noreferrer"
<p className="text-slate-400 text-sm">{app.description || "No description"}</p> className="text-emerald-400 hover:text-emerald-300 text-sm"
</div> >
{app.url}
{/* Stats Grid */} </a>
<div className="grid grid-cols-2 gap-3 mb-4"> </td>
<div className="bg-slate-800 rounded-lg p-3"> <td className="px-4 py-3 text-slate-300 font-mono">{app.port}</td>
<p className="text-xs text-slate-500 mb-1">Uptime</p> <td className="px-4 py-3">
<p className={`text-xl font-bold ${uptime >= 90 ? "text-emerald-400" : "text-yellow-400"}`}> <span className={`font-mono ${uptime >= 90 ? "text-emerald-400" : "text-yellow-400"}`}>
{uptime}% {uptime}%
</p> </span>
</div> </td>
<div className="bg-slate-800 rounded-lg p-3"> <td className="px-4 py-3 text-slate-300 font-mono">
<p className="text-xs text-slate-500 mb-1">Response</p> {latest?.responseTime ? `${latest.responseTime}ms` : "--"}
<p className="text-xl font-bold text-white"> </td>
{latest?.responseTime ? `${latest.responseTime}ms` : "--"} <td className="px-4 py-3">
</p> <div className="flex items-center gap-2">
</div> <button
</div> onClick={() => checkApp(app)}
disabled={checking === app.id}
{/* Mini Chart */} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
{history.length > 1 && ( title="Check now"
<div className="h-16 mb-4"> >
<ResponsiveContainer width="100%" height="100%"> <RefreshCw className={`w-4 h-4 ${checking === app.id ? "animate-spin" : ""}`} />
<LineChart data={history.map((h, i) => ({ i, status: h.status === "up" ? 1 : 0 }))}> </button>
<Line <a
type="step" href={app.url}
dataKey="status" target="_blank"
stroke={isUp ? "#22C55E" : "#EF4444"} rel="noopener noreferrer"
strokeWidth={2} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
dot={false} title="Open app"
/> >
</LineChart> <ExternalLink className="w-4 h-4" />
</ResponsiveContainer> </a>
</div> <button
)} onClick={() => deleteApp(app.id)}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-slate-700 rounded"
{/* Actions */} title="Delete"
<div className="flex gap-2"> >
<button <Trash2 className="w-4 h-4" />
onClick={(e) => { </button>
e.stopPropagation(); </div>
checkApp(app); </td>
}} </tr>
disabled={checking === app.id} );
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 py-2 rounded-lg text-sm font-medium transition-colors" })}
> </tbody>
{checking === app.id ? "Checking..." : "Check Now"} </table>
</button> </div>
<a </div>
href={app.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
);
})}
</div>
) : (
/* List View */
<div className="space-y-2">
{apps.map((app) => {
const { isUp, uptime, latest } = getAppStatus(app.id);
return (
<div
key={app.id}
className={`flex items-center gap-4 bg-slate-900 rounded-lg border-l-4 p-4 ${
isUp ? "border-l-emerald-500" : "border-l-red-500"
}`}
>
<div className={`w-3 h-3 rounded-full ${isUp ? "bg-emerald-500" : "bg-red-500"}`} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">{app.name}</h3>
<p className="text-sm text-slate-400 truncate">{app.url}</p>
</div>
<div className="hidden sm:flex items-center gap-6 text-sm">
<div className="text-center">
<p className="text-slate-500">Port</p>
<p className="font-mono text-white">{app.port}</p>
</div>
<div className="text-center">
<p className="text-slate-500">Uptime</p>
<p className={`font-mono ${uptime >= 90 ? "text-emerald-400" : "text-yellow-400"}`}>
{uptime}%
</p>
</div>
<div className="text-center">
<p className="text-slate-500">Response</p>
<p className="font-mono text-white">
{latest?.responseTime ? `${latest.responseTime}ms` : "--"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => checkApp(app)}
disabled={checking === app.id}
className="p-2 text-slate-400 hover:text-white"
>
<RefreshCw className={`w-4 h-4 ${checking === app.id ? "animate-spin" : ""}`} />
</button>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-slate-400 hover:text-white"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={() => deleteApp(app.id)}
className="p-2 text-slate-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</main>
{/* Add App Modal */} {/* Add App Modal */}
{showAddApp && ( {showAddApp && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-xl p-6 w-full max-w-md"> <div className="bg-slate-900 rounded-lg p-6 w-full max-w-md border border-slate-800">
<h2 className="text-xl font-bold text-white mb-4">Add New App</h2> <h2 className="text-xl font-bold text-white mb-4">Add New App</h2>
<form onSubmit={addApp} className="space-y-4"> <form onSubmit={addApp} className="space-y-4">
<div> <div>
@ -431,57 +345,6 @@ export default function HeartbeatMonitor() {
</div> </div>
</div> </div>
)} )}
{/* App Detail Modal */}
{selectedApp && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-xl p-6 w-full max-w-lg">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">{selectedApp.name}</h2>
<button
onClick={() => setSelectedApp(null)}
className="text-slate-400 hover:text-white"
>
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-sm text-slate-500">URL</p>
<p className="text-emerald-400 font-mono text-sm break-all">{selectedApp.url}</p>
</div>
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-sm text-slate-500">Port</p>
<p className="text-white font-mono">{selectedApp.port}</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => {
checkApp(selectedApp);
setSelectedApp(null);
}}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white py-2 rounded-lg font-medium"
>
Check Now
</button>
<button
onClick={() => {
deleteApp(selectedApp.id);
setSelectedApp(null);
}}
className="flex-1 bg-red-500/20 hover:bg-red-500/30 text-red-400 py-2 rounded-lg font-medium"
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }