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":
/*!**********************************************************!*\
!*** ./node_modules/lucide-react/dist/esm/icons/plus.js ***!

View File

@ -130,7 +130,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("8a3e399d90687866")
/******/ __webpack_require__.h = () => ("175a446eeb5052e2")
/******/ })();
/******/
/******/ /* 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_require__.h = () => ("c48572fb170ff111")
/******/ __webpack_require__.h = () => ("1122623f302a8ef0")
/******/ })();
/******/
/******/ /* 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",
"timestamp": "2026-02-18T19:39:06.827Z",
"status": "down"
},
{
"appId": "blog-backup",
"timestamp": "2026-02-18T19:39:48.171Z",
"status": "down"
}
]
}

View File

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