From e0fcd07fe0f9db2924fdf68e169c67c4ef98ce1e Mon Sep 17 00:00:00 2001 From: Prosper19 Date: Thu, 9 Jul 2026 15:22:35 +0100 Subject: [PATCH] feat(monitor): add live TUI dashboard for PM2, system metrics, and logs Terminal UI (React/Ink) that connects over SSH and displays real-time per-process PM2 stats, system CPU/memory/disk gauges, release history, and live log streaming. Supports multi-app switching, configurable polling interval, and works for both backend and frontend deployments. --- package.json | 3 + pnpm-lock.yaml | 247 ++++++++++++++++ pnpm-workspace.yaml | 4 + src/cli/commands/help.ts | 1 + src/cli/commands/monitor.ts | 41 +++ src/cli/index.ts | 9 + src/cli/monitor/App.tsx | 238 +++++++++++++++ src/cli/monitor/app-selector.tsx | 63 ++++ src/cli/monitor/charts.ts | 91 ++++++ src/cli/monitor/index.tsx | 16 ++ src/cli/monitor/panels/LogPanel.tsx | 23 ++ src/cli/monitor/panels/Pm2Panel.tsx | 110 +++++++ src/cli/monitor/panels/ReleasePanel.tsx | 46 +++ src/cli/monitor/panels/SystemPanel.tsx | 115 ++++++++ src/cli/monitor/poller.ts | 69 +++++ src/cli/monitor/state.ts | 137 +++++++++ tests/unit/monitor.test.ts | 367 ++++++++++++++++++++++++ tsconfig.json | 1 + 18 files changed, 1581 insertions(+) create mode 100644 pnpm-workspace.yaml create mode 100644 src/cli/commands/monitor.ts create mode 100644 src/cli/monitor/App.tsx create mode 100644 src/cli/monitor/app-selector.tsx create mode 100644 src/cli/monitor/charts.ts create mode 100644 src/cli/monitor/index.tsx create mode 100644 src/cli/monitor/panels/LogPanel.tsx create mode 100644 src/cli/monitor/panels/Pm2Panel.tsx create mode 100644 src/cli/monitor/panels/ReleasePanel.tsx create mode 100644 src/cli/monitor/panels/SystemPanel.tsx create mode 100644 src/cli/monitor/poller.ts create mode 100644 src/cli/monitor/state.ts create mode 100644 tests/unit/monitor.test.ts diff --git a/package.json b/package.json index a4db978..bb3655e 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,12 @@ "commander": "^12.1.0", "execa": "^9.5.1", "fs-extra": "^11.3.5", + "ink": "^6.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "listr2": "^10.2.1", "ora": "^8.1.1", + "react": "^19.0.0", "ssh2": "^1.16.0", "zod": "^3.24.1" }, @@ -43,6 +45,7 @@ "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", + "@types/react": "^19.0.0", "@types/ssh2": "^1.15.1", "@vitest/coverage-v8": "^2.1.9", "typescript": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c906fb6..1948765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: fs-extra: specifier: ^11.3.5 version: 11.3.5 + ink: + specifier: ^6.0.0 + version: 6.8.0(@types/react@19.2.17)(react@19.2.7) jiti: specifier: ^2.4.2 version: 2.7.0 @@ -35,6 +38,9 @@ importers: ora: specifier: ^8.1.1 version: 8.2.0 + react: + specifier: ^19.0.0 + version: 19.2.7 ssh2: specifier: ^1.16.0 version: 1.17.0 @@ -51,6 +57,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.19.19 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 '@types/ssh2': specifier: ^1.15.1 version: 1.15.5 @@ -69,6 +78,10 @@ importers: packages: + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -430,6 +443,9 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} @@ -501,6 +517,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -538,6 +558,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -550,6 +578,10 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -561,6 +593,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cpu-features@0.0.10: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} @@ -569,6 +605,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -601,11 +640,18 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -676,6 +722,23 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -684,6 +747,11 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -765,6 +833,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -796,6 +868,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -811,6 +887,10 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -841,6 +921,20 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -856,6 +950,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -872,6 +969,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -895,6 +995,10 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -937,6 +1041,14 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -962,6 +1074,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -972,6 +1085,10 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1067,6 +1184,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + wrap-ansi@10.0.0: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} @@ -1083,15 +1204,35 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1323,6 +1464,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 @@ -1407,6 +1552,8 @@ snapshots: assertion-error@2.0.1: {} + auto-bind@5.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -1440,6 +1587,12 @@ snapshots: check-error@2.1.3: {} + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -1451,6 +1604,10 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1459,6 +1616,8 @@ snapshots: commander@12.1.0: {} + convert-to-spaces@2.0.1: {} + cpu-features@0.0.10: dependencies: buildcheck: 0.0.7 @@ -1471,6 +1630,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1489,6 +1650,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.49.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1515,6 +1678,8 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1592,12 +1757,50 @@ snapshots: human-signals@8.0.1: {} + indent-string@5.0.0: {} + + ink@6.8.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.49.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.7 + react-reconciler: 0.33.0(react@19.2.7) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.8.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.21.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.6.0 + is-in-ci@2.0.0: {} + is-interactive@2.0.0: {} is-plain-obj@4.1.0: {} @@ -1688,6 +1891,8 @@ snapshots: dependencies: semver: 7.8.0 + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} minimatch@10.2.5: @@ -1712,6 +1917,10 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -1732,6 +1941,8 @@ snapshots: parse-ms@4.0.0: {} + patch-console@2.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -1757,6 +1968,18 @@ snapshots: dependencies: parse-ms: 4.0.0 + react-reconciler@0.33.0(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -1797,6 +2020,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + semver@7.8.0: {} shebang-command@2.0.0: @@ -1807,6 +2032,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} @@ -1831,6 +2058,10 @@ snapshots: cpu-features: 0.0.10 nan: 2.27.0 + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@3.10.0: {} @@ -1874,6 +2105,10 @@ snapshots: dependencies: has-flag: 4.0.0 + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -1896,6 +2131,10 @@ snapshots: tweetnacl@0.14.5: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} undici-types@5.26.5: {} @@ -1987,6 +2226,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 @@ -2011,6 +2254,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ws@8.21.0: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1966a65 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + cpu-features: true + esbuild: true + ssh2: true diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index 45da88c..6a3e26e 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -28,6 +28,7 @@ ${chalk.bold('PROCESS MANAGEMENT')} ${chalk.green('restart')} Restart the application ${chalk.green('stop')} Stop the application ${chalk.green('metrics')} Open PM2 monitoring dashboard + ${chalk.green('monitor')} Live TUI dashboard with stats and logs ${chalk.bold('SECURITY & MAINTENANCE')} ${chalk.green('harden')} Apply server security hardening diff --git a/src/cli/commands/monitor.ts b/src/cli/commands/monitor.ts new file mode 100644 index 0000000..8a9106a --- /dev/null +++ b/src/cli/commands/monitor.ts @@ -0,0 +1,41 @@ +import { loadConfig } from '../../config/loader.js'; +import { SshConnection } from '../../infrastructure/ssh/connection.js'; +import { getActiveApp } from '../../domain/workspace.js'; +import { runMonitor } from '../monitor/index.js'; +import { ui } from '../ui.js'; + +export async function cmdMonitor( + cwd: string, + options: { interval?: string; app?: string; config?: string }, +): Promise { + const interval = Math.max(1, parseInt(options.interval ?? '2', 10) || 2); + const config = await loadConfig(cwd, options.config); + const app = options.app ? getActiveApp(config, options.app) : config.apps[0]; + + if (!app) { + ui.error('No app configured in shipnode.config.ts'); + process.exit(1); + } + + const host = `${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`; + const ssh = new SshConnection(); + + try { + await ssh.connect(config.ssh); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ui.error(`Failed to connect to ${host}: ${msg}`); + process.exit(1); + } + + try { + await runMonitor({ + executor: ssh, + config, + app, + interval, + }); + } finally { + ssh.disconnect(); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 03aa7b0..d72f898 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ import { cmdLogs } from './commands/logs.js'; import { cmdRestart } from './commands/restart.js'; import { cmdStop } from './commands/stop.js'; import { cmdMetrics } from './commands/metrics.js'; +import { cmdMonitor } from './commands/monitor.js'; import { cmdEject } from './commands/eject.js'; import { cmdHelp } from './commands/help.js'; import { cmdUserAdd, cmdUserSync, cmdUserList, cmdUserRemove } from './commands/user.js'; @@ -148,6 +149,14 @@ program .option('--config ', 'Use a specific config file') .action((opts) => cmdMetrics(process.cwd(), opts)); +program + .command('monitor') + .description('Live TUI dashboard with PM2 stats, system metrics, and logs') + .option('--interval ', 'Polling interval in seconds (default: 2)', '2') + .option('--app ', 'Target a specific app') + .option('--config ', 'Use a specific config file') + .action((opts) => cmdMonitor(process.cwd(), opts)); + // ── Security & maintenance ──────────────────────────────────────── program diff --git a/src/cli/monitor/App.tsx b/src/cli/monitor/App.tsx new file mode 100644 index 0000000..daa89af --- /dev/null +++ b/src/cli/monitor/App.tsx @@ -0,0 +1,238 @@ +import { Box, Text, useInput, useApp } from 'ink'; +import { useEffect, useState, useRef } from 'react'; +import type { RemoteExecutor } from '../../domain/remote/executor.js'; +import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; +import { MetricsHistory, type MetricsSnapshot } from './state.js'; +import { collectMetrics, collectLogs } from './poller.js'; +import { Pm2Panel } from './panels/Pm2Panel.js'; +import { SystemPanel } from './panels/SystemPanel.js'; +import { ReleasePanel } from './panels/ReleasePanel.js'; +import { LogPanel } from './panels/LogPanel.js'; +import { AppSelector } from './app-selector.js'; +import chalk from 'chalk'; + +const ACCENT = '#d6a85d'; +const BG = '#0d1117'; +const BG_HEADER = '#161b22'; +const BORDER = '#30363d'; + +interface AppProps { + executor: RemoteExecutor; + config: ShipnodeConfig; + app: ShipnodeApp; + interval: number; +} + +export function App({ executor, config, app: initialApp, interval }: AppProps) { + const { exit } = useApp(); + const [currentApp, setCurrentApp] = useState(initialApp); + const [snapshot, setSnapshot] = useState(null); + const historyRef = useRef(new MetricsHistory()); + const [liveMode, setLiveMode] = useState(false); + const [logBuffer, setLogBuffer] = useState(''); + const [events, setEvents] = useState([]); + const [showSelector, setShowSelector] = useState(false); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(''); + + const history = historyRef.current; + + const appendEvent = (msg: string) => { + setEvents((prev) => [...prev.slice(-100), msg]); + }; + + const doPoll = async () => { + try { + const snap = await collectMetrics(executor, currentApp, config); + setSnapshot(snap); + history.push(snap); + setLastUpdate(new Date().toLocaleTimeString()); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + appendEvent(chalk.red(`Poll failed: ${msg}`)); + } + }; + + const doLogPoll = async () => { + try { + const namespace = currentApp.pm2?.apps[0]?.name ?? ''; + if (!namespace) return; + const freshLogs = await collectLogs(executor, namespace, 20); + if (freshLogs && freshLogs !== '(no logs)') { + setLogBuffer((prev) => { + const combined = prev + ? prev.split('\n').slice(-50).join('\n') + '\n' + freshLogs + : freshLogs; + return combined; + }); + } + } catch { + // best-effort + } + }; + + useEffect(() => { + doPoll(); + const timer = setInterval(doPoll, interval * 1000); + return () => clearInterval(timer); + }, [currentApp, interval]); + + useEffect(() => { + if (!liveMode) return; + const timer = setInterval(doLogPoll, interval * 1000); + return () => clearInterval(timer); + }, [liveMode, currentApp, interval]); + + useInput((input, key) => { + if (input === 'q') { + exit(); + } + if (key.tab) { + setShowSelector(true); + } + if (input === 'l' || input === 'L') { + setLiveMode((v) => { + const next = !v; + if (next) { + appendEvent(chalk.yellow('Live logs enabled')); + } else { + setLogBuffer(''); + appendEvent(chalk.yellow('Live logs disabled')); + } + return next; + }); + } + if (input === 'r' || input === 'R') { + doPoll(); + appendEvent(chalk.green('Refresh triggered')); + } + }); + + if (showSelector) { + return ( + { + setCurrentApp(app); + setShowSelector(false); + history.clear(); + setLogBuffer(''); + setLiveMode(false); + setSnapshot(null); + appendEvent(chalk.green(`Switched to ${chalk.bold(app.name)}`)); + }} + onCancel={() => setShowSelector(false)} + /> + ); + } + + const host = currentApp.domain ?? 'no domain'; + + return ( + + {/* Header */} + + + ShipNode Monitor{' '} + + + — {currentApp.name} ({currentApp.appType}) —{' '} + + {host} + + {' │'} interval: {interval}s + + {liveMode ? ( + + {' │'} logs: ON + + ) : ( + │ logs: off + )} + + {' │'} [Q]uit [Tab]switch app [L]toggle logs [R]refresh + + + + {/* Main content: PM2 + System */} + + + {snapshot ? ( + + ) : ( + + Waiting for data... + + )} + + + {snapshot ? ( + + ) : ( + + Waiting for data... + + )} + + + + {/* Release panel */} + {snapshot && ( + + + + )} + + {/* Bottom panel: events or logs */} + + {liveMode ? ( + + ) : ( + + + Events + + {events.length === 0 ? ( + No events yet. Press [L] to enable live logs. + ) : ( + events.map((e, i) => {e}) + )} + + )} + + + {/* Status bar */} + + + {' '}Last update: {lastUpdate || '—'}{' │ '} + {snapshot ? `${snapshot.processes.length} process(es)` : '—'} + + {error && ( + + {' │ '}Error: {error} + + )} + + + ); +} diff --git a/src/cli/monitor/app-selector.tsx b/src/cli/monitor/app-selector.tsx new file mode 100644 index 0000000..83bea42 --- /dev/null +++ b/src/cli/monitor/app-selector.tsx @@ -0,0 +1,63 @@ +import { Box, Text, useInput } from 'ink'; +import { useState } from 'react'; +import type { ShipnodeApp } from '../../shared/types.js'; + +const BG = '#0d1117'; +const BG_OVERLAY = '#161b22'; +const BG_SELECTED = '#1f2937'; + +interface AppSelectorProps { + apps: ShipnodeApp[]; + onSelect: (app: ShipnodeApp) => void; + onCancel: () => void; +} + +export function AppSelector({ apps, onSelect, onCancel }: AppSelectorProps) { + const [selected, setSelected] = useState(0); + + useInput((input, key) => { + if (key.return) { + onSelect(apps[selected]); + } else if (key.escape || input === 'q') { + onCancel(); + } else if (key.upArrow) { + setSelected(Math.max(0, selected - 1)); + } else if (key.downArrow) { + setSelected(Math.min(apps.length - 1, selected + 1)); + } + }); + + return ( + + + Select App ({apps.length} configured) + + {apps.map((app, i) => { + const typeLabel = app.appType === 'backend' ? 'backend' : 'frontend'; + const isSelected = i === selected; + return ( + + + {isSelected ? '❯' : ' '} {app.name} + + ({typeLabel}) + {app.domain && {app.domain}} + + ); + })} + ↑↓ navigate Enter select q/Escape cancel + + ); +} diff --git a/src/cli/monitor/charts.ts b/src/cli/monitor/charts.ts new file mode 100644 index 0000000..00a1198 --- /dev/null +++ b/src/cli/monitor/charts.ts @@ -0,0 +1,91 @@ +import chalk from 'chalk'; + +const SPARK_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const; +const BAR_FILL = '█'; +const BAR_EMPTY = '░'; + +export function sparkline(values: number[], width: number): string { + if (values.length === 0 || width <= 0) return ''; + if (values.length === 1) return SPARK_CHARS[Math.min(Math.round(values[0]), 7)].repeat(width); + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const step = range / 7; + + const sampled = sampleArray(values, width); + return sampled.map((v) => SPARK_CHARS[Math.min(Math.round((v - min) / step), 7)]).join(''); +} + +export function gauge(percent: number, width: number): string { + const clamped = Math.max(0, Math.min(1, percent)); + const filled = Math.round(clamped * width); + const empty = width - filled; + + const color = percent < 0.6 ? chalk.green : percent < 0.85 ? chalk.yellow : chalk.red; + const bar = BAR_FILL.repeat(filled) + BAR_EMPTY.repeat(Math.max(0, empty)); + return color(bar); +} + +export interface BarItem { + label: string; + value: number; +} + +export function barChart(items: BarItem[], width: number): string { + if (items.length === 0) return ''; + const maxVal = Math.max(...items.map((i) => i.value), 1); + const barWidth = Math.max(width - 20, 10); + const labelPad = Math.max(...items.map((i) => i.label.length)) + 1; + + return items + .map((item) => { + const pct = item.value / maxVal; + const filled = Math.round(pct * barWidth); + const bar = BAR_FILL.repeat(filled) + BAR_EMPTY.repeat(Math.max(0, barWidth - filled)); + const color = item.value >= maxVal * 0.8 ? chalk.yellow : item.value >= maxVal * 0.5 ? chalk.cyan : chalk.dim; + return `${item.label.padEnd(labelPad)} ${color(bar)} ${String(item.value)}`; + }) + .join('\n'); +} + +export function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0) parts.push(`${h}h`); + if (m > 0 || parts.length === 0) parts.push(`${m}m`); + return parts.join(' '); +} + +export function formatBytes(mb: number): string { + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${Math.round(mb)} MB`; +} + +export function statusDot(status: string): string { + switch (status) { + case 'online': + return chalk.green('●'); + case 'stopped': + case 'stopping': + return chalk.yellow('●'); + case 'errored': + return chalk.red('●'); + default: + return chalk.dim('●'); + } +} + +export function sampleArray(arr: number[], n: number): number[] { + if (arr.length <= n) return arr; + const step = (arr.length - 1) / (n - 1); + const result: number[] = []; + for (let i = 0; i < n; i++) { + const idx = Math.round(i * step); + result.push(arr[Math.min(idx, arr.length - 1)]); + } + return result; +} diff --git a/src/cli/monitor/index.tsx b/src/cli/monitor/index.tsx new file mode 100644 index 0000000..02fd651 --- /dev/null +++ b/src/cli/monitor/index.tsx @@ -0,0 +1,16 @@ +import { render } from 'ink'; +import type { RemoteExecutor } from '../../domain/remote/executor.js'; +import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; +import { App } from './App.js'; + +interface MonitorOptions { + executor: RemoteExecutor; + config: ShipnodeConfig; + app: ShipnodeApp; + interval: number; +} + +export async function runMonitor(options: MonitorOptions): Promise { + const { waitUntilExit } = render(); + await waitUntilExit(); +} diff --git a/src/cli/monitor/panels/LogPanel.tsx b/src/cli/monitor/panels/LogPanel.tsx new file mode 100644 index 0000000..c4a03ca --- /dev/null +++ b/src/cli/monitor/panels/LogPanel.tsx @@ -0,0 +1,23 @@ +import { Box, Text } from 'ink'; + +const BG = '#0d1117'; + +interface LogPanelProps { + logBuffer: string; +} + +export function LogPanel({ logBuffer }: LogPanelProps) { + const lines = logBuffer.split('\n').slice(-30); + return ( + + Live Logs + {lines.length === 0 ? ( + Waiting for logs... + ) : ( + lines.map((line, i) => ( + {line} + )) + )} + + ); +} diff --git a/src/cli/monitor/panels/Pm2Panel.tsx b/src/cli/monitor/panels/Pm2Panel.tsx new file mode 100644 index 0000000..96787f6 --- /dev/null +++ b/src/cli/monitor/panels/Pm2Panel.tsx @@ -0,0 +1,110 @@ +import { Box, Text } from 'ink'; +import type { ProcessInfo } from '../state.js'; +import { formatUptime, formatBytes, statusDot, sampleArray } from '../charts.js'; + +const BG = '#0d1117'; +const BG_ROW = '#111822'; + +interface Pm2PanelProps { + processes: ProcessInfo[]; + cpuHistory: number[]; + memHistory: number[]; +} + +function Gauge({ percent, width }: { percent: number; width: number }) { + const clamped = Math.max(0, Math.min(1, percent)); + const filled = Math.round(clamped * width); + const empty = width - filled; + const color = percent < 0.6 ? 'green' : percent < 0.85 ? 'yellow' : 'red'; + return ( + + {'█'.repeat(Math.max(0, filled))}{'░'.repeat(Math.max(0, empty))} + + ); +} + +function Sparkline({ values, width }: { values: number[]; width: number }) { + if (values.length === 0 || width <= 0) return null; + if (values.length === 1) { + return {'▁'.repeat(width)}; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const sampled = sampleArray(values, width); + const CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + return ( + + {sampled.map((v, i) => { + const idx = Math.min(Math.round(((v - min) / range) * 7), 7); + const color = idx < 2 ? 'green' : idx < 5 ? 'yellow' : 'red'; + return {CHARS[idx]}; + })} + + ); +} + +export function Pm2Panel({ processes, cpuHistory, memHistory }: Pm2PanelProps) { + if (processes.length === 0) { + return ( + + PM2 Processes + No PM2 processes (frontend app) + + ); + } + + return ( + + PM2 Processes + {processes.map((p) => { + const uptimeSeconds = p.uptime > 0 ? Math.floor((Date.now() - p.uptime) / 1000) : 0; + return ( + + {/* Process name row */} + + + {statusDot(p.status)} {p.name} + + pid:{p.pid ?? '—'} {p.status} + {p.restarts > 0 && restarts:{p.restarts}} + + {/* CPU row */} + + CPU + {' '} + + {' '} + {p.cpu.toFixed(1)}% + {cpuHistory.length > 1 && ( + + + + )} + + {/* Memory row */} + + MEM + {' '} + + {' '} + {formatBytes(p.memory)} + {memHistory.length > 1 && ( + + + + )} + + {/* Uptime row */} + + up + {formatUptime(uptimeSeconds)} + + + ); + })} + + ); +} diff --git a/src/cli/monitor/panels/ReleasePanel.tsx b/src/cli/monitor/panels/ReleasePanel.tsx new file mode 100644 index 0000000..3f1bd3c --- /dev/null +++ b/src/cli/monitor/panels/ReleasePanel.tsx @@ -0,0 +1,46 @@ +import { Box, Text } from 'ink'; +import type { ReleaseRecord } from '../state.js'; + +const BG = '#0d1117'; + +interface ReleasePanelProps { + currentRelease: string | null; + releases: ReleaseRecord[]; +} + +export function ReleasePanel({ currentRelease, releases }: ReleasePanelProps) { + return ( + + Releases + + {currentRelease ? ( + + Current: + {currentRelease.split('/').pop() ?? currentRelease} + + ) : ( + Current: none + )} + + {releases.length > 0 ? ( + + {releases.slice(0, 5).map((r, i) => { + const dur = r.duration ? `${r.duration}s` : '—'; + const statusIcon = r.status === 'success' ? '✓' : '✗'; + const statusColor = r.status === 'success' ? 'green' : 'red'; + const commit = r.gitCommit ? ` ${r.gitCommit.slice(0, 7)}` : ''; + return ( + + #{i + 1} {r.timestamp} {statusIcon} {dur}{commit} + + ); + })} + + ) : ( + + No releases recorded + + )} + + ); +} diff --git a/src/cli/monitor/panels/SystemPanel.tsx b/src/cli/monitor/panels/SystemPanel.tsx new file mode 100644 index 0000000..a85734f --- /dev/null +++ b/src/cli/monitor/panels/SystemPanel.tsx @@ -0,0 +1,115 @@ +import { Box, Text } from 'ink'; +import type { SystemInfo } from '../state.js'; +import { formatUptime, formatBytes, sampleArray } from '../charts.js'; + +const BG = '#0d1117'; + +interface SystemPanelProps { + system: SystemInfo; + cpuHistory: number[]; + memHistory: number[]; +} + +function Gauge({ percent, width }: { percent: number; width: number }) { + const clamped = Math.max(0, Math.min(1, percent)); + const filled = Math.round(clamped * width); + const empty = width - filled; + const color = percent < 0.6 ? 'green' : percent < 0.85 ? 'yellow' : 'red'; + return ( + + {'█'.repeat(Math.max(0, filled))}{'░'.repeat(Math.max(0, empty))} + + ); +} + +function Sparkline({ values, width }: { values: number[]; width: number }) { + if (values.length === 0 || width <= 0) return null; + if (values.length === 1) { + return {'▁'.repeat(width)}; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const sampled = sampleArray(values, width); + const CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + return ( + + {sampled.map((v, i) => { + const idx = Math.min(Math.round(((v - min) / range) * 7), 7); + const color = idx < 2 ? 'green' : idx < 5 ? 'yellow' : 'red'; + return {CHARS[idx]}; + })} + + ); +} + +export function SystemPanel({ system, cpuHistory, memHistory }: SystemPanelProps) { + const memPct = system.totalMem > 0 ? system.usedMem / system.totalMem : 0; + const diskPct = system.totalDisk > 0 ? system.usedDisk / system.totalDisk : 0; + const cpuPct = system.cpuLoad; + + return ( + + System + + {/* CPU */} + + + CPU + {' '} + + {' '} + {(cpuPct * 100).toFixed(1)}% + + + {cpuHistory.length > 1 ? ( + + ) : ( + + )} + + + + {/* Memory */} + + + MEM + {' '} + + {' '} + {formatBytes(system.usedMem)} + / {formatBytes(system.totalMem)} + + + {memHistory.length > 1 ? ( + + ) : ( + + )} + + + + {/* Disk */} + + + DSK + {' '} + + {' '} + {formatBytes(system.usedDisk * 1024)} + / {formatBytes(system.totalDisk * 1024)} + + + + {/* Uptime */} + + + up + {formatUptime(system.uptime)} + + + + ); +} diff --git a/src/cli/monitor/poller.ts b/src/cli/monitor/poller.ts new file mode 100644 index 0000000..609381c --- /dev/null +++ b/src/cli/monitor/poller.ts @@ -0,0 +1,69 @@ +import type { RemoteExecutor } from '../../domain/remote/executor.js'; +import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; +import { getDeploymentName } from '../../domain/pm2/apps.js'; +import type { MetricsSnapshot } from './state.js'; +import { parsePm2Jlist, parseSystemStats, parseReleaseRecords } from './state.js'; + +const MISE = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`; + +function buildSystemInfoCommand(): string { + return [ + `echo "mem:$(free -mb | awk '/^Mem:/{print $2, $3}')"`, + `echo "load:$(awk '{print $1}' /proc/loadavg)"`, + `echo "uptime:$(cat /proc/uptime | awk '{print $1}')"`, + `echo "disk:$(df -BG --output=size,used / | tail -1 | awk '{print $1+0, $2+0}')"`, + ].join(' && '); +} + +export async function collectMetrics( + executor: RemoteExecutor, + app: ShipnodeApp, + config: ShipnodeConfig, +): Promise { + const timestamp = new Date().toISOString(); + const appPath = `${config.remotePath}/${app.name}`; + + const mise = app.appType === 'backend' ? MISE : ''; + const namespace = app.pm2?.apps[0]?.name ?? ''; + const deployName = getDeploymentName({ ...config, apps: [app] } as ShipnodeConfig) ?? namespace; + + const pm2Result = await executor.exec( + app.appType === 'backend' && app.pm2 ? `${mise} && pm2 jlist` : 'echo "[]"', + ); + + const sysResult = await executor.exec(buildSystemInfoCommand()); + + const currentResult = await executor.exec(`readlink "${appPath}/current" 2>/dev/null || echo "none"`); + + const releasesResult = await executor.exec(`cat "${appPath}/.shipnode/releases.json" 2>/dev/null || echo '[]'`); + + const processes = parsePm2Jlist(pm2Result.stdout, deployName); + const system = parseSystemStats(sysResult.stdout); + const releases = parseReleaseRecords(releasesResult.stdout); + const currentRelease = currentResult.stdout === 'none' ? null : currentResult.stdout.trim(); + + const error = + pm2Result.exitCode !== 0 && app.appType === 'backend' + ? 'PM2 command failed' + : undefined; + + return { + timestamp, + processes, + system, + currentRelease, + releases, + error, + }; +} + +export async function collectLogs( + executor: RemoteExecutor, + namespace: string, + lines: number = 20, +): Promise { + const result = await executor.exec( + `${MISE} && pm2 logs ${namespace} --lines ${lines} --nostream 2>&1 || echo "(no logs)"`, + ); + return result.stdout; +} diff --git a/src/cli/monitor/state.ts b/src/cli/monitor/state.ts new file mode 100644 index 0000000..6fc4ab0 --- /dev/null +++ b/src/cli/monitor/state.ts @@ -0,0 +1,137 @@ +export interface ProcessInfo { + name: string; + pm2Name: string; + pid: number | null; + status: string; + cpu: number; + memory: number; + uptime: number; + restarts: number; +} + +export interface SystemInfo { + cpuLoad: number; + totalMem: number; + usedMem: number; + totalDisk: number; + usedDisk: number; + uptime: number; +} + +export interface ReleaseRecord { + timestamp: string; + status: string; + duration: number; + gitCommit?: string; +} + +export interface MetricsSnapshot { + timestamp: string; + processes: ProcessInfo[]; + system: SystemInfo; + currentRelease: string | null; + releases: ReleaseRecord[]; + error?: string; +} + +export class MetricsHistory { + cpu: number[] = []; + memory: number[] = []; + responseMs: number[] = []; + + constructor(private maxEntries: number = 60) {} + + push(snapshot: MetricsSnapshot): void { + const avgCpu = snapshot.processes.reduce((s, p) => s + p.cpu, 0) / Math.max(snapshot.processes.length, 1); + const avgMem = snapshot.processes.reduce((s, p) => s + p.memory, 0) / Math.max(snapshot.processes.length, 1); + + this.cpu.push(avgCpu); + this.memory.push(avgMem); + + if (this.cpu.length > this.maxEntries) this.cpu.shift(); + if (this.memory.length > this.maxEntries) this.memory.shift(); + } + + clear(): void { + this.cpu = []; + this.memory = []; + this.responseMs = []; + } +} + +interface Pm2JlistEntry { + name: string; + pid?: number; + pm2_env?: { + status?: string; + pm_uptime?: number; + restart_time?: number; + }; + monit?: { + memory: number; + cpu: number; + }; +} + +export function parsePm2Jlist(stdout: string, namespace: string): ProcessInfo[] { + try { + const entries: Pm2JlistEntry[] = JSON.parse(stdout.trim()); + return entries.map((e) => ({ + name: resolveShortName(e.name, namespace), + pm2Name: e.name, + pid: e.pid ?? null, + status: e.pm2_env?.status ?? 'unknown', + cpu: e.monit?.cpu ?? 0, + memory: e.monit?.memory != null ? Math.round(e.monit.memory / (1024 * 1024)) : 0, + uptime: e.pm2_env?.pm_uptime ?? 0, + restarts: e.pm2_env?.restart_time ?? 0, + })); + } catch { + return []; + } +} + +export function parseSystemStats(stdout: string): SystemInfo { + const lines = stdout.trim().split('\n').filter(Boolean); + const result: SystemInfo = { + cpuLoad: 0, + totalMem: 0, + usedMem: 0, + totalDisk: 0, + usedDisk: 0, + uptime: 0, + }; + + for (const line of lines) { + if (line.startsWith('mem:')) { + const parts = line.split(/[:\s]+/); + result.totalMem = parseInt(parts[1] ?? '0', 10) || 0; + result.usedMem = parseInt(parts[2] ?? '0', 10) || 0; + } else if (line.startsWith('load:')) { + result.cpuLoad = parseFloat(line.split(/[:\s]+/)[1] ?? '0') || 0; + } else if (line.startsWith('uptime:')) { + result.uptime = parseInt(line.split(/[:\s]+/)[1] ?? '0', 10) || 0; + } else if (line.startsWith('disk:')) { + const parts = line.split(/[:\s]+/); + result.totalDisk = parseInt(parts[1] ?? '0', 10) || 0; + result.usedDisk = parseInt(parts[2] ?? '0', 10) || 0; + } + } + + return result; +} + +export function parseReleaseRecords(stdout: string): ReleaseRecord[] { + try { + const records: ReleaseRecord[] = JSON.parse(stdout.trim()); + return records.reverse().slice(0, 10); + } catch { + return []; + } +} + +function resolveShortName(pm2Name: string, namespace: string): string { + if (pm2Name === namespace) return pm2Name; + if (pm2Name.startsWith(`${namespace}-`)) return pm2Name.slice(namespace.length + 1); + return pm2Name; +} diff --git a/tests/unit/monitor.test.ts b/tests/unit/monitor.test.ts new file mode 100644 index 0000000..1bd02da --- /dev/null +++ b/tests/unit/monitor.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect } from 'vitest'; +import { FakeRemoteExecutor } from '../testing/fake-executor.js'; +import { sparkline, gauge, formatUptime, formatBytes, statusDot } from '../../src/cli/monitor/charts.js'; +import { parsePm2Jlist, parseSystemStats, parseReleaseRecords, MetricsHistory } from '../../src/cli/monitor/state.js'; +import { collectMetrics } from '../../src/cli/monitor/poller.js'; +import { assembleConfig } from '../../src/config/assembly.js'; + +// ── Charts ──────────────────────────────────────────────────────── + +describe('sparkline', () => { + it('renders empty string for empty values', () => { + expect(sparkline([], 5)).toBe(''); + }); + + it('renders correct characters for normalized values', () => { + const result = sparkline([0, 50, 100], 3); + expect(result).toHaveLength(3); + expect(result).toBe('▁▅█'); + }); + + it('handles single value', () => { + const result = sparkline([50], 3); + // Single value < 7 → Math.round(50) = 50, clamped to 7 → SPARK_CHARS[7] = █ + expect(result).toBe('███'); + }); + + it('handles constant values (no range)', () => { + const result = sparkline([10, 10, 10], 3); + expect(result).toHaveLength(3); + }); + + it('samples down to fit width', () => { + const values = Array.from({ length: 100 }, (_, i) => i); + const result = sparkline(values, 10); + expect(result).toHaveLength(10); + }); +}); + +describe('gauge', () => { + it('renders a full bar for 100%', () => { + const result = gauge(1, 10); + expect(result).toContain('█'); + expect(result).not.toContain('░'); + }); + + it('renders an empty bar for 0%', () => { + const result = gauge(0, 10); + expect(result).toContain('░'); + }); + + it('renders half bar for 50%', () => { + const result = gauge(0.5, 10); + const filledCount = [...result].filter((c) => c === '█').length; + expect(filledCount).toBe(5); + }); + + it('clamps values above 1', () => { + const result = gauge(2, 10); + expect(result).not.toContain('░'); + }); + + it('clamps values below 0', () => { + const result = gauge(-1, 10); + expect(result).not.toContain('█'); + }); + + it('uses green for < 60%', () => { + const result = gauge(0.5, 5); + expect(result).toBeTruthy(); + expect(result).toContain('█'); + expect(result).toContain('░'); + }); + + it('uses yellow for 60-85%', () => { + const result = gauge(0.7, 5); + expect(result).toBeTruthy(); + expect(result).toContain('█'); + }); + + it('uses red for > 85%', () => { + const result = gauge(0.9, 5); + expect(result).toBeTruthy(); + expect(result).not.toContain('░'); + }); +}); + +describe('formatUptime', () => { + it('formats days, hours, minutes', () => { + expect(formatUptime(90061)).toBe('1d 1h 1m'); + }); + + it('shows only hours and minutes for < 1 day', () => { + expect(formatUptime(3661)).toBe('1h 1m'); + }); + + it('shows only minutes for < 1 hour', () => { + expect(formatUptime(61)).toBe('1m'); + }); + + it('shows 0m for 0 seconds', () => { + expect(formatUptime(0)).toBe('0m'); + }); +}); + +describe('formatBytes', () => { + it('formats MB', () => { + expect(formatBytes(500)).toBe('500 MB'); + }); + + it('formats GB', () => { + expect(formatBytes(2048)).toBe('2.0 GB'); + }); +}); + +describe('statusDot', () => { + it('returns ● for online', () => { + expect(statusDot('online')).toBeTruthy(); + expect(statusDot('online')).toContain('●'); + }); + + it('returns ● for errored', () => { + expect(statusDot('errored')).toBeTruthy(); + expect(statusDot('errored')).toContain('●'); + }); +}); + +// ── State parsing ───────────────────────────────────────────────── + +function makePm2Entry(name: string, overrides: Record = {}) { + return { name, pm2_env: { status: 'online', pm_uptime: Date.now() - 3600000, restart_time: 0 }, monit: { cpu: 5, memory: 128 * 1024 * 1024 }, ...overrides }; +} + +describe('parsePm2Jlist', () => { + it('parses a list of PM2 processes', () => { + const stdout = JSON.stringify([ + makePm2Entry('api'), + makePm2Entry('api-worker'), + ]); + const result = parsePm2Jlist(stdout, 'api'); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('api'); + expect(result[0].cpu).toBe(5); + expect(result[0].memory).toBe(128); // 128 MB after bytes→MB conversion + expect(result[0].status).toBe('online'); + }); + + it('resolves prefixed names', () => { + const stdout = JSON.stringify([makePm2Entry('api-worker')]); + const result = parsePm2Jlist(stdout, 'api'); + expect(result[0].name).toBe('worker'); + expect(result[0].pm2Name).toBe('api-worker'); + }); + + it('returns empty array on invalid JSON', () => { + expect(parsePm2Jlist('not json', 'api')).toEqual([]); + }); + + it('handles missing monit data', () => { + const stdout = JSON.stringify([{ name: 'api', pm2_env: { status: 'online' } }]); + const result = parsePm2Jlist(stdout, 'api'); + expect(result[0].cpu).toBe(0); + expect(result[0].memory).toBe(0); + }); +}); + +describe('parseSystemStats', () => { + it('parses all system fields', () => { + const stdout = [ + 'mem:16000 8000', + 'load:0.5', + 'uptime:86400', + 'disk:100 45', + ].join('\n'); + const result = parseSystemStats(stdout); + expect(result.totalMem).toBe(16000); + expect(result.usedMem).toBe(8000); + expect(result.cpuLoad).toBe(0.5); + expect(result.uptime).toBe(86400); + expect(result.totalDisk).toBe(100); + expect(result.usedDisk).toBe(45); + }); + + it('defaults to zero for missing fields', () => { + const result = parseSystemStats(''); + expect(result.totalMem).toBe(0); + expect(result.cpuLoad).toBe(0); + }); +}); + +describe('parseReleaseRecords', () => { + it('parses and reverses to show newest first', () => { + const records = [ + { timestamp: '2026-01-01', status: 'success', duration: 10 }, + { timestamp: '2026-01-02', status: 'success', duration: 12 }, + ]; + const result = parseReleaseRecords(JSON.stringify(records)); + expect(result).toHaveLength(2); + expect(result[0].timestamp).toBe('2026-01-02'); + }); + + it('returns empty array on invalid JSON', () => { + expect(parseReleaseRecords('not json')).toEqual([]); + }); +}); + +describe('MetricsHistory', () => { + it('pushes CPU and memory averages', () => { + const history = new MetricsHistory(5); + history.push({ + timestamp: 'now', + processes: [ + { name: 'a', pm2Name: 'a', pid: 1, status: 'online', cpu: 10, memory: 100, uptime: 0, restarts: 0 }, + { name: 'b', pm2Name: 'b', pid: 2, status: 'online', cpu: 20, memory: 200, uptime: 0, restarts: 0 }, + ], + system: { cpuLoad: 0, totalMem: 0, usedMem: 0, totalDisk: 0, usedDisk: 0, uptime: 0 }, + currentRelease: null, + releases: [], + }); + expect(history.cpu).toHaveLength(1); + expect(history.cpu[0]).toBe(15); // average of 10, 20 + expect(history.memory[0]).toBe(150); // average of 100, 200 + }); + + it('limits entries to max', () => { + const history = new MetricsHistory(3); + for (let i = 0; i < 5; i++) { + history.push({ + timestamp: `${i}`, + processes: [{ name: 'a', pm2Name: 'a', pid: 1, status: 'online', cpu: i, memory: i * 10, uptime: 0, restarts: 0 }], + system: { cpuLoad: 0, totalMem: 0, usedMem: 0, totalDisk: 0, usedDisk: 0, uptime: 0 }, + currentRelease: null, + releases: [], + }); + } + expect(history.cpu).toHaveLength(3); + expect(history.cpu).toEqual([2, 3, 4]); + }); +}); + +// ── Poller ──────────────────────────────────────────────────────── + +const testConfig = assembleConfig({ + app: 'backend', + ssh: { host: '1.2.3.4', user: 'deploy', port: 22 }, + remotePath: '/var/www/app', + pm2: { apps: [{ name: 'api', port: 3000 }] }, +}); + +describe('collectMetrics', () => { + it('issues correct SSH commands and parses results', async () => { + const executor = new FakeRemoteExecutor(); + executor.when( + (c) => c.includes('pm2 jlist'), + { stdout: JSON.stringify([{ name: 'api', pid: 123, pm2_env: { status: 'online', pm_uptime: Date.now(), restart_time: 0 }, monit: { cpu: 2.5, memory: 128 * 1024 * 1024 } }]), stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('free -mb'), + { stdout: 'mem:16000 8000', stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('/proc/loadavg'), + { stdout: 'load:0.5', stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('/proc/uptime'), + { stdout: 'uptime:86400', stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('df -BG'), + { stdout: 'disk:100 45', stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('readlink'), + { stdout: '/var/www/app/releases/2026-01-01T00-00-00', stderr: '', exitCode: 0 }, + ); + executor.when( + (c) => c.includes('releases.json'), + { stdout: JSON.stringify([{ timestamp: '2026-01-01', status: 'success', duration: 10 }]), stderr: '', exitCode: 0 }, + ); + + const result = await collectMetrics(executor, testConfig.apps[0], testConfig); + + expect(result.processes).toHaveLength(1); + expect(result.processes[0].name).toBe('api'); + expect(result.processes[0].cpu).toBe(2.5); + expect(result.system.totalMem).toBe(16000); + expect(result.currentRelease).toContain('2026-01-01'); + expect(result.releases).toHaveLength(1); + + const history = executor.getHistory(); + expect(history.some((h) => h.command.includes('pm2 jlist'))).toBe(true); + expect(history.some((h) => h.command.includes('readlink'))).toBe(true); + expect(history.some((h) => h.command.includes('releases.json'))).toBe(true); + }); + + it('sets error flag when pm2 jlist fails for backend app', async () => { + const executor = new FakeRemoteExecutor(); + executor.when( + (c) => c.includes('pm2 jlist'), + { stdout: '', stderr: 'connection refused', exitCode: 1 }, + ); + executor.when( + (c) => true, + { stdout: '', stderr: '', exitCode: 0 }, + ); + + const result = await collectMetrics(executor, testConfig.apps[0], testConfig); + expect(result.error).toBe('PM2 command failed'); + expect(result.processes).toEqual([]); + }); + + it('handles frontend app without PM2 gracefully', async () => { + const frontendConfig = assembleConfig({ + app: 'frontend', + ssh: { host: '1.2.3.4', user: 'deploy', port: 22 }, + remotePath: '/var/www/app', + }); + + const executor = new FakeRemoteExecutor(); + executor.when( + (c) => true, + { stdout: '', stderr: '', exitCode: 0 }, + ); + + const result = await collectMetrics(executor, frontendConfig.apps[0], frontendConfig); + expect(result.processes).toEqual([]); + }); +}); + +// ── Ink Component smoke tests ───────────────────────────────────── + +describe('Ink components', () => { + it('Pm2Panel is a function', async () => { + const mod = await import('../../src/cli/monitor/panels/Pm2Panel.js'); + expect(typeof mod.Pm2Panel).toBe('function'); + }); + + it('SystemPanel is a function', async () => { + const mod = await import('../../src/cli/monitor/panels/SystemPanel.js'); + expect(typeof mod.SystemPanel).toBe('function'); + }); + + it('ReleasePanel is a function', async () => { + const mod = await import('../../src/cli/monitor/panels/ReleasePanel.js'); + expect(typeof mod.ReleasePanel).toBe('function'); + }); + + it('LogPanel is a function', async () => { + const mod = await import('../../src/cli/monitor/panels/LogPanel.js'); + expect(typeof mod.LogPanel).toBe('function'); + }); + + it('AppSelector is a function', async () => { + const mod = await import('../../src/cli/monitor/app-selector.js'); + expect(typeof mod.AppSelector).toBe('function'); + }); + + it('App is a function', async () => { + const mod = await import('../../src/cli/monitor/App.js'); + expect(typeof mod.App).toBe('function'); + }); + + it('runMonitor is a function', async () => { + const mod = await import('../../src/cli/monitor/index.js'); + expect(typeof mod.runMonitor).toBe('function'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8b4c05f..d80181b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], + "jsx": "react-jsx", "outDir": "dist", "rootDir": "src", "strict": true,