← back to Port Viewer
initial scaffold (gitify-all 2026-05-06)
363d68c11237b8119f5311e7d8de58fe8db2a727 · 2026-05-06 10:25:45 -0700 · Steve Abrams
Files touched
A .claude/settings.local.jsonA .gitignoreA package-lock.jsonA package.jsonA public/favicon.svgA public/index.htmlA server.jsA test.html
Diff
commit 363d68c11237b8119f5311e7d8de58fe8db2a727
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:25:45 2026 -0700
initial scaffold (gitify-all 2026-05-06)
---
.claude/settings.local.json | 39 +
.gitignore | 12 +
package-lock.json | 842 ++++++++++++
package.json | 13 +
public/favicon.svg | 4 +
public/index.html | 2982 +++++++++++++++++++++++++++++++++++++++++++
server.js | 972 ++++++++++++++
test.html | 14 +
8 files changed, 4878 insertions(+)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..45b679c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,39 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(lsof:*)",
+ "Bash(node server.js)",
+ "Bash(curl:*)",
+ "Bash(ss:*)",
+ "Read(//etc/nginx/sites-enabled/**)",
+ "Read(//etc/nginx/sites-available/**)",
+ "Bash(nginx:*)",
+ "Bash(systemctl reload:*)",
+ "Bash(for i in {1..10})",
+ "Bash(do echo \"Test $i:\")",
+ "Bash(done)",
+ "Bash(systemctl list-units:*)",
+ "Bash(node:*)",
+ "Read(//tmp/**)",
+ "Bash(cat:*)",
+ "Bash(npm install:*)",
+ "Read(//root/**)",
+ "Bash(chmod:*)",
+ "Bash(/root/fix-all-ports.sh:*)",
+ "Bash(systemctl stop:*)",
+ "Bash(systemctl status:*)",
+ "Bash(xargs kill:*)",
+ "Bash(ufw:*)",
+ "Bash(iptables:*)",
+ "Bash(systemctl:*)",
+ "Bash(systemctl restart:*)",
+ "Bash(/root/port-viewer-watchdog.sh:*)",
+ "Bash(npm run build:*)",
+ "Bash(pkill:*)",
+ "Bash(bash -c:*)",
+ "Read(//opt/**)"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a87a309
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,842 @@
+{
+ "name": "port-viewer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "port-viewer",
+ "version": "1.0.0",
+ "dependencies": {
+ "express": "^4.18.2",
+ "nodemailer": "^7.0.6"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
+ "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3d7ac90
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "port-viewer",
+ "version": "1.0.0",
+ "description": "A beautiful web app to view active network ports",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "express": "^4.18.2",
+ "nodemailer": "^7.0.6"
+ }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..1d9f2c8
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">P</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5e3d817
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,2982 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Port Viewer - Active Ports</title>
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+ }
+
+ .container {
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+
+ header {
+ text-align: center;
+ color: white;
+ margin-bottom: 40px;
+ animation: fadeIn 0.6s ease-in;
+ }
+
+ h1 {
+ font-size: 3em;
+ margin-bottom: 10px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
+ }
+
+ .subtitle {
+ font-size: 1.2em;
+ opacity: 0.9;
+ }
+
+ .stats {
+ display: flex;
+ justify-content: center;
+ gap: 30px;
+ margin: 20px 0;
+ flex-wrap: wrap;
+ }
+
+ .stat-box {
+ background: rgba(255,255,255,0.2);
+ backdrop-filter: blur(10px);
+ padding: 15px 30px;
+ border-radius: 15px;
+ color: white;
+ font-weight: bold;
+ }
+
+ .stat-number {
+ font-size: 2em;
+ display: block;
+ }
+
+ .cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 25px;
+ animation: fadeIn 0.8s ease-in;
+ }
+
+ .card {
+ background: white;
+ border-radius: 20px;
+ padding: 25px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+ position: relative;
+ overflow: hidden;
+ animation: slideUp 0.5s ease-out;
+ }
+
+ .card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 5px;
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ }
+
+ .card.active-user-app {
+ border: 3px solid #00b894;
+ box-shadow: 0 10px 30px rgba(0, 184, 148, 0.3);
+ }
+
+ .card.active-user-app::before {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ height: 8px;
+ }
+
+ .card.offline-app {
+ border: 3px solid #d63031;
+ background: #ffe5e5;
+ box-shadow: 0 10px 30px rgba(214, 48, 49, 0.3);
+ }
+
+ .card.offline-app::before {
+ background: linear-gradient(90deg, #d63031 0%, #c0392b 100%);
+ height: 8px;
+ }
+
+ .card.loading {
+ border: 3px solid #0984e3;
+ background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+ box-shadow: 0 10px 30px rgba(9, 132, 227, 0.3);
+ position: relative;
+ }
+
+ .loading-spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 0.8s linear infinite;
+ margin-left: 8px;
+ vertical-align: middle;
+ }
+
+ .loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(9, 132, 227, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 15px;
+ border-radius: 20px;
+ z-index: 10;
+ }
+
+ .loading-overlay .big-spinner {
+ width: 60px;
+ height: 60px;
+ border: 5px solid rgba(9, 132, 227, 0.2);
+ border-radius: 50%;
+ border-top-color: #0984e3;
+ animation: spin 1s linear infinite;
+ }
+
+ .loading-text {
+ color: #0984e3;
+ font-weight: 600;
+ font-size: 1.2em;
+ text-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .card:hover {
+ transform: translateY(-10px);
+ box-shadow: 0 15px 40px rgba(0,0,0,0.3);
+ }
+
+ .port-number {
+ font-size: 3em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ }
+
+ .port-info {
+ margin: 10px 0;
+ }
+
+ .info-label {
+ font-size: 0.9em;
+ color: #666;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 5px;
+ }
+
+ .info-value {
+ font-size: 1.1em;
+ color: #333;
+ font-weight: 600;
+ word-break: break-all;
+ }
+
+ .protocol-badge {
+ display: inline-block;
+ padding: 5px 15px;
+ border-radius: 20px;
+ font-size: 0.9em;
+ font-weight: bold;
+ margin-top: 10px;
+ }
+
+ .protocol-tcp {
+ background: #e3f2fd;
+ color: #1976d2;
+ }
+
+ .protocol-tcp6 {
+ background: #f3e5f5;
+ color: #7b1fa2;
+ }
+
+ .protocol-udp {
+ background: #e8f5e9;
+ color: #388e3c;
+ }
+
+ .loading {
+ text-align: center;
+ color: white;
+ font-size: 1.5em;
+ margin-top: 50px;
+ }
+
+ .spinner {
+ border: 4px solid rgba(255,255,255,0.3);
+ border-top: 4px solid white;
+ border-radius: 50%;
+ width: 50px;
+ height: 50px;
+ animation: spin 1s linear infinite;
+ margin: 20px auto;
+ }
+
+ .refresh-btn {
+ position: fixed;
+ bottom: 30px;
+ right: 30px;
+ background: white;
+ color: #667eea;
+ border: none;
+ padding: 15px 30px;
+ border-radius: 50px;
+ font-size: 1em;
+ font-weight: bold;
+ cursor: pointer;
+ box-shadow: 0 5px 20px rgba(0,0,0,0.3);
+ transition: all 0.3s ease;
+ }
+
+ .refresh-btn:hover {
+ transform: scale(1.1);
+ box-shadow: 0 8px 30px rgba(0,0,0,0.4);
+ }
+
+ @keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+
+ .error {
+ background: rgba(255,255,255,0.9);
+ color: #d32f2f;
+ padding: 20px;
+ border-radius: 15px;
+ text-align: center;
+ margin: 20px;
+ }
+
+ .app-name {
+ font-size: 1.3em;
+ font-weight: bold;
+ color: #764ba2;
+ margin: 15px 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .website-links {
+ margin: 10px 0;
+ }
+
+ .website-link {
+ display: inline-block;
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 8px 16px;
+ border-radius: 20px;
+ text-decoration: none;
+ font-size: 0.9em;
+ margin: 5px 5px 5px 0;
+ transition: all 0.3s ease;
+ position: relative;
+ }
+
+ .website-link:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+ }
+
+ .web-view-container {
+ margin-top: 15px;
+ border-top: 2px solid #e0e0e0;
+ padding-top: 15px;
+ }
+
+ .web-view-toggle {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 20px;
+ cursor: pointer;
+ font-weight: bold;
+ margin-bottom: 10px;
+ width: 100%;
+ transition: all 0.3s ease;
+ font-size: 0.9em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ }
+
+ .web-view-toggle:hover {
+ transform: scale(1.02);
+ box-shadow: 0 5px 15px rgba(0, 184, 148, 0.4);
+ }
+
+ .web-view-toggle.active {
+ background: linear-gradient(90deg, #ff6b6b 0%, #ee5a6f 100%);
+ }
+
+ .web-view-iframe-container {
+ display: block;
+ width: 100%;
+ height: 300px;
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
+ margin-top: 10px;
+ position: relative;
+ background: white;
+ animation: slideDown 0.3s ease-out;
+ }
+
+ .web-view-iframe-container.active {
+ display: block;
+ }
+
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ max-height: 0;
+ }
+ to {
+ opacity: 1;
+ max-height: 400px;
+ }
+ }
+
+ .web-view-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: white;
+ }
+
+ .web-view-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ color: #667eea;
+ }
+
+ .web-view-error {
+ padding: 20px;
+ text-align: center;
+ color: #d63031;
+ font-weight: bold;
+ }
+
+ .fullscreen-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: rgba(102, 126, 234, 0.9);
+ color: white;
+ border: none;
+ padding: 8px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 0.85em;
+ font-weight: bold;
+ z-index: 10;
+ transition: all 0.3s ease;
+ }
+
+ .fullscreen-btn:hover {
+ background: rgba(102, 126, 234, 1);
+ transform: scale(1.05);
+ }
+
+ .preview-container {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 80%;
+ max-width: 1200px;
+ height: 80%;
+ background: white;
+ border-radius: 20px;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
+ z-index: 1000;
+ display: none;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .preview-container.active {
+ display: flex;
+ }
+
+ .preview-header {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .preview-title {
+ font-size: 1.5em;
+ font-weight: bold;
+ }
+
+ .close-preview {
+ background: rgba(255,255,255,0.3);
+ border: none;
+ color: white;
+ font-size: 1.5em;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .close-preview:hover {
+ background: rgba(255,255,255,0.5);
+ transform: rotate(90deg);
+ }
+
+ .preview-iframe {
+ flex: 1;
+ border: none;
+ background: white;
+ }
+
+ .preview-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.7);
+ z-index: 999;
+ display: none;
+ }
+
+ .preview-overlay.active {
+ display: block;
+ }
+
+ .docker-badge {
+ background: #e3f2fd;
+ color: #1976d2;
+ padding: 5px 12px;
+ border-radius: 15px;
+ font-size: 0.8em;
+ display: inline-block;
+ margin-top: 5px;
+ }
+
+ .open-icon {
+ font-size: 0.8em;
+ opacity: 0.8;
+ }
+
+ .directory-info {
+ background: #f5f5f5;
+ padding: 10px;
+ border-radius: 10px;
+ margin: 10px 0;
+ font-size: 0.9em;
+ color: #666;
+ font-family: monospace;
+ word-break: break-all;
+ }
+
+ .restart-btn {
+ background: linear-gradient(90deg, #ff6b6b 0%, #ee5a6f 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 20px;
+ cursor: pointer;
+ font-weight: bold;
+ margin-top: 10px;
+ width: 100%;
+ transition: all 0.3s ease;
+ font-size: 0.9em;
+ }
+
+ .restart-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(238, 90, 111, 0.4);
+ }
+
+ .restart-btn:active {
+ transform: scale(0.95);
+ }
+
+ .restart-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ }
+
+ .system-badge {
+ background: #ffeaa7;
+ color: #d63031;
+ padding: 5px 12px;
+ border-radius: 15px;
+ font-size: 0.8em;
+ display: inline-block;
+ margin-top: 5px;
+ }
+
+ .user-app-indicator {
+ background: #55efc4;
+ color: #00b894;
+ padding: 5px 12px;
+ border-radius: 15px;
+ font-size: 0.8em;
+ display: inline-block;
+ margin-top: 5px;
+ }
+
+ .last-port-info {
+ background: #dfe6e9;
+ padding: 8px 12px;
+ border-radius: 10px;
+ margin: 5px 0;
+ font-size: 0.85em;
+ color: #2d3436;
+ }
+
+ .offline-badge {
+ background: #d63031;
+ color: white;
+ padding: 8px 15px;
+ border-radius: 15px;
+ font-size: 0.9em;
+ display: inline-block;
+ margin: 10px 0;
+ font-weight: bold;
+ }
+
+ .offline-app .port-number {
+ color: #d63031;
+ opacity: 0.7;
+ }
+
+ .offline-app .info-value,
+ .offline-app .info-label {
+ opacity: 0.6;
+ }
+
+ .active-indicator {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #00b894;
+ margin-left: 10px;
+ animation: pulse 2s infinite;
+ }
+
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+
+ .add-dir-btn {
+ position: fixed;
+ bottom: 100px;
+ right: 30px;
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ border: none;
+ padding: 15px 30px;
+ border-radius: 50px;
+ font-size: 1em;
+ font-weight: bold;
+ cursor: pointer;
+ box-shadow: 0 5px 20px rgba(0, 184, 148, 0.4);
+ transition: all 0.3s ease;
+ }
+
+ .add-dir-btn:hover {
+ transform: scale(1.1);
+ box-shadow: 0 8px 30px rgba(0, 184, 148, 0.5);
+ }
+
+ .modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 2000;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .modal.active {
+ display: flex;
+ }
+
+ .modal-content {
+ background: white;
+ border-radius: 20px;
+ padding: 40px;
+ max-width: 500px;
+ width: 90%;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ animation: slideUp 0.3s ease-out;
+ }
+
+ .modal-header {
+ font-size: 2em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 20px;
+ }
+
+ .form-group {
+ margin-bottom: 20px;
+ }
+
+ .form-label {
+ display: block;
+ font-weight: bold;
+ color: #333;
+ margin-bottom: 8px;
+ font-size: 0.95em;
+ }
+
+ .form-input {
+ width: 100%;
+ padding: 12px;
+ border: 2px solid #ddd;
+ border-radius: 10px;
+ font-size: 1em;
+ transition: border-color 0.3s ease;
+ }
+
+ .form-input:focus {
+ outline: none;
+ border-color: #667eea;
+ }
+
+ .form-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 30px;
+ }
+
+ .btn-submit {
+ flex: 1;
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 15px;
+ border-radius: 10px;
+ font-size: 1em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .btn-submit:hover {
+ transform: scale(1.05);
+ }
+
+ .btn-cancel {
+ flex: 1;
+ background: #ddd;
+ color: #333;
+ border: none;
+ padding: 15px;
+ border-radius: 10px;
+ font-size: 1em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .btn-cancel:hover {
+ background: #ccc;
+ }
+
+ .ask-server-section {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 20px;
+ padding: 30px;
+ margin-bottom: 40px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ }
+
+ .ask-header {
+ font-size: 1.8em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ }
+
+ .ask-subtitle {
+ color: #666;
+ margin-bottom: 20px;
+ font-size: 0.95em;
+ }
+
+ .ask-input-container {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ }
+
+ .ask-input {
+ flex: 1;
+ padding: 15px 20px;
+ border: 2px solid #ddd;
+ border-radius: 50px;
+ font-size: 1em;
+ transition: border-color 0.3s ease;
+ }
+
+ .ask-input:focus {
+ outline: none;
+ border-color: #667eea;
+ }
+
+ .ask-btn {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 15px 30px;
+ border-radius: 50px;
+ font-size: 1em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ white-space: nowrap;
+ }
+
+ .ask-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+ }
+
+ .ask-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ .ask-response {
+ background: #f8f9fa;
+ border-radius: 15px;
+ padding: 20px;
+ margin-top: 20px;
+ display: none;
+ animation: slideUp 0.3s ease-out;
+ }
+
+ .ask-response.show {
+ display: block;
+ }
+
+ .response-header {
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ font-size: 1.1em;
+ }
+
+ .response-content {
+ background: white;
+ padding: 15px;
+ border-radius: 10px;
+ font-family: 'Courier New', monospace;
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ max-height: 400px;
+ overflow-y: auto;
+ color: #333;
+ }
+
+ .response-error {
+ background: #ffe5e5;
+ color: #d63031;
+ padding: 15px;
+ border-radius: 10px;
+ font-weight: bold;
+ }
+
+ .quick-queries {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 15px;
+ }
+
+ .quick-query-btn {
+ background: rgba(102, 126, 234, 0.1);
+ color: #667eea;
+ border: 2px solid #667eea;
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 0.85em;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .quick-query-btn:hover {
+ background: #667eea;
+ color: white;
+ }
+
+ .directories-section {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 20px;
+ padding: 30px;
+ margin-bottom: 40px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ }
+
+ .directories-header {
+ font-size: 1.8em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .dir-header-actions {
+ display: flex;
+ gap: 10px;
+ }
+
+ .dir-add-btn {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 20px;
+ font-size: 0.9em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .dir-add-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(0, 184, 148, 0.4);
+ }
+
+ .dir-scan-btn {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 20px;
+ font-size: 0.9em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .dir-scan-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+ }
+
+ .directories-list {
+ margin-top: 20px;
+ }
+
+ .dir-item {
+ background: white;
+ border-radius: 15px;
+ padding: 20px;
+ margin-bottom: 15px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all 0.3s ease;
+ border: 2px solid #e0e0e0;
+ }
+
+ .dir-item:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
+ }
+
+ .dir-item.active {
+ border-color: #00b894;
+ background: linear-gradient(to right, rgba(0, 184, 148, 0.05), white);
+ }
+
+ .dir-item.offline {
+ border-color: #d63031;
+ background: linear-gradient(to right, rgba(214, 48, 49, 0.05), white);
+ }
+
+ .dir-info {
+ flex: 1;
+ }
+
+ .dir-path {
+ font-family: 'Courier New', monospace;
+ font-weight: bold;
+ color: #333;
+ font-size: 1.1em;
+ margin-bottom: 5px;
+ }
+
+ .dir-meta {
+ display: flex;
+ gap: 15px;
+ font-size: 0.85em;
+ color: #666;
+ margin-bottom: 10px;
+ }
+
+ .dir-iframe-container {
+ width: 100%;
+ height: 250px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: white;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
+ margin-top: 10px;
+ }
+
+ .dir-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+
+ .dir-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-weight: bold;
+ }
+
+ .dir-status.active {
+ color: #00b894;
+ }
+
+ .dir-status.offline {
+ color: #d63031;
+ }
+
+ .dir-actions {
+ display: flex;
+ gap: 10px;
+ }
+
+ .dir-action-btn {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 10px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-size: 0.85em;
+ }
+
+ .dir-action-btn.start {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ }
+
+ .dir-action-btn.restart {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ }
+
+ .dir-action-btn.delete {
+ background: #ff6b6b;
+ color: white;
+ }
+
+ .dir-action-btn.stop {
+ background: #e74c3c;
+ color: white;
+ }
+
+ .dir-action-btn.assign {
+ background: #f39c12;
+ color: white;
+ }
+
+ .dir-action-btn.open {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ }
+
+ .dir-action-btn:hover {
+ transform: scale(1.05);
+ }
+
+ .dir-action-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ .empty-directories {
+ text-align: center;
+ padding: 40px;
+ color: #999;
+ }
+
+ .status-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ animation: pulse 2s infinite;
+ }
+
+ .status-dot.active {
+ background: #00b894;
+ }
+
+ .status-dot.offline {
+ background: #d63031;
+ animation: none;
+ }
+
+ .email-settings-section {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 20px;
+ padding: 30px;
+ margin-bottom: 40px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ }
+
+ .email-header {
+ font-size: 1.8em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ }
+
+ .email-content {
+ display: flex;
+ gap: 20px;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+
+ .email-toggle {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .toggle-switch {
+ position: relative;
+ width: 60px;
+ height: 30px;
+ background: #ccc;
+ border-radius: 30px;
+ cursor: pointer;
+ transition: background 0.3s;
+ }
+
+ .toggle-switch.active {
+ background: #00b894;
+ }
+
+ .toggle-switch::after {
+ content: '';
+ position: absolute;
+ width: 24px;
+ height: 24px;
+ background: white;
+ border-radius: 50%;
+ top: 3px;
+ left: 3px;
+ transition: left 0.3s;
+ }
+
+ .toggle-switch.active::after {
+ left: 33px;
+ }
+
+ .email-input-group {
+ flex: 1;
+ min-width: 300px;
+ }
+
+ .email-input-group label {
+ display: block;
+ font-weight: bold;
+ color: #333;
+ margin-bottom: 5px;
+ font-size: 0.9em;
+ }
+
+ .email-input-group input {
+ width: 100%;
+ padding: 10px 15px;
+ border: 2px solid #ddd;
+ border-radius: 10px;
+ font-size: 1em;
+ transition: border-color 0.3s;
+ }
+
+ .email-input-group input:focus {
+ outline: none;
+ border-color: #667eea;
+ }
+
+ .save-email-btn {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 10px 25px;
+ border-radius: 10px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .save-email-btn:hover {
+ transform: scale(1.05);
+ }
+
+ .email-status {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 20px;
+ border-radius: 10px;
+ background: #e8f5e9;
+ color: #2e7d32;
+ font-weight: bold;
+ font-size: 0.9em;
+ }
+
+ .email-status.disabled {
+ background: #ffebee;
+ color: #c62828;
+ }
+
+ .login-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ z-index: 10000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .login-overlay.hidden {
+ display: none;
+ }
+
+ .login-box {
+ background: white;
+ border-radius: 20px;
+ padding: 50px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ text-align: center;
+ max-width: 400px;
+ width: 90%;
+ animation: slideUp 0.5s ease-out;
+ }
+
+ .login-logo {
+ font-size: 4em;
+ margin-bottom: 20px;
+ }
+
+ .login-title {
+ font-size: 2em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 10px;
+ }
+
+ .login-subtitle {
+ color: #666;
+ margin-bottom: 30px;
+ }
+
+ .login-input {
+ width: 100%;
+ padding: 15px;
+ border: 2px solid #ddd;
+ border-radius: 10px;
+ font-size: 1em;
+ margin-bottom: 20px;
+ transition: border-color 0.3s;
+ }
+
+ .login-input:focus {
+ outline: none;
+ border-color: #667eea;
+ }
+
+ .login-btn {
+ width: 100%;
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 15px;
+ border-radius: 10px;
+ font-size: 1.1em;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .login-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+ }
+
+ .login-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ .login-error {
+ background: #ffebee;
+ color: #c62828;
+ padding: 10px;
+ border-radius: 10px;
+ margin-bottom: 20px;
+ display: none;
+ }
+
+ .login-error.show {
+ display: block;
+ }
+
+ .logout-btn {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 2px solid white;
+ padding: 10px 20px;
+ border-radius: 20px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ z-index: 1000;
+ }
+
+ .logout-btn:hover {
+ background: white;
+ color: #667eea;
+ }
+
+ .live-domains-section {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 20px;
+ padding: 30px;
+ margin-bottom: 40px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+ color: white;
+ }
+
+ .domains-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+ gap: 20px;
+ margin-top: 20px;
+ }
+
+ .domain-card {
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(10px);
+ border-radius: 12px;
+ padding: 20px;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ transition: all 0.3s ease;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .domain-card:hover {
+ transform: translateY(-5px);
+ background: rgba(255, 255, 255, 0.25);
+ border-color: rgba(255, 255, 255, 0.4);
+ }
+
+ .domain-card.live {
+ border-color: #00b894;
+ background: rgba(0, 184, 148, 0.2);
+ }
+
+ .domain-status {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #00b894;
+ box-shadow: 0 0 10px #00b894;
+ animation: pulse 2s infinite;
+ }
+
+ .domain-name {
+ flex: 1;
+ font-weight: 600;
+ font-size: 1.1em;
+ }
+
+ .domain-link {
+ color: white;
+ text-decoration: none;
+ opacity: 0.8;
+ transition: opacity 0.3s;
+ }
+
+ .domain-link:hover {
+ opacity: 1;
+ }
+
+ .domain-iframe-container {
+ width: 100%;
+ height: 300px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: white;
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
+ }
+
+ .domain-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+
+ .all-dirs-section {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 20px;
+ padding: 30px;
+ margin-bottom: 40px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ }
+
+ .all-dirs-header {
+ font-size: 1.8em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .all-dirs-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 15px;
+ margin-top: 20px;
+ }
+
+ .all-dir-card {
+ background: white;
+ border: 2px solid #e0e0e0;
+ border-radius: 12px;
+ padding: 15px;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ }
+
+ .all-dir-card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
+ border-color: #667eea;
+ }
+
+ .all-dir-card.tracked {
+ background: linear-gradient(to right, rgba(0, 184, 148, 0.05), white);
+ border-color: #00b894;
+ }
+
+ .all-dir-name {
+ font-family: 'Courier New', monospace;
+ font-weight: bold;
+ color: #333;
+ font-size: 0.95em;
+ margin-bottom: 10px;
+ word-break: break-all;
+ }
+
+ .all-dir-path {
+ font-family: 'Courier New', monospace;
+ font-size: 0.75em;
+ color: #999;
+ margin-bottom: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .all-dir-status {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.75em;
+ font-weight: bold;
+ margin-bottom: 10px;
+ }
+
+ .all-dir-status.tracked {
+ background: #e8f5e9;
+ color: #2e7d32;
+ }
+
+ .all-dir-status.untracked {
+ background: #fff3cd;
+ color: #856404;
+ }
+
+ .all-dir-actions {
+ display: flex;
+ gap: 5px;
+ }
+
+ .all-dir-btn {
+ flex: 1;
+ padding: 8px 12px;
+ border: none;
+ border-radius: 8px;
+ font-weight: bold;
+ cursor: pointer;
+ font-size: 0.8em;
+ transition: all 0.3s ease;
+ }
+
+ .all-dir-btn:hover {
+ transform: scale(1.05);
+ }
+
+ .all-dir-btn.add {
+ background: linear-gradient(90deg, #00b894 0%, #00a085 100%);
+ color: white;
+ }
+
+ .all-dir-btn.tracked {
+ background: #e0e0e0;
+ color: #666;
+ cursor: default;
+ }
+
+ .loading-dirs {
+ text-align: center;
+ padding: 40px;
+ color: #999;
+ }
+ </style>
+</head>
+<body>
+ <!-- Login Overlay -->
+ <div class="login-overlay" id="loginOverlay">
+ <div class="login-box">
+ <div class="login-logo">🔒</div>
+ <div class="login-title">Port Viewer</div>
+ <div class="login-subtitle">Enter password to access</div>
+ <div class="login-error" id="loginError">Invalid password</div>
+ <form onsubmit="handleLogin(event)">
+ <input
+ type="password"
+ class="login-input"
+ id="loginPassword"
+ placeholder="Password"
+ autocomplete="current-password"
+ required
+ />
+ <button type="submit" class="login-btn" id="loginBtn">
+ 🔓 Login
+ </button>
+ </form>
+ </div>
+ </div>
+
+ <button class="logout-btn" id="logoutBtn" onclick="handleLogout()" style="display: none;">
+ 🚪 Logout
+ </button>
+
+ <div class="container">
+ <header>
+ <h1>🚀 Port Viewer</h1>
+ <p class="subtitle">Active Network Ports & Services</p>
+ <div class="stats" id="stats"></div>
+ </header>
+
+ <!-- Live Domains Section -->
+ <div class="live-domains-section">
+ <div class="all-dirs-header">
+ <span>🌐 Live Domains</span>
+ <button class="dir-scan-btn" onclick="loadDomains()">🔄 Refresh</button>
+ </div>
+ <div id="domainsContainer">
+ <div class="loading-dirs">
+ <div class="spinner"></div>
+ <p>Loading domains...</p>
+ </div>
+ </div>
+ </div>
+
+ <!-- All Server Directories Section -->
+ <div class="all-dirs-section">
+ <div class="all-dirs-header">
+ <span>📂 All Server Directories</span>
+ <button class="dir-scan-btn" onclick="loadAllServerDirectories()">🔄 Refresh</button>
+ </div>
+ <div id="allDirsContainer">
+ <div class="loading-dirs">
+ <div class="spinner"></div>
+ <p>Scanning server directories...</p>
+ </div>
+ </div>
+ </div>
+
+ <!-- Email Alert Settings -->
+ <div class="email-settings-section">
+ <div class="email-header">📧 Email Alerts</div>
+ <div class="email-content">
+ <div class="email-toggle">
+ <span style="font-weight: bold;">Alerts:</span>
+ <div class="toggle-switch" id="emailToggle" onclick="toggleEmailAlerts()"></div>
+ </div>
+ <div class="email-input-group">
+ <label>Email Address</label>
+ <input type="email" id="emailRecipient" placeholder="your@email.com" />
+ </div>
+ <button class="save-email-btn" onclick="saveEmailConfig()">💾 Save Settings</button>
+ <div class="email-status" id="emailStatus">
+ <span>⏱️ Monitoring every 60 seconds</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Server Directories Section -->
+ <div class="directories-section">
+ <div class="directories-header">
+ <span>📁 Server Directories</span>
+ <div class="dir-header-actions">
+ <button class="dir-scan-btn" onclick="scanAllDirectories()">🔍 Scan All</button>
+ <button class="dir-add-btn" onclick="openAddDirectoryModal()">➕ Add Directory</button>
+ </div>
+ </div>
+ <div class="directories-list" id="directoriesList">
+ <div class="empty-directories">
+ Loading directories...
+ </div>
+ </div>
+ </div>
+
+ <!-- Ask the Server Section -->
+ <div class="ask-server-section">
+ <div class="ask-header">💬 Ask the Server</div>
+ <div class="ask-subtitle">
+ 👋 Ask me anything about this server! Try: "What's the uptime?", "Show memory usage", "How many ports are open?", or type "help" for more commands.
+ </div>
+ <div class="ask-input-container">
+ <input
+ type="text"
+ class="ask-input"
+ id="askInput"
+ placeholder="Ask about uptime, memory, CPU, disk, ports, processes..."
+ onkeypress="if(event.key === 'Enter') askServer()"
+ />
+ <button class="ask-btn" id="askBtn" onclick="askServer()">Send 🚀</button>
+ </div>
+ <div class="quick-queries">
+ <button class="quick-query-btn" onclick="quickQuery('uptime')">⏱️ Uptime</button>
+ <button class="quick-query-btn" onclick="quickQuery('memory')">💾 Memory</button>
+ <button class="quick-query-btn" onclick="quickQuery('cpu')">🖥️ CPU</button>
+ <button class="quick-query-btn" onclick="quickQuery('disk')">💿 Disk</button>
+ <button class="quick-query-btn" onclick="quickQuery('ports')">🔌 Ports</button>
+ <button class="quick-query-btn" onclick="quickQuery('processes')">⚙️ Processes</button>
+ <button class="quick-query-btn" onclick="quickQuery('help')">❓ Help</button>
+ </div>
+ <div class="ask-response" id="askResponse"></div>
+ </div>
+
+ <div id="loading" class="loading">
+ <div class="spinner"></div>
+ <p>Loading ports...</p>
+ </div>
+
+ <div id="error" style="display: none;"></div>
+ <div class="cards-grid" id="cards"></div>
+ </div>
+
+ <button class="refresh-btn" onclick="loadPorts()">🔄 Refresh</button>
+
+ <!-- Add Directory Modal -->
+ <div class="modal" id="addDirModal">
+ <div class="modal-content">
+ <div class="modal-header">📁 Add Directory</div>
+ <form id="addDirForm" onsubmit="handleAddDirectory(event)">
+ <div class="form-group">
+ <label class="form-label">Directory Path *</label>
+ <input type="text" class="form-input" id="dirPath" placeholder="/root/my-app" required>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Port Number (optional)</label>
+ <input type="number" class="form-input" id="dirPort" placeholder="3000">
+ </div>
+ <div class="form-group">
+ <label class="form-label">App Name (optional)</label>
+ <input type="text" class="form-input" id="dirAppName" placeholder="My App">
+ </div>
+ <div class="form-buttons">
+ <button type="button" class="btn-cancel" onclick="closeAddDirectoryModal()">Cancel</button>
+ <button type="submit" class="btn-submit">Add Directory</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <!-- Assign Port Modal -->
+ <div class="modal" id="assignPortModal">
+ <div class="modal-content">
+ <div class="modal-header">🔢 Assign Port</div>
+ <form id="assignPortForm" onsubmit="handleAssignPort(event)">
+ <div class="form-group">
+ <label class="form-label">Directory</label>
+ <input type="text" class="form-input" id="assignDirPath" readonly>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Port Number *</label>
+ <input type="number" class="form-input" id="assignPort" placeholder="3000" required>
+ </div>
+ <div class="form-group">
+ <label class="form-label">App Name (optional)</label>
+ <input type="text" class="form-input" id="assignAppName" placeholder="My App">
+ </div>
+ <div class="form-buttons">
+ <button type="button" class="btn-cancel" onclick="closeAssignPortModal()">Cancel</button>
+ <button type="submit" class="btn-submit">Assign Port</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <!-- Scan Directories Modal -->
+ <div class="modal" id="scanDirModal">
+ <div class="modal-content" style="max-width: 700px;">
+ <div class="modal-header">🔍 All Server Directories</div>
+ <div id="scanDirList" style="max-height: 400px; overflow-y: auto; margin: 20px 0;">
+ <div style="text-align: center; padding: 20px;">
+ <div class="spinner"></div>
+ <p>Scanning directories...</p>
+ </div>
+ </div>
+ <div class="form-buttons">
+ <button type="button" class="btn-cancel" onclick="closeScanDirModal()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="preview-overlay" id="previewOverlay" onclick="closePreview()"></div>
+ <div class="preview-container" id="previewContainer">
+ <div class="preview-header">
+ <div class="preview-title" id="previewTitle"></div>
+ <button class="close-preview" onclick="closePreview()">×</button>
+ </div>
+ <iframe class="preview-iframe" id="previewIframe"></iframe>
+ </div>
+
+ <script>
+ // Authentication Management
+ let authToken = localStorage.getItem('authToken');
+ let serverInfo = { serverIp: 'localhost', port: 6000 }; // Default values
+
+ // Fetch server info on page load
+ fetch('/api/server-info')
+ .then(response => response.json())
+ .then(info => {
+ serverInfo = info;
+ })
+ .catch(err => console.error('Failed to fetch server info:', err));
+
+ // Check if authenticated on page load
+ if (!authToken) {
+ document.getElementById('loginOverlay').classList.remove('hidden');
+ } else {
+ document.getElementById('loginOverlay').classList.add('hidden');
+ document.getElementById('logoutBtn').style.display = 'block';
+ initializeApp();
+ }
+
+ async function handleLogin(event) {
+ event.preventDefault();
+
+ const password = document.getElementById('loginPassword').value;
+ const btn = document.getElementById('loginBtn');
+ const errorDiv = document.getElementById('loginError');
+
+ btn.disabled = true;
+ btn.textContent = '🔄 Logging in...';
+ errorDiv.classList.remove('show');
+
+ try {
+ const response = await fetch('/api/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ password })
+ });
+
+ const result = await response.json();
+
+ if (result.success && result.token) {
+ authToken = result.token;
+ localStorage.setItem('authToken', authToken);
+
+ document.getElementById('loginOverlay').classList.add('hidden');
+ document.getElementById('logoutBtn').style.display = 'block';
+ document.getElementById('loginPassword').value = '';
+
+ initializeApp();
+ } else {
+ errorDiv.classList.add('show');
+ btn.disabled = false;
+ btn.textContent = '🔓 Login';
+ }
+ } catch (err) {
+ errorDiv.textContent = 'Connection error. Please try again.';
+ errorDiv.classList.add('show');
+ btn.disabled = false;
+ btn.textContent = '🔓 Login';
+ }
+ }
+
+ async function handleLogout() {
+ if (!confirm('Are you sure you want to logout?')) return;
+
+ try {
+ await fetch('/api/logout', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`
+ }
+ });
+ } catch (err) {
+ console.error('Logout error:', err);
+ }
+
+ authToken = null;
+ localStorage.removeItem('authToken');
+ document.getElementById('loginOverlay').classList.remove('hidden');
+ document.getElementById('logoutBtn').style.display = 'none';
+ }
+
+ // Wrapper for authenticated fetch
+ async function authFetch(url, options = {}) {
+ if (!options.headers) options.headers = {};
+ options.headers['Authorization'] = `Bearer ${authToken}`;
+
+ const response = await fetch(url, options);
+
+ if (response.status === 401) {
+ // Session expired
+ authToken = null;
+ localStorage.removeItem('authToken');
+ document.getElementById('loginOverlay').classList.remove('hidden');
+ document.getElementById('logoutBtn').style.display = 'none';
+ throw new Error('Session expired. Please login again.');
+ }
+
+ return response;
+ }
+
+ function initializeApp() {
+ loadPorts();
+ loadDirectories();
+ loadEmailConfig();
+ loadDomains();
+ loadAllServerDirectories();
+
+ // Auto-refresh every 2 minutes
+ setInterval(() => {
+ loadPorts();
+ loadDirectories();
+ loadDomains();
+ }, 120000);
+ }
+
+ // Load live domains
+ async function loadDomains() {
+ const container = document.getElementById('domainsContainer');
+ container.innerHTML = `
+ <div class="loading-dirs">
+ <div class="spinner"></div>
+ <p>Loading domains...</p>
+ </div>
+ `;
+
+ try {
+ const response = await authFetch('/api/domains');
+ const data = await response.json();
+
+ if (data.domains && data.domains.length > 0) {
+ const grid = document.createElement('div');
+ grid.className = 'domains-container';
+
+ data.domains.forEach(domain => {
+ const card = document.createElement('div');
+ card.className = 'domain-card live';
+
+ const proxyUrl = `/api/proxy/${encodeURIComponent('https://' + domain)}`;
+
+ card.innerHTML = `
+ <div style="display: flex; align-items: center; gap: 12px;">
+ <div class="domain-status"></div>
+ <div class="domain-name">${domain}</div>
+ <a href="https://${domain}" target="_blank" class="domain-link">🔗</a>
+ </div>
+ <div class="domain-iframe-container">
+ <iframe class="domain-iframe" src="${proxyUrl}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups"></iframe>
+ </div>
+ `;
+
+ grid.appendChild(card);
+ });
+
+ container.innerHTML = '';
+ container.appendChild(grid);
+ } else {
+ container.innerHTML = `
+ <div class="loading-dirs">
+ 🌐 No domains configured
+ </div>
+ `;
+ }
+ } catch (err) {
+ container.innerHTML = `
+ <div class="loading-dirs" style="color: #fff3cd;">
+ ❌ Error: ${err.message}
+ </div>
+ `;
+ }
+ }
+
+ // Load all server directories
+ async function loadAllServerDirectories() {
+ const container = document.getElementById('allDirsContainer');
+ container.innerHTML = `
+ <div class="loading-dirs">
+ <div class="spinner"></div>
+ <p>Scanning server directories...</p>
+ </div>
+ `;
+
+ try {
+ // Fetch all directories
+ const scanResponse = await authFetch('/api/scan-directories');
+ const scanResult = await scanResponse.json();
+
+ // Fetch tracked ports to know which are already tracked
+ const portsResponse = await authFetch('/api/ports');
+ const ports = await portsResponse.json();
+
+ // Fetch directory data to get lastSeen timestamps
+ const dirDataResponse = await authFetch('/api/directories');
+ const directoryData = await dirDataResponse.json();
+
+ const trackedDirs = new Set();
+ ports.forEach(port => {
+ if (port.directory && port.isUserApp) {
+ trackedDirs.add(port.directory);
+ }
+ });
+
+ if (scanResult.directories && scanResult.directories.length > 0) {
+ // Sort directories by most recently accessed
+ const sortedDirs = scanResult.directories.sort((a, b) => {
+ const aData = directoryData[a];
+ const bData = directoryData[b];
+
+ // If both have lastSeen, sort by timestamp (most recent first)
+ if (aData?.lastSeen && bData?.lastSeen) {
+ return new Date(bData.lastSeen) - new Date(aData.lastSeen);
+ }
+ // Tracked directories come before untracked
+ if (aData?.lastSeen) return -1;
+ if (bData?.lastSeen) return 1;
+ // Both untracked - maintain alphabetical order
+ return a.localeCompare(b);
+ });
+
+ const grid = document.createElement('div');
+ grid.className = 'all-dirs-grid';
+
+ sortedDirs.forEach(dir => {
+ const isTracked = trackedDirs.has(dir);
+ const dirName = dir.split('/').pop();
+ const dirData = directoryData[dir];
+ const lastPort = dirData?.lastPort || 'N/A';
+
+ const card = document.createElement('div');
+ card.className = `all-dir-card ${isTracked ? 'tracked' : ''}`;
+
+ card.innerHTML = `
+ <div class="all-dir-name">📁 ${dirName}</div>
+ <div class="all-dir-path" title="${dir}">${dir}</div>
+ <div class="all-dir-status ${isTracked ? 'tracked' : 'untracked'}">
+ ${isTracked ? '✅ Tracked' : '⚠️ Not Tracked'}
+ </div>
+ ${dirData?.lastPort ? `<div style="font-size: 0.75em; color: #666; margin: 5px 0;">Last Port: ${lastPort}</div>` : ''}
+ <div class="all-dir-actions">
+ ${isTracked
+ ? '<button class="all-dir-btn tracked" disabled>Already Tracked</button>'
+ : `<button class="all-dir-btn add" onclick="quickAddDirectory(event, '${dir}', '${dirName}')">➕ Add & Start</button>`
+ }
+ </div>
+ `;
+
+ grid.appendChild(card);
+ });
+
+ container.innerHTML = '';
+ container.appendChild(grid);
+ } else {
+ container.innerHTML = `
+ <div class="loading-dirs">
+ 📂 No Node.js projects found on the server
+ </div>
+ `;
+ }
+ } catch (err) {
+ container.innerHTML = `
+ <div class="loading-dirs" style="color: #d63031;">
+ ❌ Error: ${err.message}
+ </div>
+ `;
+ }
+ }
+
+ async function quickAddDirectory(event, directory, appName) {
+ // Fetch directory data to check if there's a last port
+ const dirDataResponse = await authFetch('/api/directories');
+ const directoryData = await dirDataResponse.json();
+ const lastPort = directoryData[directory]?.lastPort;
+
+ // Suggest an available port if no last port
+ let suggestedPort = lastPort || '3000';
+ if (!lastPort) {
+ try {
+ const portResponse = await authFetch('/api/suggest-port');
+ const portData = await portResponse.json();
+ suggestedPort = portData.port;
+ } catch (err) {
+ console.error('Failed to get suggested port:', err);
+ }
+ }
+
+ const button = event.target;
+ const card = button.closest('.all-dir-card');
+ const originalButtonHTML = button.innerHTML;
+
+ const port = prompt(`Enter port number for ${appName} (suggested: ${suggestedPort}):`, suggestedPort);
+ if (!port) return;
+
+ // Start loading spinner IMMEDIATELY after OK is clicked
+ button.disabled = true;
+ button.innerHTML = '⏳ Adding<span class="loading-spinner"></span>';
+
+ card.classList.add('loading');
+ const overlay = document.createElement('div');
+ overlay.className = 'loading-overlay';
+ overlay.innerHTML = `
+ <div class="big-spinner"></div>
+ <div class="loading-text">🚀 Adding & starting ${appName}...</div>
+ `;
+ card.appendChild(overlay);
+
+ try {
+ // Add the directory
+ overlay.querySelector('.loading-text').textContent = `📝 Adding ${appName}...`;
+ const addResponse = await authFetch('/api/add-directory', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ directory: directory,
+ port: port,
+ appName: appName
+ })
+ });
+
+ const addResult = await addResponse.json();
+
+ if (addResult.success) {
+ // Now start it
+ overlay.querySelector('.loading-text').textContent = `🚀 Starting ${appName}...`;
+ button.innerHTML = '⏳ Starting<span class="loading-spinner"></span>';
+
+ const startResponse = await authFetch(`/api/restart/${encodeURIComponent(directory)}`);
+ const startResult = await startResponse.json();
+
+ if (startResult.success) {
+ overlay.querySelector('.loading-text').textContent = `✅ ${appName} started successfully!`;
+ button.innerHTML = '✅ Started!';
+ button.style.background = '#00b894';
+
+ // ALWAYS open the app
+ setTimeout(() => {
+ const url = `http://${serverInfo.serverIp}:${port}`;
+ window.open(url, '_blank');
+ }, 1500);
+
+ // Reload everything
+ setTimeout(() => {
+ card.classList.remove('loading');
+ overlay.remove();
+ loadAllServerDirectories();
+ loadDirectories();
+ loadPorts();
+ }, 3000);
+ } else {
+ overlay.querySelector('.loading-text').textContent = `❌ Failed to start`;
+ button.innerHTML = '❌ Failed';
+ button.style.background = '#d63031';
+ setTimeout(() => {
+ card.classList.remove('loading');
+ overlay.remove();
+ button.innerHTML = originalButtonHTML;
+ button.style.background = '';
+ button.disabled = false;
+ }, 3000);
+ alert(`✅ ${appName} added but failed to start: ${startResult.message || startResult.error}`);
+ }
+ } else {
+ card.classList.remove('loading');
+ overlay.remove();
+ button.innerHTML = originalButtonHTML;
+ button.disabled = false;
+ alert(`❌ Error: ${addResult.error}`);
+ }
+ } catch (err) {
+ card.classList.remove('loading');
+ if (overlay && overlay.parentNode) overlay.remove();
+ button.innerHTML = originalButtonHTML;
+ button.disabled = false;
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ async function loadPorts() {
+ const loading = document.getElementById('loading');
+ const cards = document.getElementById('cards');
+ const error = document.getElementById('error');
+ const stats = document.getElementById('stats');
+
+ loading.style.display = 'block';
+ cards.innerHTML = '';
+ error.style.display = 'none';
+
+ try {
+ const response = await authFetch('/api/ports');
+ const ports = await response.json();
+
+ loading.style.display = 'none';
+
+ if (ports.error) {
+ error.innerHTML = `<div class="error">${ports.error}</div>`;
+ error.style.display = 'block';
+ return;
+ }
+
+ // Display stats
+ const tcpCount = ports.filter(p => p.protocol.includes('TCP')).length;
+ const udpCount = ports.filter(p => p.protocol.includes('UDP')).length;
+
+ stats.innerHTML = `
+ <div class="stat-box">
+ <span class="stat-number">${ports.length}</span>
+ <div>Total Ports</div>
+ </div>
+ <div class="stat-box">
+ <span class="stat-number">${tcpCount}</span>
+ <div>TCP</div>
+ </div>
+ <div class="stat-box">
+ <span class="stat-number">${udpCount}</span>
+ <div>UDP</div>
+ </div>
+ `;
+
+ // Display cards
+ ports.forEach((port, index) => {
+ const card = document.createElement('div');
+ card.className = 'card';
+ card.style.animationDelay = `${index * 0.05}s`;
+
+ // Add special classes for user apps
+ if (port.isUserApp && port.directory) {
+ if (port.isOffline) {
+ card.className += ' offline-app';
+ } else {
+ card.className += ' active-user-app';
+ }
+ }
+
+ const protocolClass = `protocol-${port.protocol.toLowerCase()}`;
+
+ let websiteLinksHTML = '';
+ let webViewHTML = '';
+ if (port.websites && port.websites.length > 0) {
+ websiteLinksHTML = '<div class="website-links">';
+ const firstWebsite = port.websites[0];
+ const firstUrl = firstWebsite.includes('localhost') || firstWebsite.includes('127.0.0.1')
+ ? `http://localhost:${port.port}`
+ : `https://${firstWebsite}`;
+
+ port.websites.forEach(website => {
+ const url = website.includes('localhost') || website.includes('127.0.0.1')
+ ? `http://localhost:${port.port}`
+ : `https://${website}`;
+ websiteLinksHTML += `
+ <a href="${url}" class="website-link" target="_blank"
+ onmouseover="showPreview('${url}', '${website}')"
+ onmouseout="hidePreviewDelayed()">
+ ${website} <span class="open-icon">↗</span>
+ </a>
+ `;
+ });
+ websiteLinksHTML += '</div>';
+
+ // Add web view container for the first website - always visible
+ // Use direct URL for better CSS loading, fallback to proxy if needed
+ const directUrl = firstUrl;
+ const proxyUrl = `/api/proxy/${encodeURIComponent(firstUrl)}`;
+ webViewHTML = `
+ <div class="web-view-container">
+ <div class="web-view-iframe-container active" id="webview-${port.port}">
+ <button class="fullscreen-btn" onclick="openFullscreen('${firstUrl}', '${firstWebsite}')">⛶ Fullscreen</button>
+ <iframe class="web-view-iframe" id="iframe-${port.port}" src="${directUrl}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups"></iframe>
+ </div>
+ </div>
+ `;
+ }
+
+ let dockerHTML = '';
+ if (port.docker) {
+ dockerHTML = `<div class="docker-badge">🐳 ${port.docker.image}</div>`;
+ }
+
+ let directoryHTML = '';
+ let restartButtonHTML = '';
+ let appTypeHTML = '';
+ let statusHTML = '';
+
+ if (port.isUserApp && port.directory) {
+ if (port.isOffline) {
+ appTypeHTML = '<div class="offline-badge">❌ OFFLINE</div>';
+ statusHTML = `<div class="last-port-info">Last Port: ${port.port}</div>`;
+ restartButtonHTML = `<button class="restart-btn" onclick="restartApp('${port.directory}', '${port.port}')">🔄 Start App</button>`;
+ } else {
+ appTypeHTML = '<span class="user-app-indicator">👤 User App <span class="active-indicator"></span></span>';
+ restartButtonHTML = `<button class="restart-btn" onclick="restartApp('${port.directory}', '${port.port}')">🔄 Restart App</button>`;
+ }
+ directoryHTML = `<div class="directory-info">📁 ${port.directory}</div>`;
+ } else if (!port.isUserApp && port.port) {
+ const sysPort = parseInt(port.port);
+ if ([22, 53, 80, 443, 111, 123, 514].includes(sysPort)) {
+ appTypeHTML = '<span class="system-badge">⚙️ System Service</span>';
+ }
+ }
+
+ let protocolBadge = port.protocol !== 'N/A' ? `<span class="protocol-badge ${protocolClass}">${port.protocol}</span>` : '';
+
+ card.innerHTML = `
+ <div class="port-number">:${port.port}</div>
+ ${port.appName ? `<div class="app-name">📱 ${port.appName}</div>` : ''}
+ ${appTypeHTML}
+ ${statusHTML}
+ ${websiteLinksHTML}
+ ${webViewHTML}
+ ${dockerHTML}
+ ${directoryHTML}
+ <div class="port-info">
+ <div class="info-label">Address</div>
+ <div class="info-value">${port.address}</div>
+ </div>
+ <div class="port-info">
+ <div class="info-label">Process</div>
+ <div class="info-value">${port.process}</div>
+ </div>
+ ${protocolBadge}
+ ${restartButtonHTML}
+ `;
+
+ cards.appendChild(card);
+ });
+
+ if (ports.length === 0) {
+ cards.innerHTML = '<div class="error">No listening ports found</div>';
+ }
+ } catch (err) {
+ loading.style.display = 'none';
+ error.innerHTML = `<div class="error">Failed to load ports: ${err.message}</div>`;
+ error.style.display = 'block';
+ }
+ }
+
+
+ // Email configuration functions
+ async function loadEmailConfig() {
+ try {
+ const response = await authFetch('/api/email-config');
+ const config = await response.json();
+
+ document.getElementById('emailRecipient').value = config.recipient || '';
+ const toggle = document.getElementById('emailToggle');
+ const status = document.getElementById('emailStatus');
+
+ if (config.enabled) {
+ toggle.classList.add('active');
+ status.classList.remove('disabled');
+ status.innerHTML = '<span>✅ Alerts Enabled - Monitoring every 60 seconds</span>';
+ } else {
+ toggle.classList.remove('active');
+ status.classList.add('disabled');
+ status.innerHTML = '<span>❌ Alerts Disabled</span>';
+ }
+ } catch (err) {
+ console.error('Failed to load email config:', err);
+ }
+ }
+
+ function toggleEmailAlerts() {
+ const toggle = document.getElementById('emailToggle');
+ toggle.classList.toggle('active');
+ }
+
+ async function saveEmailConfig() {
+ const enabled = document.getElementById('emailToggle').classList.contains('active');
+ const recipient = document.getElementById('emailRecipient').value.trim();
+
+ if (!recipient) {
+ alert('Please enter an email address');
+ return;
+ }
+
+ try {
+ const response = await authFetch('/api/email-config', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ enabled, recipient })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ alert('✅ Email settings saved successfully!');
+ loadEmailConfig();
+ } else {
+ alert(`❌ Error: ${result.error}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ // Load and display directories
+ async function loadDirectories() {
+ try {
+ const response = await authFetch('/api/ports');
+ const ports = await response.json();
+
+ // Extract unique directories and their status
+ const directories = {};
+
+ ports.forEach(port => {
+ if (port.directory && port.isUserApp) {
+ directories[port.directory] = {
+ path: port.directory,
+ appName: port.appName || path.basename(port.directory),
+ port: port.port,
+ isActive: !port.isOffline,
+ process: port.process,
+ lastSeen: new Date().toISOString()
+ };
+ }
+ });
+
+ displayDirectories(directories);
+ } catch (err) {
+ console.error('Failed to load directories:', err);
+ }
+ }
+
+ function displayDirectories(directories) {
+ const container = document.getElementById('directoriesList');
+ const dirArray = Object.values(directories);
+
+ if (dirArray.length === 0) {
+ container.innerHTML = `
+ <div class="empty-directories">
+ 📂 No directories tracked yet. Click "Add Directory" to add one!
+ </div>
+ `;
+ return;
+ }
+
+ container.innerHTML = dirArray.map(dir => {
+ const statusClass = dir.isActive ? 'active' : 'offline';
+ const statusText = dir.isActive ? 'ONLINE' : 'OFFLINE';
+
+ let actionButtons = '';
+ if (dir.isActive) {
+ actionButtons = `
+ <button class="dir-action-btn open" onclick="openApp('${dir.port}')">🌐 Open</button>
+ <button class="dir-action-btn restart" onclick="restartDirectory('${dir.path}', '${dir.port}')">🔄 Restart</button>
+ <button class="dir-action-btn stop" onclick="stopPort('${dir.port}')">⏹️ Stop</button>
+ `;
+ } else {
+ actionButtons = `
+ <button class="dir-action-btn start" onclick="restartDirectory('${dir.path}', '${dir.port}')">▶️ Start</button>
+ `;
+ }
+
+ const iframeHTML = dir.isActive ? `
+ <div class="dir-iframe-container">
+ <iframe class="dir-iframe" src="/api/proxy/${encodeURIComponent('http://' + serverInfo.serverIp + ':' + dir.port)}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups"></iframe>
+ </div>
+ ` : '';
+
+ return `
+ <div class="dir-item ${statusClass}">
+ <div class="dir-info">
+ <div class="dir-path">📁 ${dir.path}</div>
+ <div class="dir-meta">
+ <span class="dir-status ${statusClass}">
+ <span class="status-dot ${statusClass}"></span>
+ ${statusText}
+ </span>
+ <span>Port: ${dir.port}</span>
+ <span>App: ${dir.appName}</span>
+ </div>
+ ${iframeHTML}
+ </div>
+ <div class="dir-actions">
+ ${actionButtons}
+ <button class="dir-action-btn assign" onclick="openAssignPortModal('${dir.path}', '${dir.port}', '${dir.appName}')">🔢 Assign</button>
+ <button class="dir-action-btn delete" onclick="deleteDirectory('${dir.path}')">🗑️ Remove</button>
+ </div>
+ </div>
+ `;
+ }).join('');
+ }
+
+ async function deleteDirectory(directory) {
+ if (!confirm(`Are you sure you want to remove ${directory} from tracking?\n\nThis won't delete the actual directory, just stop tracking it.`)) {
+ return;
+ }
+
+ try {
+ const response = await authFetch(`/api/delete-directory/${encodeURIComponent(directory)}`);
+ const result = await response.json();
+
+ if (result.success) {
+ loadDirectories();
+ loadPorts();
+ } else {
+ alert(`❌ Error: ${result.error}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ async function restartDirectory(directory, port) {
+ if (!confirm(`Are you sure you want to restart the app in ${directory}?`)) {
+ return;
+ }
+
+ const button = event.target;
+ const originalText = button.textContent;
+ button.disabled = true;
+ button.innerHTML = 'Starting<span class="loading-spinner"></span>';
+
+ try {
+ const response = await authFetch(`/api/restart/${encodeURIComponent(directory)}`);
+ const result = await response.json();
+
+ if (result.success) {
+ button.innerHTML = '✅ Started!';
+ button.style.background = '#00b894';
+
+ // Always open the URL in a new tab
+ const portToOpen = result.lastPort || port;
+ if (portToOpen) {
+ setTimeout(() => {
+ const url = `http://${serverInfo.serverIp}:${portToOpen}`;
+ window.open(url, '_blank');
+ }, 1500);
+ }
+
+ // Reload directories after a delay
+ setTimeout(() => {
+ loadDirectories();
+ loadPorts();
+ button.textContent = originalText;
+ button.style.background = '';
+ button.disabled = false;
+ }, 3000);
+ } else {
+ button.textContent = '❌ Failed';
+ button.style.background = '#d63031';
+ setTimeout(() => {
+ button.textContent = originalText;
+ button.style.background = '';
+ button.disabled = false;
+ }, 3000);
+ alert(`❌ Failed to restart: ${result.error || result.message}`);
+ }
+ } catch (err) {
+ button.textContent = '❌ Error';
+ button.disabled = false;
+ setTimeout(() => {
+ button.textContent = originalText;
+ }, 3000);
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ function openApp(port) {
+ const url = `http://${serverInfo.serverIp}:${port}`;
+ window.open(url, '_blank');
+ }
+
+ async function stopPort(port) {
+ if (!confirm(`Are you sure you want to stop the process on port ${port}?`)) {
+ return;
+ }
+
+ try {
+ const response = await authFetch(`/api/stop-port/${port}`, {
+ method: 'POST'
+ });
+ const result = await response.json();
+
+ if (result.success) {
+ alert(`✅ Stopped process on port ${port}`);
+ loadDirectories();
+ loadPorts();
+ } else {
+ alert(`❌ Error: ${result.error || 'Failed to stop process'}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ function openAssignPortModal(directory, currentPort, appName) {
+ document.getElementById('assignDirPath').value = directory;
+ document.getElementById('assignPort').value = currentPort || '';
+ document.getElementById('assignAppName').value = appName || '';
+ document.getElementById('assignPortModal').classList.add('active');
+ }
+
+ function closeAssignPortModal() {
+ document.getElementById('assignPortModal').classList.remove('active');
+ document.getElementById('assignPortForm').reset();
+ }
+
+ async function handleAssignPort(event) {
+ event.preventDefault();
+
+ const directory = document.getElementById('assignDirPath').value;
+ const port = document.getElementById('assignPort').value;
+ const appName = document.getElementById('assignAppName').value.trim();
+
+ try {
+ const response = await authFetch('/api/assign-port', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ directory: directory,
+ port: port,
+ appName: appName || undefined
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ closeAssignPortModal();
+ alert(`✅ Port ${port} assigned to ${directory}`);
+ loadDirectories();
+ loadPorts();
+ } else {
+ alert(`❌ Error: ${result.error}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ async function scanAllDirectories() {
+ document.getElementById('scanDirModal').classList.add('active');
+ document.getElementById('scanDirList').innerHTML = `
+ <div style="text-align: center; padding: 20px;">
+ <div class="spinner"></div>
+ <p>Scanning directories...</p>
+ </div>
+ `;
+
+ try {
+ const response = await authFetch('/api/scan-directories');
+ const result = await response.json();
+
+ if (result.directories && result.directories.length > 0) {
+ const listHTML = result.directories.map(dir => `
+ <div class="dir-item" style="margin-bottom: 10px;">
+ <div class="dir-info" style="flex: 1;">
+ <div class="dir-path" style="font-size: 0.95em;">📁 ${dir}</div>
+ </div>
+ <div class="dir-actions">
+ <button class="dir-action-btn" style="padding: 6px 12px; font-size: 0.8em;" onclick="addScannedDirectory('${dir}')">➕ Add</button>
+ </div>
+ </div>
+ `).join('');
+
+ document.getElementById('scanDirList').innerHTML = listHTML;
+ } else {
+ document.getElementById('scanDirList').innerHTML = `
+ <div class="empty-directories">
+ No Node.js projects found
+ </div>
+ `;
+ }
+ } catch (err) {
+ document.getElementById('scanDirList').innerHTML = `
+ <div class="error">Error: ${err.message}</div>
+ `;
+ }
+ }
+
+ function closeScanDirModal() {
+ document.getElementById('scanDirModal').classList.remove('active');
+ }
+
+ async function addScannedDirectory(directory) {
+ // Suggest an available port
+ let suggestedPort = '3000';
+ try {
+ const portResponse = await authFetch('/api/suggest-port');
+ const portData = await portResponse.json();
+ suggestedPort = portData.port;
+ } catch (err) {
+ console.error('Failed to get suggested port:', err);
+ }
+
+ const port = prompt(`Enter port number for ${directory} (suggested: ${suggestedPort}):`, suggestedPort);
+ if (!port) return;
+
+ try {
+ const response = await authFetch('/api/add-directory', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ directory: directory,
+ port: port,
+ appName: directory.split('/').pop()
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ alert(`✅ Directory added: ${directory}`);
+ closeScanDirModal();
+ loadDirectories();
+ loadPorts();
+ } else {
+ alert(`❌ Error: ${result.error}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ // Close modals when clicking outside
+ document.getElementById('assignPortModal').addEventListener('click', (e) => {
+ if (e.target.id === 'assignPortModal') {
+ closeAssignPortModal();
+ }
+ });
+
+ document.getElementById('scanDirModal').addEventListener('click', (e) => {
+ if (e.target.id === 'scanDirModal') {
+ closeScanDirModal();
+ }
+ });
+
+ // Ask the Server functionality
+ async function askServer() {
+ const input = document.getElementById('askInput');
+ const btn = document.getElementById('askBtn');
+ const responseDiv = document.getElementById('askResponse');
+ const query = input.value.trim();
+
+ if (!query) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Thinking...';
+ responseDiv.innerHTML = '';
+ responseDiv.classList.remove('show');
+
+ try {
+ const response = await authFetch('/api/ask', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ query })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ responseDiv.innerHTML = `
+ <div class="response-header">📊 ${result.query || query}</div>
+ <div class="response-content">${result.response}</div>
+ `;
+ } else {
+ responseDiv.innerHTML = `
+ <div class="response-error">❌ ${result.error}</div>
+ `;
+ }
+
+ responseDiv.classList.add('show');
+ } catch (err) {
+ responseDiv.innerHTML = `
+ <div class="response-error">❌ Error: ${err.message}</div>
+ `;
+ responseDiv.classList.add('show');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Send 🚀';
+ }
+ }
+
+ function quickQuery(type) {
+ const input = document.getElementById('askInput');
+ const queries = {
+ 'uptime': 'What is the uptime?',
+ 'memory': 'Show memory usage',
+ 'cpu': 'Show CPU usage',
+ 'disk': 'Show disk usage',
+ 'ports': 'Show open ports',
+ 'processes': 'Show top processes',
+ 'help': 'help'
+ };
+ input.value = queries[type] || type;
+ askServer();
+ }
+
+ // Preview functionality
+ let previewTimeout = null;
+ let hideTimeout = null;
+
+ function showPreview(url, title) {
+ clearTimeout(hideTimeout);
+
+ previewTimeout = setTimeout(() => {
+ const overlay = document.getElementById('previewOverlay');
+ const container = document.getElementById('previewContainer');
+ const iframe = document.getElementById('previewIframe');
+ const titleEl = document.getElementById('previewTitle');
+
+ titleEl.textContent = title;
+ iframe.src = url;
+
+ overlay.classList.add('active');
+ container.classList.add('active');
+ }, 500); // Show preview after 500ms hover
+ }
+
+ function hidePreviewDelayed() {
+ clearTimeout(previewTimeout);
+
+ hideTimeout = setTimeout(() => {
+ closePreview();
+ }, 300);
+ }
+
+ function closePreview() {
+ const overlay = document.getElementById('previewOverlay');
+ const container = document.getElementById('previewContainer');
+ const iframe = document.getElementById('previewIframe');
+
+ overlay.classList.remove('active');
+ container.classList.remove('active');
+
+ // Clear iframe after animation
+ setTimeout(() => {
+ if (!container.classList.contains('active')) {
+ iframe.src = '';
+ }
+ }, 300);
+ }
+
+ // Prevent closing when hovering over preview
+ document.getElementById('previewContainer').addEventListener('mouseenter', () => {
+ clearTimeout(hideTimeout);
+ });
+
+ document.getElementById('previewContainer').addEventListener('mouseleave', () => {
+ closePreview();
+ });
+
+ // Add Directory Modal Functions
+ function openAddDirectoryModal() {
+ document.getElementById('addDirModal').classList.add('active');
+ }
+
+ function closeAddDirectoryModal() {
+ document.getElementById('addDirModal').classList.remove('active');
+ document.getElementById('addDirForm').reset();
+ }
+
+ async function handleAddDirectory(event) {
+ event.preventDefault();
+
+ const directory = document.getElementById('dirPath').value.trim();
+ const port = document.getElementById('dirPort').value.trim();
+ const appName = document.getElementById('dirAppName').value.trim();
+
+ try {
+ const response = await authFetch('/api/add-directory', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ directory: directory,
+ port: port || undefined,
+ appName: appName || undefined
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ closeAddDirectoryModal();
+ alert(`✅ Directory added: ${directory}`);
+ loadPorts();
+ loadDirectories();
+ } else {
+ alert(`❌ Error: ${result.error}`);
+ }
+ } catch (err) {
+ alert(`❌ Error: ${err.message}`);
+ }
+ }
+
+ // Close modal when clicking outside
+ document.getElementById('addDirModal').addEventListener('click', (e) => {
+ if (e.target.id === 'addDirModal') {
+ closeAddDirectoryModal();
+ }
+ });
+
+ // Web View Toggle Functions
+ function toggleWebView(port, url) {
+ const container = document.getElementById(`webview-${port}`);
+ const iframe = document.getElementById(`iframe-${port}`);
+ const button = event.target.closest('.web-view-toggle');
+
+ if (container.classList.contains('active')) {
+ // Hide the web view
+ container.classList.remove('active');
+ button.classList.remove('active');
+ button.innerHTML = '<span>👁️ Show Live Preview</span>';
+ iframe.src = ''; // Clear iframe to stop loading
+ } else {
+ // Show the web view
+ container.classList.add('active');
+ button.classList.add('active');
+ button.innerHTML = '<span>👁️ Hide Live Preview</span>';
+
+ // Load iframe if not already loaded
+ if (!iframe.src) {
+ iframe.src = url;
+
+ // Hide loading spinner when iframe loads
+ iframe.onload = function() {
+ const loading = container.querySelector('.web-view-loading');
+ if (loading) loading.style.display = 'none';
+ };
+
+ // Handle iframe errors
+ iframe.onerror = function() {
+ const loading = container.querySelector('.web-view-loading');
+ if (loading) {
+ loading.innerHTML = '<div class="web-view-error">❌ Failed to load preview</div>';
+ }
+ };
+ }
+ }
+ }
+
+ function openFullscreen(url, title) {
+ const overlay = document.getElementById('previewOverlay');
+ const container = document.getElementById('previewContainer');
+ const iframe = document.getElementById('previewIframe');
+ const titleEl = document.getElementById('previewTitle');
+
+ titleEl.textContent = title;
+ iframe.src = url;
+
+ overlay.classList.add('active');
+ container.classList.add('active');
+ }
+
+ // Restart app function
+ async function restartApp(directory, port) {
+ if (!confirm(`Are you sure you want to restart the app in ${directory}?`)) {
+ return;
+ }
+
+ const button = event.target;
+ const card = button.closest('.card');
+
+ button.disabled = true;
+ button.innerHTML = '⏳ Starting<span class="loading-spinner"></span>';
+
+ // Add loading state to card
+ card.classList.add('loading');
+ const overlay = document.createElement('div');
+ overlay.className = 'loading-overlay';
+ overlay.innerHTML = `
+ <div class="big-spinner"></div>
+ <div class="loading-text">🚀 Starting app...</div>
+ `;
+ card.appendChild(overlay);
+
+ try {
+ const response = await authFetch(`/api/restart/${encodeURIComponent(directory)}`);
+ const result = await response.json();
+
+ if (result.success) {
+ overlay.querySelector('.loading-text').textContent = '✅ Started successfully!';
+ button.innerHTML = '✅ Started!';
+ button.style.background = 'linear-gradient(90deg, #00b894 0%, #00a085 100%)';
+
+ // ALWAYS open the URL in a new tab using server IP
+ const portToOpen = result.lastPort || port;
+ if (portToOpen) {
+ setTimeout(() => {
+ const url = `http://${serverInfo.serverIp}:${portToOpen}`;
+ window.open(url, '_blank');
+ }, 1500);
+ }
+
+ setTimeout(() => {
+ card.classList.remove('loading');
+ overlay.remove();
+ button.textContent = '🔄 Restart App';
+ button.style.background = 'linear-gradient(90deg, #ff6b6b 0%, #ee5a6f 100%)';
+ button.disabled = false;
+
+ // Reload ports after restart
+ setTimeout(() => {
+ loadPorts();
+ loadDirectories();
+ }, 2000);
+ }, 3000);
+ } else {
+ overlay.querySelector('.loading-text').textContent = '❌ Failed to start';
+ button.textContent = '❌ Failed';
+ button.style.background = '#d63031';
+ setTimeout(() => {
+ card.classList.remove('loading');
+ overlay.remove();
+ button.textContent = '🔄 Restart App';
+ button.style.background = 'linear-gradient(90deg, #ff6b6b 0%, #ee5a6f 100%)';
+ button.disabled = false;
+ }, 3000);
+ alert(`Failed to restart: ${result.error || result.message}`);
+ }
+ } catch (err) {
+ card.classList.remove('loading');
+ if (overlay && overlay.parentNode) overlay.remove();
+ button.textContent = '❌ Error';
+ button.disabled = false;
+ alert(`Error: ${err.message}`);
+ setTimeout(() => {
+ button.textContent = '🔄 Restart App';
+ }, 3000);
+ }
+ }
+ </script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..9e31766
--- /dev/null
+++ b/server.js
@@ -0,0 +1,972 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { exec } = require('child_process');
+const nodemailer = require('nodemailer');
+const crypto = require('crypto');
+
+const PORT = 8888; // Permanent port assignment
+const SERVER_IP = '45.61.58.125'; // Server public IP address
+const SYSTEM_PORTS = [22, 53, 80, 443, 111, 123, 514]; // Common system ports to exclude
+
+// Authentication
+const AUTH_PASSWORD = 'serveraccess911'; // Change this to your desired password
+const sessions = new Map(); // Store active sessions
+
+// Store directory/port mappings in memory
+const directoryData = {};
+
+// Store previous port states for change detection
+const previousPortStates = {};
+
+// Email configuration
+let emailConfig = {
+ enabled: true,
+ recipient: 'steve@designerwallcoverings.com',
+ smtp: {
+ host: 'localhost',
+ port: 25,
+ secure: false
+ }
+};
+
+// Create email transporter
+let transporter = null;
+try {
+ transporter = nodemailer.createTransport(emailConfig.smtp);
+} catch (err) {
+ console.error('Failed to create email transporter:', err.message);
+}
+
+// Helper functions for authentication
+function generateToken() {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+function isAuthenticated(req) {
+ const authHeader = req.headers['authorization'];
+ if (!authHeader) return false;
+
+ const token = authHeader.replace('Bearer ', '');
+ return sessions.has(token);
+}
+
+function getCookie(req, name) {
+ const cookies = req.headers.cookie;
+ if (!cookies) return null;
+
+ const cookie = cookies.split(';').find(c => c.trim().startsWith(name + '='));
+ return cookie ? cookie.split('=')[1] : null;
+}
+
+const server = http.createServer((req, res) => {
+ // Login endpoint - always accessible
+ if (req.url === '/api/login' && req.method === 'POST') {
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const { password } = JSON.parse(body);
+
+ if (password === AUTH_PASSWORD) {
+ const token = generateToken();
+ sessions.set(token, { createdAt: Date.now() });
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: true, token }));
+ } else {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error: 'Invalid password' }));
+ }
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid request' }));
+ }
+ });
+ return;
+ }
+
+ // Logout endpoint
+ if (req.url === '/api/logout' && req.method === 'POST') {
+ const authHeader = req.headers['authorization'];
+ if (authHeader) {
+ const token = authHeader.replace('Bearer ', '');
+ sessions.delete(token);
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: true }));
+ return;
+ }
+
+ // Check authentication for API routes (except login, server-info, suggest-port, domains, and proxy)
+ if (req.url.startsWith('/api/') && req.url !== '/api/server-info' && req.url !== '/api/suggest-port' && req.url !== '/api/domains' && !req.url.startsWith('/api/proxy/') && !isAuthenticated(req)) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+
+ if (req.url === '/api/server-info') {
+ // Return server information
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ serverIp: SERVER_IP,
+ port: PORT
+ }));
+ return;
+ }
+
+ if (req.url === '/api/ports') {
+ exec('ss -tulpn 2>/dev/null | grep LISTEN', (error, stdout, stderr) => {
+ if (error && !stdout) {
+ // Fallback to netstat if ss fails
+ exec('netstat -tulpn 2>/dev/null | grep LISTEN', (error2, stdout2, stderr2) => {
+ if (error2 && !stdout2) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Failed to fetch port information' }));
+ return;
+ }
+ enrichPortData(parseNetstat(stdout2), (enrichedPorts) => {
+ const allServices = mergeWithOfflineDirectories(enrichedPorts);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(allServices));
+ });
+ });
+ } else {
+ enrichPortData(parseSS(stdout), (enrichedPorts) => {
+ const allServices = mergeWithOfflineDirectories(enrichedPorts);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(allServices));
+ });
+ }
+ });
+ } else if (req.url.startsWith('/api/restart/')) {
+ // Handle restart requests
+ const directory = decodeURIComponent(req.url.replace('/api/restart/', ''));
+ handleRestart(directory, res);
+ } else if (req.url === '/api/directories') {
+ // Get stored directory data
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(directoryData));
+ } else if (req.url === '/api/domains') {
+ // Get all domains from nginx configs
+ exec('grep -h "server_name" /etc/nginx/sites-enabled/* 2>/dev/null', (error, stdout) => {
+ const domains = [];
+
+ if (stdout) {
+ const lines = stdout.trim().split('\n');
+ lines.forEach(line => {
+ // Parse server_name lines
+ const match = line.match(/server_name\s+([^;]+);/);
+ if (match) {
+ const serverNames = match[1].trim().split(/\s+/);
+ serverNames.forEach(domain => {
+ if (domain && !domain.includes('localhost') && !domain.match(/^\d+\.\d+\.\d+\.\d+$/)) {
+ domains.push(domain);
+ }
+ });
+ }
+ });
+ }
+
+ // Get unique domains
+ const uniqueDomains = [...new Set(domains)].sort();
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ domains: uniqueDomains }));
+ });
+ } else if (req.url === '/api/suggest-port') {
+ // Suggest an available port
+ exec('ss -tulpn | grep LISTEN', (error, stdout) => {
+ const usedPorts = new Set();
+
+ // Parse all listening ports
+ const lines = stdout.trim().split('\n');
+ lines.forEach(line => {
+ const match = line.match(/:(\d+)\s/);
+ if (match) {
+ usedPorts.add(parseInt(match[1]));
+ }
+ });
+
+ // Find available port starting from 9000
+ let suggestedPort = 9000;
+ while (usedPorts.has(suggestedPort)) {
+ suggestedPort++;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ port: suggestedPort }));
+ });
+ } else if (req.url === '/api/scan-directories') {
+ // Scan for directories with package.json - deep scan in specific directories
+ const scanPaths = [
+ 'find /root/DW-Websites -name "package.json" -type f 2>/dev/null',
+ 'find /root/WebsitesMisc -name "package.json" -type f 2>/dev/null',
+ 'find /root/Scripts -name "package.json" -type f 2>/dev/null',
+ 'find /opt -name "package.json" -type f 2>/dev/null',
+ 'find /root -maxdepth 3 -name "package.json" -type f 2>/dev/null'
+ ];
+
+ const combinedCommand = scanPaths.join(' ; ');
+ exec(combinedCommand, (error, stdout) => {
+ const directories = new Set(); // Use Set to avoid duplicates
+ if (stdout) {
+ const files = stdout.trim().split('\n');
+ files.forEach(file => {
+ if (file) {
+ const dir = path.dirname(file);
+ // Exclude node_modules
+ if (!dir.includes('node_modules')) {
+ directories.add(dir);
+ }
+ }
+ });
+ }
+
+ const uniqueDirectories = Array.from(directories).sort();
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ directories: uniqueDirectories,
+ count: uniqueDirectories.length
+ }));
+ });
+ } else if (req.url === '/api/add-directory' && req.method === 'POST') {
+ // Handle adding a directory manually
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const { directory, port, appName } = JSON.parse(body);
+
+ if (!directory) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Directory path is required' }));
+ return;
+ }
+
+ // Check if directory exists
+ if (!fs.existsSync(directory)) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Directory does not exist' }));
+ return;
+ }
+
+ // Add to directoryData
+ directoryData[directory] = {
+ lastPort: port || 'N/A',
+ lastSeen: new Date().toISOString(),
+ process: 'Unknown',
+ appName: appName || path.basename(directory)
+ };
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: 'Directory added successfully',
+ directory: directory
+ }));
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
+ }
+ });
+ } else if (req.url === '/api/ask' && req.method === 'POST') {
+ // Handle natural language server queries
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const { query } = JSON.parse(body);
+ handleServerQuery(query, res);
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
+ }
+ });
+ } else if (req.url.startsWith('/api/delete-directory/')) {
+ // Handle deleting a directory
+ const directory = decodeURIComponent(req.url.replace('/api/delete-directory/', ''));
+ if (directoryData[directory]) {
+ delete directoryData[directory];
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: 'Directory removed successfully'
+ }));
+ } else {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Directory not found' }));
+ }
+ } else if (req.url.startsWith('/api/stop-port/') && req.method === 'POST') {
+ // Stop/kill process on port
+ const port = decodeURIComponent(req.url.replace('/api/stop-port/', ''));
+ exec(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, (error, stdout, stderr) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: `Stopped process on port ${port}`
+ }));
+ });
+ } else if (req.url === '/api/assign-port' && req.method === 'POST') {
+ // Assign port to directory
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const { directory, port, appName } = JSON.parse(body);
+
+ if (!directory || !port) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Directory and port are required' }));
+ return;
+ }
+
+ // Update or create directory mapping
+ directoryData[directory] = {
+ lastPort: port,
+ lastSeen: new Date().toISOString(),
+ process: 'Unknown',
+ appName: appName || path.basename(directory)
+ };
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: `Assigned port ${port} to ${directory}`,
+ directory: directory,
+ port: port
+ }));
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
+ }
+ });
+ } else if (req.url === '/api/email-config') {
+ // Get email configuration
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ enabled: emailConfig.enabled,
+ recipient: emailConfig.recipient
+ }));
+ } else if (req.url === '/api/email-config' && req.method === 'POST') {
+ // Update email configuration
+ let body = '';
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const { enabled, recipient } = JSON.parse(body);
+
+ if (enabled !== undefined) emailConfig.enabled = enabled;
+ if (recipient) emailConfig.recipient = recipient;
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: 'Email configuration updated',
+ config: {
+ enabled: emailConfig.enabled,
+ recipient: emailConfig.recipient
+ }
+ }));
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
+ }
+ });
+ } else if (req.url.startsWith('/api/proxy/')) {
+ // Proxy endpoint to bypass X-Frame-Options
+ const targetUrl = decodeURIComponent(req.url.replace('/api/proxy/', ''));
+
+ exec(`curl -sL "${targetUrl}"`, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
+ if (error) {
+ res.writeHead(500, { 'Content-Type': 'text/html' });
+ res.end(`<html><body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;background:#f0f0f0;"><div style="text-align:center;"><h2>❌ Failed to load</h2><p>${targetUrl}</p><p style="font-size:0.9em;color:#666;">${error.message}</p></div></body></html>`);
+ return;
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': 'text/html',
+ 'X-Frame-Options': 'ALLOWALL',
+ 'Content-Security-Policy': ''
+ });
+ res.end(stdout);
+ });
+ } else if (req.url === '/test') {
+ // Serve test page
+ const filePath = path.join(__dirname, 'test.html');
+ fs.readFile(filePath, (err, data) => {
+ if (err) {
+ res.writeHead(404);
+ res.end('Not found');
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(data);
+ });
+ } else {
+ // Serve index.html
+ const filePath = path.join(__dirname, 'public', 'index.html');
+ fs.readFile(filePath, (err, data) => {
+ if (err) {
+ res.writeHead(404);
+ res.end('Not found');
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(data);
+ });
+ }
+});
+
+function parseSS(output) {
+ const lines = output.trim().split('\n');
+ const ports = [];
+
+ lines.forEach(line => {
+ const parts = line.trim().split(/\s+/);
+ if (parts.length < 5) return;
+
+ const protocol = parts[0];
+ const localAddress = parts[4];
+ const [address, port] = localAddress.split(':').slice(-2);
+
+ // Extract process info and PID
+ let process = 'Unknown';
+ let pid = null;
+ const usersMatch = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
+ if (usersMatch) {
+ process = usersMatch[1];
+ pid = usersMatch[2];
+ }
+
+ ports.push({
+ port: port || 'N/A',
+ protocol: protocol.toUpperCase(),
+ address: address === '*' ? '0.0.0.0' : address,
+ process: process,
+ pid: pid
+ });
+ });
+
+ return ports.sort((a, b) => parseInt(a.port) - parseInt(b.port));
+}
+
+function parseNetstat(output) {
+ const lines = output.trim().split('\n');
+ const ports = [];
+
+ lines.forEach(line => {
+ const parts = line.trim().split(/\s+/);
+ if (parts.length < 7) return;
+
+ const protocol = parts[0];
+ const localAddress = parts[3];
+ const [address, port] = localAddress.split(':').slice(-2);
+ const processInfo = parts[6] || 'Unknown';
+
+ // Extract PID from process info (format: pid/process)
+ let process = processInfo;
+ let pid = null;
+ const pidMatch = processInfo.match(/(\d+)\/(.*)/);
+ if (pidMatch) {
+ pid = pidMatch[1];
+ process = pidMatch[2];
+ }
+
+ ports.push({
+ port: port || 'N/A',
+ protocol: protocol.toUpperCase(),
+ address: address === '*' ? '0.0.0.0' : address,
+ process: process,
+ pid: pid
+ });
+ });
+
+ return ports.sort((a, b) => parseInt(a.port) - parseInt(b.port));
+}
+
+function enrichPortData(ports, callback) {
+ // Get nginx config data - read actual config files
+ exec('cat /etc/nginx/sites-enabled/* 2>/dev/null', (error, nginxOutput) => {
+ const portMapping = parseNginxConfig(nginxOutput || '');
+
+ // Get Docker data
+ exec('docker ps --format "{{.Names}}|{{.Ports}}|{{.Image}}"', (error, dockerOutput) => {
+ const dockerMapping = parseDockerPs(dockerOutput || '');
+
+ // Get directory info for each port with PID
+ let completed = 0;
+ const enrichedPorts = ports.map(portInfo => {
+ const portNum = portInfo.port;
+ const websites = portMapping[portNum] || [];
+ const docker = dockerMapping[portNum] || null;
+
+ return {
+ ...portInfo,
+ websites: websites,
+ website: websites.length > 0 ? websites.join(', ') : null,
+ docker: docker,
+ appName: getAppName(portInfo, websites, docker),
+ directory: null,
+ isUserApp: false
+ };
+ });
+
+ // Get directory for each port
+ enrichedPorts.forEach((portInfo, index) => {
+ if (portInfo.pid && !SYSTEM_PORTS.includes(parseInt(portInfo.port))) {
+ getProcessDirectory(portInfo.pid, (dir) => {
+ enrichedPorts[index].directory = dir;
+ enrichedPorts[index].isUserApp = dir && !dir.startsWith('/usr') && !dir.startsWith('/lib') && !dir.startsWith('/bin');
+
+ // Store directory data
+ if (dir && enrichedPorts[index].isUserApp) {
+ directoryData[dir] = {
+ lastPort: portInfo.port,
+ lastSeen: new Date().toISOString(),
+ process: portInfo.process,
+ appName: portInfo.appName
+ };
+ }
+
+ completed++;
+ if (completed === enrichedPorts.length) {
+ callback(enrichedPorts);
+ }
+ });
+ } else {
+ completed++;
+ if (completed === enrichedPorts.length) {
+ callback(enrichedPorts);
+ }
+ }
+ });
+
+ // If no async operations, call immediately
+ if (enrichedPorts.length === 0 || enrichedPorts.every(p => !p.pid || SYSTEM_PORTS.includes(parseInt(p.port)))) {
+ callback(enrichedPorts);
+ }
+ });
+ });
+}
+
+function parseNginxConfig(output) {
+ const mapping = {};
+
+ // Split into server blocks
+ const serverBlocks = output.split(/server\s*{/);
+
+ serverBlocks.forEach(block => {
+ // Find server_name
+ const serverNameMatch = block.match(/server_name\s+([^;]+);/);
+ // Find proxy_pass with port
+ const proxyPassMatch = block.match(/proxy_pass\s+http:\/\/[^:]+:(\d+)/);
+
+ if (serverNameMatch && proxyPassMatch) {
+ const port = proxyPassMatch[1];
+ const domains = serverNameMatch[1]
+ .split(/\s+/)
+ .filter(d => d && d !== '_' && d !== 'www._' && !d.match(/^\d+\.\d+\.\d+\.\d+$/))
+ .map(d => d.replace(/^www\./, '')); // Remove www. prefix
+
+ if (domains.length > 0) {
+ if (!mapping[port]) mapping[port] = [];
+ domains.forEach(domain => {
+ if (!mapping[port].includes(domain)) {
+ mapping[port].push(domain);
+ }
+ });
+ }
+ }
+ });
+
+ return mapping;
+}
+
+function parseDockerPs(output) {
+ const lines = output.trim().split('\n');
+ const mapping = {};
+
+ lines.forEach(line => {
+ const [name, ports, image] = line.split('|');
+ if (!ports) return;
+
+ const portMatch = ports.match(/:(\d+)->/g);
+ if (portMatch) {
+ portMatch.forEach(pm => {
+ const port = pm.match(/:(\d+)->/)[1];
+ mapping[port] = { name, image };
+ });
+ }
+ });
+
+ return mapping;
+}
+
+function getAppName(portInfo, websites, docker) {
+ if (docker) return docker.name;
+ if (websites.length > 0) return websites[0];
+
+ // Guess based on port
+ const portNum = parseInt(portInfo.port);
+ if (portNum === 80 || portNum === 443) return 'Web Server (nginx)';
+ if (portNum === 22) return 'SSH Server';
+ if (portNum === 3000) return 'Node.js App';
+ if (portNum === 8080) return 'HTTP Server';
+ if (portNum === 9000) return 'Node.js App';
+ if (portInfo.process.includes('nginx')) return 'nginx';
+ if (portInfo.process.includes('node')) return 'Node.js App';
+ if (portInfo.process.includes('docker')) return 'Docker Service';
+
+ return portInfo.process;
+}
+
+function getProcessDirectory(pid, callback) {
+ exec(`readlink -f /proc/${pid}/cwd 2>/dev/null`, (error, stdout) => {
+ if (error || !stdout) {
+ callback(null);
+ } else {
+ callback(stdout.trim());
+ }
+ });
+}
+
+function mergeWithOfflineDirectories(activePorts) {
+ const activeDirectories = new Set();
+ const result = [...activePorts];
+
+ // Mark active directories
+ activePorts.forEach(port => {
+ if (port.directory && port.isUserApp) {
+ activeDirectories.add(port.directory);
+ }
+ });
+
+ // Add offline directories
+ Object.keys(directoryData).forEach(directory => {
+ if (!activeDirectories.has(directory)) {
+ const data = directoryData[directory];
+ result.push({
+ port: data.lastPort || 'N/A',
+ protocol: 'N/A',
+ address: 'N/A',
+ process: data.process || 'Unknown',
+ pid: null,
+ websites: [],
+ website: null,
+ docker: null,
+ appName: data.appName || path.basename(directory),
+ directory: directory,
+ isUserApp: true,
+ isOffline: true
+ });
+ }
+ });
+
+ return result;
+}
+
+function handleServerQuery(query, res) {
+ const q = query.toLowerCase().trim();
+
+ let command = '';
+ let type = 'general';
+
+ // Parse the query and determine the command
+ if (q === 'help' || q.includes('help')) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ type: 'help',
+ response: `**Available Commands:**
+- uptime / show uptime
+- memory / memory usage / ram
+- cpu / cpu usage / load
+- disk / disk usage / storage
+- ports / open ports / listening ports
+- processes / top processes
+- directories / my directories
+- network / network info
+- users / logged in users
+- services / systemd services`
+ }));
+ return;
+ }
+
+ if (q.includes('uptime')) {
+ command = 'uptime -p';
+ type = 'uptime';
+ } else if (q.includes('memory') || q.includes('ram')) {
+ command = 'free -h';
+ type = 'memory';
+ } else if (q.includes('cpu') || q.includes('load')) {
+ command = 'top -bn1 | head -20';
+ type = 'cpu';
+ } else if (q.includes('disk') || q.includes('storage')) {
+ command = 'df -h';
+ type = 'disk';
+ } else if (q.includes('port') && (q.includes('open') || q.includes('listening'))) {
+ command = 'ss -tulpn | grep LISTEN';
+ type = 'ports';
+ } else if (q.includes('process') || q.includes('top')) {
+ command = 'ps aux --sort=-%mem | head -15';
+ type = 'processes';
+ } else if (q.includes('director')) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ type: 'directories',
+ response: JSON.stringify(directoryData, null, 2),
+ data: directoryData
+ }));
+ return;
+ } else if (q.includes('network')) {
+ command = 'ip addr show';
+ type = 'network';
+ } else if (q.includes('user') || q.includes('logged in')) {
+ command = 'who';
+ type = 'users';
+ } else if (q.includes('service')) {
+ command = 'systemctl list-units --type=service --state=running';
+ type = 'services';
+ } else if (q.includes('docker')) {
+ command = 'docker ps';
+ type = 'docker';
+ } else {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: false,
+ error: 'I didn\'t understand that question. Type "help" to see available commands.'
+ }));
+ return;
+ }
+
+ // Execute the command
+ exec(command, (error, stdout, stderr) => {
+ if (error) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: false,
+ error: stderr || error.message
+ }));
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ type: type,
+ query: query,
+ response: stdout.trim()
+ }));
+ });
+}
+
+function handleRestart(directory, res) {
+ console.log(`[RESTART] Request for directory: ${directory}`);
+
+ if (!directory || !directoryData[directory]) {
+ console.log(`[RESTART] Directory not found: ${directory}`);
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Directory not found' }));
+ return;
+ }
+
+ const data = directoryData[directory];
+ const lastPort = data.lastPort;
+ console.log(`[RESTART] Port: ${lastPort}`);
+
+ // Find package.json or main entry point
+ const packageJsonPath = path.join(directory, 'package.json');
+
+ fs.readFile(packageJsonPath, 'utf8', (err, content) => {
+ let startCommand = 'npm start';
+
+ if (!err) {
+ try {
+ const pkg = JSON.parse(content);
+ if (pkg.scripts && pkg.scripts.start) {
+ startCommand = 'npm start';
+ } else if (pkg.main) {
+ startCommand = `node ${pkg.main}`;
+ }
+ } catch (e) {
+ // Fallback to npm start
+ }
+ }
+
+ // Kill existing process on the port first
+ exec(`lsof -ti:${lastPort} | xargs kill -9 2>/dev/null`, () => {
+ // Also kill any node processes running in this directory
+ exec(`ps aux | grep "node.*${directory}" | grep -v grep | awk '{print $2}' | xargs kill -9 2>/dev/null`, () => {
+ // Wait a bit for processes to be killed
+ setTimeout(() => {
+ // Start the process in the directory with PORT environment variable
+ const logFile = `/tmp/${path.basename(directory)}.log`;
+
+ // For Next.js apps, call next directly instead of through npm
+ let finalCommand = startCommand;
+ if (startCommand === 'npm start' || startCommand.includes('next')) {
+ // Check if package.json has next
+ const pkg = JSON.parse(content);
+ if (pkg.scripts && pkg.scripts.start && pkg.scripts.start.includes('next')) {
+ // Call next directly to avoid npm flag issues - use dev mode for instant start
+ finalCommand = `npx next dev -p ${lastPort}`;
+ } else {
+ finalCommand = `PORT=${lastPort} ${startCommand}`;
+ }
+ } else {
+ finalCommand = `PORT=${lastPort} ${startCommand}`;
+ }
+
+ // Use bash with NVM to ensure correct Node version
+ const bashCommand = `bash -c "source ~/.nvm/nvm.sh && cd '${directory}' && ${finalCommand} > ${logFile} 2>&1 &"`;
+ console.log(`[RESTART] Executing: ${finalCommand}`);
+
+ exec(bashCommand, (error, stdout, stderr) => {
+ if (error) {
+ console.log(`[RESTART] Error: ${error.message}`);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ error: 'Failed to restart',
+ message: stderr || error.message,
+ logFile: logFile
+ }));
+ } else {
+ console.log(`[RESTART] Command executed successfully`);
+ // Respond immediately - don't wait
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ success: true,
+ message: `Restarted ${path.basename(directory)} on port ${lastPort}`,
+ directory: directory,
+ lastPort: lastPort,
+ openUrl: `http://localhost:${lastPort}`,
+ logFile: logFile
+ }));
+ }
+ });
+ }, 500); // Reduced wait time
+ });
+ });
+ });
+}
+
+async function sendDownAlert(directory, port, appName) {
+ if (!emailConfig.enabled || !transporter) {
+ console.log(`[Alert] Would send email: ${appName} (${directory}) on port ${port} is DOWN`);
+ return;
+ }
+
+ const mailOptions = {
+ from: '"Port Viewer Alert" <noreply@server.local>',
+ to: emailConfig.recipient,
+ subject: `🚨 ALERT: ${appName || 'Application'} is DOWN`,
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2 style="color: #d63031; border-bottom: 3px solid #d63031; padding-bottom: 10px;">
+ 🚨 Port Down Alert
+ </h2>
+ <div style="background: #fff3cd; padding: 20px; border-radius: 10px; margin: 20px 0;">
+ <h3 style="margin-top: 0;">Application Offline</h3>
+ <p><strong>Application:</strong> ${appName || 'Unknown'}</p>
+ <p><strong>Directory:</strong> <code>${directory}</code></p>
+ <p><strong>Port:</strong> ${port}</p>
+ <p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
+ </div>
+ <p>The application has stopped responding and is no longer listening on its port.</p>
+ <p style="color: #666; font-size: 12px; margin-top: 30px;">
+ This is an automated alert from Port Viewer monitoring system.
+ </p>
+ </div>
+ `
+ };
+
+ try {
+ await transporter.sendMail(mailOptions);
+ console.log(`✉️ Email alert sent to ${emailConfig.recipient} for ${appName}`);
+ } catch (error) {
+ console.error(`Failed to send email alert:`, error.message);
+ }
+}
+
+function checkPortChanges(currentPorts) {
+ const currentState = {};
+
+ // Build current state map
+ currentPorts.forEach(port => {
+ if (port.directory && port.isUserApp) {
+ currentState[port.directory] = {
+ isOnline: !port.isOffline,
+ port: port.port,
+ appName: port.appName
+ };
+ }
+ });
+
+ // Check for directories that went offline
+ Object.keys(currentState).forEach(directory => {
+ const current = currentState[directory];
+ const previous = previousPortStates[directory];
+
+ // If we have previous state and it was online but now is offline
+ if (previous && previous.isOnline && !current.isOnline) {
+ console.log(`⚠️ ALERT: ${current.appName} (${directory}) went offline!`);
+ sendDownAlert(directory, current.port, current.appName);
+ }
+ });
+
+ // Also check for directories that completely disappeared
+ Object.keys(previousPortStates).forEach(directory => {
+ if (!currentState[directory] && previousPortStates[directory].isOnline) {
+ const prev = previousPortStates[directory];
+ console.log(`⚠️ ALERT: ${prev.appName} (${directory}) disappeared!`);
+ sendDownAlert(directory, prev.port, prev.appName);
+ }
+ });
+
+ // Update previous state
+ Object.keys(currentState).forEach(directory => {
+ previousPortStates[directory] = currentState[directory];
+ });
+}
+
+// Monitor ports every 60 seconds
+setInterval(() => {
+ exec('ss -tulpn 2>/dev/null | grep LISTEN', (error, stdout, stderr) => {
+ if (error && !stdout) {
+ exec('netstat -tulpn 2>/dev/null | grep LISTEN', (error2, stdout2, stderr2) => {
+ if (!error2 || stdout2) {
+ enrichPortData(parseNetstat(stdout2), (enrichedPorts) => {
+ const allServices = mergeWithOfflineDirectories(enrichedPorts);
+ checkPortChanges(allServices);
+ });
+ }
+ });
+ } else {
+ enrichPortData(parseSS(stdout), (enrichedPorts) => {
+ const allServices = mergeWithOfflineDirectories(enrichedPorts);
+ checkPortChanges(allServices);
+ });
+ }
+ });
+}, 60000); // Check every 60 seconds
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log(`🚀 Port Viewer running at http://localhost:${PORT}`);
+ console.log(`📊 View all active ports in your browser`);
+ console.log(`📧 Email alerts enabled for: ${emailConfig.recipient}`);
+ console.log(`⏱️ Port monitoring: Every 60 seconds`);
+ console.log(`🌐 External access: http://${SERVER_IP}:${PORT}`);
+});
diff --git a/test.html b/test.html
new file mode 100644
index 0000000..e722574
--- /dev/null
+++ b/test.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Port Viewer Test</title>
+</head>
+<body style="background: purple; color: white; padding: 50px; font-size: 24px;">
+ <h1>Port Viewer Test Page</h1>
+ <p>If you can see this, the server is working!</p>
+ <p>Current time: <span id="time"></span></p>
+ <script>
+ document.getElementById('time').textContent = new Date().toLocaleString();
+ </script>
+</body>
+</html>
(oldest)
·
back to Port Viewer
·
snapshot: 1 file(s) changed, ~1 modified ee28ffe →