add Login page, bulk delete logs #1
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
JWT_SECRET=secret
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +1,2 @@
 | 
			
		|||
node_modules
 | 
			
		||||
node_modules
 | 
			
		||||
.env
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
const { verifyToken } = require("../utils/jwt");
 | 
			
		||||
 | 
			
		||||
const authenticateToken = (req, res, next) => {
 | 
			
		||||
	const authHeader = req.headers.authorization;
 | 
			
		||||
	const token = authHeader?.split(" ")[1];
 | 
			
		||||
 | 
			
		||||
	if (!token) return res.status(401).json({ message: "Unauthorized" });
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		req.user = verifyToken(token);
 | 
			
		||||
		next();
 | 
			
		||||
	} catch (err) {
 | 
			
		||||
		return res.status(403).json({ message: "Invalid token" });
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = { authenticateToken };
 | 
			
		||||
| 
						 | 
				
			
			@ -10,12 +10,14 @@
 | 
			
		|||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "axios": "^1.10.0",
 | 
			
		||||
        "bcrypt": "^6.0.0",
 | 
			
		||||
        "better-sqlite3": "^12.2.0",
 | 
			
		||||
        "cheerio": "^1.1.1",
 | 
			
		||||
        "cli-progress": "^3.12.0",
 | 
			
		||||
        "cors": "^2.8.5",
 | 
			
		||||
        "dotenv": "^17.2.0",
 | 
			
		||||
        "express": "^4.18.2",
 | 
			
		||||
        "jsonwebtoken": "^9.0.2",
 | 
			
		||||
        "multer": "^1.4.5-lts.1",
 | 
			
		||||
        "mysql2": "^3.14.2",
 | 
			
		||||
        "sqlite3": "^5.1.6",
 | 
			
		||||
| 
						 | 
				
			
			@ -267,6 +269,29 @@
 | 
			
		|||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/bcrypt": {
 | 
			
		||||
      "version": "6.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
 | 
			
		||||
      "hasInstallScript": true,
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "node-addon-api": "^8.3.0",
 | 
			
		||||
        "node-gyp-build": "^4.8.4"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">= 18"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/bcrypt/node_modules/node-addon-api": {
 | 
			
		||||
      "version": "8.5.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
 | 
			
		||||
      "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": "^18 || ^20 || >= 21"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/better-sqlite3": {
 | 
			
		||||
      "version": "12.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -396,6 +421,12 @@
 | 
			
		|||
        "ieee754": "^1.1.13"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/buffer-equal-constant-time": {
 | 
			
		||||
      "version": "1.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
 | 
			
		||||
      "license": "BSD-3-Clause"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/buffer-from": {
 | 
			
		||||
      "version": "1.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -853,6 +884,15 @@
 | 
			
		|||
        "node": ">= 0.4"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ecdsa-sig-formatter": {
 | 
			
		||||
      "version": "1.0.11",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
 | 
			
		||||
      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "safe-buffer": "^5.0.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ee-first": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -1665,6 +1705,97 @@
 | 
			
		|||
      "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
 | 
			
		||||
      "optional": true
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/jsonwebtoken": {
 | 
			
		||||
      "version": "9.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
 | 
			
		||||
      "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "jws": "^3.2.2",
 | 
			
		||||
        "lodash.includes": "^4.3.0",
 | 
			
		||||
        "lodash.isboolean": "^3.0.3",
 | 
			
		||||
        "lodash.isinteger": "^4.0.4",
 | 
			
		||||
        "lodash.isnumber": "^3.0.3",
 | 
			
		||||
        "lodash.isplainobject": "^4.0.6",
 | 
			
		||||
        "lodash.isstring": "^4.0.1",
 | 
			
		||||
        "lodash.once": "^4.0.0",
 | 
			
		||||
        "ms": "^2.1.1",
 | 
			
		||||
        "semver": "^7.5.4"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=12",
 | 
			
		||||
        "npm": ">=6"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/jsonwebtoken/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/jwa": {
 | 
			
		||||
      "version": "1.4.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
 | 
			
		||||
      "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "buffer-equal-constant-time": "^1.0.1",
 | 
			
		||||
        "ecdsa-sig-formatter": "1.0.11",
 | 
			
		||||
        "safe-buffer": "^5.0.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/jws": {
 | 
			
		||||
      "version": "3.2.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
 | 
			
		||||
      "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "jwa": "^1.4.1",
 | 
			
		||||
        "safe-buffer": "^5.0.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.includes": {
 | 
			
		||||
      "version": "4.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.isboolean": {
 | 
			
		||||
      "version": "3.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.isinteger": {
 | 
			
		||||
      "version": "4.0.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
 | 
			
		||||
      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.isnumber": {
 | 
			
		||||
      "version": "3.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.isplainobject": {
 | 
			
		||||
      "version": "4.0.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
 | 
			
		||||
      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.isstring": {
 | 
			
		||||
      "version": "4.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/lodash.once": {
 | 
			
		||||
      "version": "4.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/long": {
 | 
			
		||||
      "version": "5.3.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -2045,6 +2176,17 @@
 | 
			
		|||
        "node": ">= 10.12.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/node-gyp-build": {
 | 
			
		||||
      "version": "4.8.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
 | 
			
		||||
      "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "node-gyp-build": "bin.js",
 | 
			
		||||
        "node-gyp-build-optional": "optional.js",
 | 
			
		||||
        "node-gyp-build-test": "build-test.js"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/nodemon": {
 | 
			
		||||
      "version": "3.1.10",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,12 +9,14 @@
 | 
			
		|||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "axios": "^1.10.0",
 | 
			
		||||
    "bcrypt": "^6.0.0",
 | 
			
		||||
    "better-sqlite3": "^12.2.0",
 | 
			
		||||
    "cheerio": "^1.1.1",
 | 
			
		||||
    "cli-progress": "^3.12.0",
 | 
			
		||||
    "cors": "^2.8.5",
 | 
			
		||||
    "dotenv": "^17.2.0",
 | 
			
		||||
    "express": "^4.18.2",
 | 
			
		||||
    "jsonwebtoken": "^9.0.2",
 | 
			
		||||
    "multer": "^1.4.5-lts.1",
 | 
			
		||||
    "mysql2": "^3.14.2",
 | 
			
		||||
    "sqlite3": "^5.1.6",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,524 @@
 | 
			
		|||
* {
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
	--primary: #1e88e5;
 | 
			
		||||
	--primary-light: #e3f2fd;
 | 
			
		||||
	--gray-light: #f8f9fa;
 | 
			
		||||
	--gray-border: #e0e0e0;
 | 
			
		||||
	--text-color: #333;
 | 
			
		||||
	--bg-color: #ffffff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	font-family: "Segoe UI", Roboto, sans-serif;
 | 
			
		||||
	display: flex;
 | 
			
		||||
	height: 100vh;
 | 
			
		||||
	background-color: var(--bg-color);
 | 
			
		||||
	color: var(--text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar {
 | 
			
		||||
	width: 280px;
 | 
			
		||||
	background-color: var(--gray-light);
 | 
			
		||||
	border-right: 1px solid var(--gray-border);
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar-header {
 | 
			
		||||
	padding: 16px;
 | 
			
		||||
	background: white;
 | 
			
		||||
	border-bottom: 1px solid #ddd;
 | 
			
		||||
	flex-shrink: 0;
 | 
			
		||||
	z-index: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar-list {
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	overflow-y: auto;
 | 
			
		||||
	padding: 12px 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pid-button {
 | 
			
		||||
	display: block;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	padding: 8px 12px;
 | 
			
		||||
	margin: 4px 0;
 | 
			
		||||
	text-align: left;
 | 
			
		||||
	border: 1px solid transparent;
 | 
			
		||||
	background: #fff;
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: all 0.2s ease;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pid-button:hover {
 | 
			
		||||
	background-color: var(--primary-light);
 | 
			
		||||
	border-color: var(--primary);
 | 
			
		||||
	color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pid-button.selected {
 | 
			
		||||
	background-color: var(--primary);
 | 
			
		||||
	color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content {
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	padding: 24px;
 | 
			
		||||
	overflow-y: auto;
 | 
			
		||||
	background-color: #fefefe;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select,
 | 
			
		||||
input[type="text"] {
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	padding: 6px;
 | 
			
		||||
	/* margin-bottom: 16px; */
 | 
			
		||||
	border: 1px solid #ccc;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	outline: none;
 | 
			
		||||
	transition: border-color 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select:focus,
 | 
			
		||||
input[type="text"]:focus {
 | 
			
		||||
	border-color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.log-block {
 | 
			
		||||
	background: #fafafa;
 | 
			
		||||
	padding: 12px;
 | 
			
		||||
	margin-bottom: 12px;
 | 
			
		||||
	border-left: 4px solid var(--primary);
 | 
			
		||||
	font-family: monospace;
 | 
			
		||||
	white-space: pre-wrap;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
 | 
			
		||||
	position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.command-section {
 | 
			
		||||
	margin-top: 32px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.command-section h3 {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	justify-content: space-between;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	margin-bottom: 12px;
 | 
			
		||||
	font-size: 17px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.command-section h3 > div {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	gap: 6px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.next-btn {
 | 
			
		||||
	font-size: 13px;
 | 
			
		||||
	background: var(--primary);
 | 
			
		||||
	color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	padding: 5px 10px;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background 0.2s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.next-btn:hover:not(:disabled) {
 | 
			
		||||
	background-color: #1565c0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.next-btn:disabled {
 | 
			
		||||
	background-color: #ccc;
 | 
			
		||||
	cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table {
 | 
			
		||||
	border-collapse: collapse;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	background: white;
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
 | 
			
		||||
	margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table thead {
 | 
			
		||||
	background: var(--primary);
 | 
			
		||||
	color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table th,
 | 
			
		||||
table td {
 | 
			
		||||
	padding: 12px 14px;
 | 
			
		||||
	text-align: left;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table tbody tr:hover {
 | 
			
		||||
	background-color: #f1f8ff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
code {
 | 
			
		||||
	background: #f4f4f4;
 | 
			
		||||
	padding: 2px 6px;
 | 
			
		||||
	border-radius: 4px;
 | 
			
		||||
	font-size: 13px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
mark {
 | 
			
		||||
	background-color: #ffdb38;
 | 
			
		||||
	padding: 0 2px;
 | 
			
		||||
	border-radius: 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selectAndVersionDiv {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	justify-content: space-between;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selectedPIDDiv {
 | 
			
		||||
	width: 23%;
 | 
			
		||||
}
 | 
			
		||||
#selectedVersionDiv {
 | 
			
		||||
	width: 53%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.action-btn {
 | 
			
		||||
	background-color: #8d7df3;
 | 
			
		||||
	color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	padding: 8px 14px;
 | 
			
		||||
	margin: 5px;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	font-weight: 500;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background-color 0.2s ease, box-shadow 0.2s ease;
 | 
			
		||||
	box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.action-btn:hover {
 | 
			
		||||
	background-color: #0056b3;
 | 
			
		||||
	box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.action-btn:active {
 | 
			
		||||
	background-color: #004a99;
 | 
			
		||||
	transform: scale(0.98);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Login Page */
 | 
			
		||||
.login-section {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	justify-content: center;
 | 
			
		||||
	height: 100vh;
 | 
			
		||||
	width: 100vw;
 | 
			
		||||
	padding: 20px;
 | 
			
		||||
	background-image: url("../images/backgroundLogin.jpg");
 | 
			
		||||
	background-size: cover;
 | 
			
		||||
	background-position: center;
 | 
			
		||||
	background-repeat: no-repeat;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.login-container {
 | 
			
		||||
	max-width: 400px;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	margin-bottom: 5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-wrapper {
 | 
			
		||||
	background-color: rgba(255, 255, 255, 0.93);
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
	box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
 | 
			
		||||
	padding: 30px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-title {
 | 
			
		||||
	font-size: 20px;
 | 
			
		||||
	font-weight: 700;
 | 
			
		||||
	margin-bottom: 28px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group {
 | 
			
		||||
	text-align: left;
 | 
			
		||||
	margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group label {
 | 
			
		||||
	display: block;
 | 
			
		||||
	margin-bottom: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input {
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	padding: 10px;
 | 
			
		||||
	border: 1px solid var(--gray-border);
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input:focus {
 | 
			
		||||
	border-color: #1565c0;
 | 
			
		||||
	outline: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.primary-btn {
 | 
			
		||||
	background: var(--primary);
 | 
			
		||||
	color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	padding: 8px 14px;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	font-weight: 500;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background-color 0.2s ease, box-shadow 0.2s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.primary-btn:hover:not(:disabled) {
 | 
			
		||||
	background-color: #1565c0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.primary-btn:disabled {
 | 
			
		||||
	background-color: #ccc;
 | 
			
		||||
	cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.secondary-btn {
 | 
			
		||||
	padding: 10px 18px;
 | 
			
		||||
	font-size: 0.9rem;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background 0.2s ease;
 | 
			
		||||
	border: none;
 | 
			
		||||
	background-color: #f0f0f0;
 | 
			
		||||
	color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.secondary-btn:hover {
 | 
			
		||||
	background-color: #ddd;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.secondary-btn:disabled {
 | 
			
		||||
	background-color: #ccc;
 | 
			
		||||
	cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.danger-btn {
 | 
			
		||||
	background: #e03131;
 | 
			
		||||
	color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	padding: 8px 14px;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	font-weight: 500;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background-color 0.2s ease, box-shadow 0.2s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.danger-btn:hover:not(:disabled) {
 | 
			
		||||
	background-color: #c92a2a;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.danger-btn:disabled {
 | 
			
		||||
	background-color: #ccc;
 | 
			
		||||
	cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.warning-btn {
 | 
			
		||||
	background: #f08c00;
 | 
			
		||||
	color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	padding: 8px 14px;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	font-weight: 500;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	transition: background-color 0.2s ease, box-shadow 0.2s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.warning-btn:hover:not(:disabled) {
 | 
			
		||||
	background-color: #e67700;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.warning-btn:disabled {
 | 
			
		||||
	background-color: #ccc;
 | 
			
		||||
	cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.trash-btn {
 | 
			
		||||
	font-size: 22px;
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	right: 2px;
 | 
			
		||||
	top: 2px;
 | 
			
		||||
	padding: 2px 10px;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.remove-item-btn {
 | 
			
		||||
	font-size: 20px;
 | 
			
		||||
	right: 2px;
 | 
			
		||||
	top: 2px;
 | 
			
		||||
	padding: 2px 8px;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-error {
 | 
			
		||||
	color: red;
 | 
			
		||||
	margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Modal */
 | 
			
		||||
.modal {
 | 
			
		||||
	position: fixed;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	left: 0;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	background: rgba(30, 30, 30, 0.6);
 | 
			
		||||
	display: flex;
 | 
			
		||||
	justify-content: center;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	z-index: 1000;
 | 
			
		||||
	transition: opacity 0.3s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal.hidden {
 | 
			
		||||
	display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-content {
 | 
			
		||||
	background: #fff;
 | 
			
		||||
	padding: 30px;
 | 
			
		||||
	border-radius: 12px;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	max-width: 50%;
 | 
			
		||||
	box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
 | 
			
		||||
	animation: fadeIn 0.3s ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-title {
 | 
			
		||||
	font-size: 1.5rem;
 | 
			
		||||
	margin-top: 0;
 | 
			
		||||
	margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-actions {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	justify-content: flex-end;
 | 
			
		||||
	gap: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes fadeIn {
 | 
			
		||||
	from {
 | 
			
		||||
		opacity: 0;
 | 
			
		||||
		transform: translateY(-15px);
 | 
			
		||||
	}
 | 
			
		||||
	to {
 | 
			
		||||
		opacity: 1;
 | 
			
		||||
		transform: translateY(0);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-with-badge {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	padding: 8px 14px !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-with-badge .badge {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	top: -6px;
 | 
			
		||||
	right: -10px;
 | 
			
		||||
	background-color: #fff;
 | 
			
		||||
	color: #dc3545;
 | 
			
		||||
	font-size: 12px;
 | 
			
		||||
	font-weight: bold;
 | 
			
		||||
	padding: 3px 6px;
 | 
			
		||||
	border-radius: 999px;
 | 
			
		||||
	border: 1px solid #dc3545;
 | 
			
		||||
	min-width: 20px;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown-toggle {
 | 
			
		||||
	background-color: white;
 | 
			
		||||
	border: none;
 | 
			
		||||
	color: black;
 | 
			
		||||
	padding: 6px 12px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	border-radius: 4px;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown-content {
 | 
			
		||||
	display: none;
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	right: 0;
 | 
			
		||||
	background-color: #fff;
 | 
			
		||||
	min-width: 160px;
 | 
			
		||||
	box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.2);
 | 
			
		||||
	z-index: 1;
 | 
			
		||||
	border-radius: 4px;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown-content form {
 | 
			
		||||
	margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown-content button {
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	padding: 10px 16px;
 | 
			
		||||
	border: none;
 | 
			
		||||
	background-color: #f8f9fa;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
	text-align: left;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown-content button:hover {
 | 
			
		||||
	background-color: #e9ecef;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-dropdown:hover .user-dropdown-content {
 | 
			
		||||
	display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.toast {
 | 
			
		||||
	position: fixed;
 | 
			
		||||
	bottom: 20px;
 | 
			
		||||
	right: 20px;
 | 
			
		||||
	background-color: #323232;
 | 
			
		||||
	color: white;
 | 
			
		||||
	padding: 10px 20px;
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
	font-size: 14px;
 | 
			
		||||
	box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
 | 
			
		||||
	z-index: 9999;
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
	transition: opacity 0.3s ease, bottom 0.3s ease;
 | 
			
		||||
}
 | 
			
		||||
.toast.show {
 | 
			
		||||
	opacity: 1;
 | 
			
		||||
	bottom: 40px;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,587 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
	<head>
 | 
			
		||||
		<meta charset="UTF-8" />
 | 
			
		||||
		<title>PID Log Viewer</title>
 | 
			
		||||
 | 
			
		||||
		<!-- Link to CSS file -->
 | 
			
		||||
		<link rel="stylesheet" href="/css/style.css" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<div id="logToast" class="toast hidden"></div>
 | 
			
		||||
 | 
			
		||||
		<div class="sidebar">
 | 
			
		||||
			<div class="sidebar-header">
 | 
			
		||||
				<h3>PIDs (<span id="pidCount">0</span>)</h3>
 | 
			
		||||
				<input
 | 
			
		||||
					type="text"
 | 
			
		||||
					id="pidSearch"
 | 
			
		||||
					placeholder="🔍 Search PID..."
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="sidebar-list" id="pidListQuick"></div>
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
		<div class="content">
 | 
			
		||||
			<!-- <h3>📊 PID Log Viewer</h3> -->
 | 
			
		||||
			<div
 | 
			
		||||
				style="
 | 
			
		||||
					margin-bottom: 0.5rem;
 | 
			
		||||
					display: flex;
 | 
			
		||||
					justify-content: end;
 | 
			
		||||
				"
 | 
			
		||||
			>
 | 
			
		||||
				<div class="user-dropdown">
 | 
			
		||||
					<button
 | 
			
		||||
						id="welcome-user"
 | 
			
		||||
						class="user-dropdown-toggle"
 | 
			
		||||
					></button>
 | 
			
		||||
 | 
			
		||||
					<div class="user-dropdown-content">
 | 
			
		||||
						<button onclick="logout()">Logout</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div id="selectAndVersionDiv">
 | 
			
		||||
				<div id="selectedPIDDiv">
 | 
			
		||||
					<label for="pidList">PID</label>
 | 
			
		||||
					<select id="pidList" disabled></select>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div id="selectedVersionDiv">
 | 
			
		||||
					<label for="versionList">Select Version</label>
 | 
			
		||||
					<select id="versionList"></select>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div style="width: 23%; align-items: center">
 | 
			
		||||
					<button id="copyBtn" class="action-btn">📋 Copy</button>
 | 
			
		||||
					<button id="downloadBtn" class="action-btn">
 | 
			
		||||
						⬇️ Download
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<button
 | 
			
		||||
					id="logConfirmDeleteBtn"
 | 
			
		||||
					type="button"
 | 
			
		||||
					class="btn-with-badge danger-btn"
 | 
			
		||||
				>
 | 
			
		||||
					Delete
 | 
			
		||||
					<span class="badge" id="logDeleteBadge">0</span>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div id="errorTableContainer" class="command-section">
 | 
			
		||||
				<h3>⚠️ Error History for PID</h3>
 | 
			
		||||
				<table border="1" cellspacing="0" cellpadding="5" width="100%">
 | 
			
		||||
					<thead>
 | 
			
		||||
						<tr>
 | 
			
		||||
							<th style="text-align: left">Logging</th>
 | 
			
		||||
							<th style="text-align: left">Pattern</th>
 | 
			
		||||
							<th style="text-align: left">Description</th>
 | 
			
		||||
						</tr>
 | 
			
		||||
					</thead>
 | 
			
		||||
					<tbody id="errorTableBody"></tbody>
 | 
			
		||||
				</table>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div id="logSections"></div>
 | 
			
		||||
 | 
			
		||||
			<!-- Log Delete Modal -->
 | 
			
		||||
			<div id="logDeleteModal" class="modal hidden">
 | 
			
		||||
				<div class="modal-content">
 | 
			
		||||
					<h2 class="modal-title">Confirm Deletion</h2>
 | 
			
		||||
 | 
			
		||||
					<table class="delete-table">
 | 
			
		||||
						<thead>
 | 
			
		||||
							<tr>
 | 
			
		||||
								<th>ID</th>
 | 
			
		||||
								<th>PID</th>
 | 
			
		||||
								<th>Version</th>
 | 
			
		||||
								<th>Command</th>
 | 
			
		||||
								<th style="text-align: center">Action</th>
 | 
			
		||||
							</tr>
 | 
			
		||||
						</thead>
 | 
			
		||||
						<tbody id="logDeleteListContainer"></tbody>
 | 
			
		||||
					</table>
 | 
			
		||||
 | 
			
		||||
					<div class="modal-actions">
 | 
			
		||||
						<button id="logCloseModalBtn" class="btn secondary-btn">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</button>
 | 
			
		||||
						<button id="logSubmitDeleteBtn" class="btn danger-btn">
 | 
			
		||||
							Delete
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
		<script>
 | 
			
		||||
			if (!localStorage.getItem("token")) {
 | 
			
		||||
				logout();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			document.getElementById(
 | 
			
		||||
				"welcome-user",
 | 
			
		||||
			).innerHTML = `Welcome, <strong>${
 | 
			
		||||
				JSON.parse(localStorage.getItem("user")).name
 | 
			
		||||
			}</strong> ▼`;
 | 
			
		||||
 | 
			
		||||
			function logout() {
 | 
			
		||||
				localStorage.removeItem("token");
 | 
			
		||||
				localStorage.removeItem("user");
 | 
			
		||||
				window.location.href = "/login";
 | 
			
		||||
			}
 | 
			
		||||
		</script>
 | 
			
		||||
 | 
			
		||||
		<script>
 | 
			
		||||
			// Get param pid, version from URL
 | 
			
		||||
			const params = new URLSearchParams(window.location.search);
 | 
			
		||||
			const pidParam = params.get("pid");
 | 
			
		||||
			const versionParam = params.get("version");
 | 
			
		||||
 | 
			
		||||
			const pidSearchInput = document.getElementById("pidSearch");
 | 
			
		||||
			const pidList = document.getElementById("pidList");
 | 
			
		||||
			const versionList = document.getElementById("versionList");
 | 
			
		||||
			const pidListQuick = document.getElementById("pidListQuick");
 | 
			
		||||
			const logSections = document.getElementById("logSections");
 | 
			
		||||
			const commands = ["inventory", "version", "license", "logging"];
 | 
			
		||||
 | 
			
		||||
			const logsCache = {}; // Store logs and current index for each command
 | 
			
		||||
 | 
			
		||||
			async function loadPIDs(pidParam, versionParam) {
 | 
			
		||||
				const res = await fetch("/api/pids");
 | 
			
		||||
				const pids = await res.json();
 | 
			
		||||
				document.getElementById("pidCount").textContent = pids.length;
 | 
			
		||||
				pidListQuick.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
				pids.forEach((pid) => {
 | 
			
		||||
					const btn = document.createElement("button");
 | 
			
		||||
					btn.className = "pid-button";
 | 
			
		||||
					btn.textContent = pid;
 | 
			
		||||
					btn.onclick = () => selectPID(pid);
 | 
			
		||||
					pidListQuick.appendChild(btn);
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				if (pidParam) {
 | 
			
		||||
					selectPID(pidParam, versionParam);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			async function selectPID(pid, versionParam) {
 | 
			
		||||
				document
 | 
			
		||||
					.querySelectorAll(".pid-button")
 | 
			
		||||
					.forEach((b) => b.classList.remove("selected"));
 | 
			
		||||
				const target = [
 | 
			
		||||
					...document.querySelectorAll(".pid-button"),
 | 
			
		||||
				].find((b) => b.textContent === pid);
 | 
			
		||||
				if (target) target.classList.add("selected");
 | 
			
		||||
 | 
			
		||||
				const res = await fetch(
 | 
			
		||||
					`/api/pid/${encodeURIComponent(pid)}/versions`,
 | 
			
		||||
				);
 | 
			
		||||
				const versions = await res.json();
 | 
			
		||||
 | 
			
		||||
				pidList.innerHTML = `<option value="${pid}">${pid}</option>`;
 | 
			
		||||
				versionList.innerHTML = versions
 | 
			
		||||
					.map((v) => {
 | 
			
		||||
						const cmds = Object.entries(v.commands)
 | 
			
		||||
							.filter(([_, count]) => count > 0)
 | 
			
		||||
							.map(([cmd, count]) => `${cmd} (${count})`)
 | 
			
		||||
							.join(", ");
 | 
			
		||||
						return `<option value="${v.version}">${v.version} - ${
 | 
			
		||||
							cmds || "No commands"
 | 
			
		||||
						}</option>`;
 | 
			
		||||
					})
 | 
			
		||||
					.join("");
 | 
			
		||||
 | 
			
		||||
				// Set param pid, version to URL
 | 
			
		||||
				const currentParams = new URLSearchParams(
 | 
			
		||||
					window.location.search,
 | 
			
		||||
				);
 | 
			
		||||
				currentParams.set("pid", pid);
 | 
			
		||||
 | 
			
		||||
				// Có param version thì chọn
 | 
			
		||||
				if (versionParam) {
 | 
			
		||||
					versionList.value = versionParam;
 | 
			
		||||
					versionList.dispatchEvent(new Event("change"));
 | 
			
		||||
					currentParams.set("version", versionParam);
 | 
			
		||||
				} else {
 | 
			
		||||
					// ✅ Default chọn version đầu tiên
 | 
			
		||||
					if (versions.length > 0) {
 | 
			
		||||
						versionList.value = versions[0].version;
 | 
			
		||||
						versionList.dispatchEvent(new Event("change"));
 | 
			
		||||
						currentParams.set("version", versions[0].version);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				history.replaceState(null, "", `?${currentParams.toString()}`);
 | 
			
		||||
 | 
			
		||||
				await loadErrorTable(pid);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			pidSearchInput.addEventListener("input", async () => {
 | 
			
		||||
				const keyword = pidSearchInput.value.toLowerCase().trim();
 | 
			
		||||
				const res = await fetch("/api/pids");
 | 
			
		||||
				const pids = await res.json();
 | 
			
		||||
				const filtered = pids.filter((pid) =>
 | 
			
		||||
					pid.toLowerCase().includes(keyword),
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
				pidListQuick.innerHTML = "";
 | 
			
		||||
				filtered.forEach((pid) => {
 | 
			
		||||
					const btn = document.createElement("button");
 | 
			
		||||
					btn.className = "pid-button";
 | 
			
		||||
					btn.textContent = pid;
 | 
			
		||||
					btn.onclick = () => selectPID(pid);
 | 
			
		||||
					pidListQuick.appendChild(btn);
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				pidList.innerHTML = filtered
 | 
			
		||||
					.map((pid) => `<option value="${pid}">${pid}</option>`)
 | 
			
		||||
					.join("");
 | 
			
		||||
				if (filtered.length > 0) {
 | 
			
		||||
					pidList.value = filtered[0];
 | 
			
		||||
					selectPID(filtered[0]);
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			pidList.addEventListener("change", () => {
 | 
			
		||||
				const pid = pidList.value;
 | 
			
		||||
				if (pid) selectPID(pid);
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			versionList.addEventListener("change", () => {
 | 
			
		||||
				const pid = pidList.value;
 | 
			
		||||
				const version = versionList.value;
 | 
			
		||||
				if (pid && version) {
 | 
			
		||||
					// Set param pid, version to URL
 | 
			
		||||
					const currentParams = new URLSearchParams(
 | 
			
		||||
						window.location.search,
 | 
			
		||||
					);
 | 
			
		||||
					currentParams.set("pid", pid);
 | 
			
		||||
					currentParams.set("version", version);
 | 
			
		||||
					history.replaceState(
 | 
			
		||||
						null,
 | 
			
		||||
						"",
 | 
			
		||||
						`?${currentParams.toString()}`,
 | 
			
		||||
					);
 | 
			
		||||
 | 
			
		||||
					loadLogs(pid, version);
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			async function loadLogs(pid, version) {
 | 
			
		||||
				logSections.innerHTML = "";
 | 
			
		||||
				logsCache[`${pid}_${version}`] = {};
 | 
			
		||||
 | 
			
		||||
				for (const cmd of commands) {
 | 
			
		||||
					const res = await fetch(
 | 
			
		||||
						`/api/device/${encodeURIComponent(
 | 
			
		||||
							pid,
 | 
			
		||||
						)}/${encodeURIComponent(version)}/${cmd}`,
 | 
			
		||||
					);
 | 
			
		||||
					const data = await res.json();
 | 
			
		||||
					if (!data || data.length === 0) continue;
 | 
			
		||||
 | 
			
		||||
					logsCache[`${pid}_${version}`][cmd] = {
 | 
			
		||||
						logs: data,
 | 
			
		||||
						index: 0,
 | 
			
		||||
					};
 | 
			
		||||
 | 
			
		||||
					const section = document.createElement("div");
 | 
			
		||||
					section.className = "command-section";
 | 
			
		||||
					section.id = `section-${cmd}`;
 | 
			
		||||
					section.innerHTML = `
 | 
			
		||||
            <h3>
 | 
			
		||||
                ${"show " + cmd} (${data.length})
 | 
			
		||||
                <div>
 | 
			
		||||
                <button class="next-btn" id="btn-prev-${cmd}">Previous</button>
 | 
			
		||||
                <span id="page-info-${cmd}">Output 1/${data.length}</span>
 | 
			
		||||
                <button class="next-btn" id="btn-next-${cmd}">Next</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </h3>
 | 
			
		||||
            <div class="log-block" id="log-${cmd}">
 | 
			
		||||
				<button type="button" class="danger-btn trash-btn" title="Add to delete list">🗑</button>
 | 
			
		||||
                <b>${data[0].filename}</b>\n${data[0].output}
 | 
			
		||||
            </div>
 | 
			
		||||
            `;
 | 
			
		||||
 | 
			
		||||
					logSections.appendChild(section);
 | 
			
		||||
 | 
			
		||||
					// Navigation logic
 | 
			
		||||
					const updateDisplay = () => {
 | 
			
		||||
						const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
						const curr = info.logs[info.index];
 | 
			
		||||
 | 
			
		||||
						const logBlock = document.getElementById(`log-${cmd}`);
 | 
			
		||||
						logBlock.innerHTML = `
 | 
			
		||||
							<button type="button" class="danger-btn trash-btn" title="Add to delete list">🗑</button>
 | 
			
		||||
							<b>${curr.filename}</b>\n${curr.output}
 | 
			
		||||
						`;
 | 
			
		||||
 | 
			
		||||
						document.getElementById(
 | 
			
		||||
							`page-info-${cmd}`,
 | 
			
		||||
						).textContent = `Output ${info.index + 1}/${
 | 
			
		||||
							info.logs.length
 | 
			
		||||
						}`;
 | 
			
		||||
						document.getElementById(`btn-prev-${cmd}`).disabled =
 | 
			
		||||
							info.index === 0;
 | 
			
		||||
					};
 | 
			
		||||
 | 
			
		||||
					document
 | 
			
		||||
						.getElementById(`btn-next-${cmd}`)
 | 
			
		||||
						.addEventListener("click", () => {
 | 
			
		||||
							const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
							info.index = (info.index + 1) % info.logs.length;
 | 
			
		||||
							updateDisplay();
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
					document
 | 
			
		||||
						.getElementById(`btn-prev-${cmd}`)
 | 
			
		||||
						.addEventListener("click", () => {
 | 
			
		||||
							const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
							info.index =
 | 
			
		||||
								(info.index - 1 + info.logs.length) %
 | 
			
		||||
								info.logs.length;
 | 
			
		||||
							updateDisplay();
 | 
			
		||||
						});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			loadPIDs(pidParam, versionParam);
 | 
			
		||||
 | 
			
		||||
			const copyBtn = document.getElementById("copyBtn");
 | 
			
		||||
			const downloadBtn = document.getElementById("downloadBtn");
 | 
			
		||||
 | 
			
		||||
			function getExportText() {
 | 
			
		||||
				const pid = pidList.value || "";
 | 
			
		||||
				const version = versionList.value || "";
 | 
			
		||||
				const inventory = getCommandOutput("inventory");
 | 
			
		||||
				const versionOut = getCommandOutput("version");
 | 
			
		||||
				const logging = getCommandOutput("logging");
 | 
			
		||||
				const license = getCommandOutput("license");
 | 
			
		||||
 | 
			
		||||
				return `PID: ${pid}
 | 
			
		||||
Version: ${version}
 | 
			
		||||
 | 
			
		||||
${inventory}
 | 
			
		||||
 | 
			
		||||
${versionOut}
 | 
			
		||||
 | 
			
		||||
${logging}
 | 
			
		||||
 | 
			
		||||
${license}`.trim();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Assume you store outputs in memory or fetch based on UI state
 | 
			
		||||
			function getCommandOutput(command) {
 | 
			
		||||
				const pid = pidList.value;
 | 
			
		||||
				const version = versionList.value;
 | 
			
		||||
				const key = `${pid}_${version}`;
 | 
			
		||||
 | 
			
		||||
				if (
 | 
			
		||||
					logsCache[key] &&
 | 
			
		||||
					logsCache[key][command] &&
 | 
			
		||||
					logsCache[key][command].logs.length > 0
 | 
			
		||||
				) {
 | 
			
		||||
					const { logs, index } = logsCache[key][command];
 | 
			
		||||
					return logs[index].output || "";
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				return "No data";
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			copyBtn.addEventListener("click", async () => {
 | 
			
		||||
				try {
 | 
			
		||||
					await navigator.clipboard.writeText(getExportText());
 | 
			
		||||
					alert("Copied to clipboard!");
 | 
			
		||||
				} catch (err) {
 | 
			
		||||
					alert("Failed to copy.");
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			downloadBtn.addEventListener("click", () => {
 | 
			
		||||
				const content = getExportText();
 | 
			
		||||
				const blob = new Blob([content], { type: "text/plain" });
 | 
			
		||||
				const a = document.createElement("a");
 | 
			
		||||
				a.href = URL.createObjectURL(blob);
 | 
			
		||||
				a.download = `log-${pidList.value || "unknown"}.txt`;
 | 
			
		||||
				a.click();
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			async function loadErrorTable(pid) {
 | 
			
		||||
				const res = await fetch(
 | 
			
		||||
					`/api/errors/${encodeURIComponent(pid)}`,
 | 
			
		||||
				);
 | 
			
		||||
				const data = await res.json();
 | 
			
		||||
 | 
			
		||||
				const tbody = document.getElementById("errorTableBody");
 | 
			
		||||
				tbody.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
				if (data.length === 0) {
 | 
			
		||||
					tbody.innerHTML = `<tr><td colspan="5">✅ No known issues for this PID</td></tr>`;
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				for (const err of data) {
 | 
			
		||||
					const tr = document.createElement("tr");
 | 
			
		||||
					tr.innerHTML = `
 | 
			
		||||
      <td><code>${err.highlighted_message}</code></td>
 | 
			
		||||
      <td><code>${err.regex_pattern}</code></td>
 | 
			
		||||
      <td><code>${err.description}</code></td>
 | 
			
		||||
      `;
 | 
			
		||||
					tbody.appendChild(tr);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		</script>
 | 
			
		||||
 | 
			
		||||
		<script>
 | 
			
		||||
			const STORAGE_KEY = "logDeleteList";
 | 
			
		||||
			const logConfirmDeleteBtn = document.getElementById(
 | 
			
		||||
				"logConfirmDeleteBtn",
 | 
			
		||||
			);
 | 
			
		||||
			const logDeleteModal = document.getElementById("logDeleteModal");
 | 
			
		||||
			const logDeleteListContainer = document.getElementById(
 | 
			
		||||
				"logDeleteListContainer",
 | 
			
		||||
			);
 | 
			
		||||
			const logCloseModalBtn =
 | 
			
		||||
				document.getElementById("logCloseModalBtn");
 | 
			
		||||
			const logSubmitDeleteBtn =
 | 
			
		||||
				document.getElementById("logSubmitDeleteBtn");
 | 
			
		||||
 | 
			
		||||
			function getDeleteList() {
 | 
			
		||||
				return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			function saveDeleteList(list) {
 | 
			
		||||
				localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
 | 
			
		||||
				updateDeleteBtn();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			function updateDeleteBtn() {
 | 
			
		||||
				const deleteList = getDeleteList();
 | 
			
		||||
				const badge = document.getElementById("logDeleteBadge");
 | 
			
		||||
				badge.textContent = deleteList.length;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			document.addEventListener("click", (e) => {
 | 
			
		||||
				if (e.target.classList.contains("trash-btn")) {
 | 
			
		||||
					const section = e.target.closest(".command-section");
 | 
			
		||||
					const pid = pidList.value;
 | 
			
		||||
					const version = versionList.value;
 | 
			
		||||
					const command = section.id.replace("section-", "");
 | 
			
		||||
					const index = logsCache[`${pid}_${version}`][command].index;
 | 
			
		||||
					const log =
 | 
			
		||||
						logsCache[`${pid}_${version}`][command].logs[index];
 | 
			
		||||
 | 
			
		||||
					const currentList = getDeleteList();
 | 
			
		||||
					if (!currentList.some((item) => item.id === log.id)) {
 | 
			
		||||
						currentList.push({
 | 
			
		||||
							id: log.id,
 | 
			
		||||
							pid,
 | 
			
		||||
							version,
 | 
			
		||||
							command,
 | 
			
		||||
						});
 | 
			
		||||
						saveDeleteList(currentList);
 | 
			
		||||
 | 
			
		||||
						showToast("Added to delete list");
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			function renderDeleteModal() {
 | 
			
		||||
				const deleteList = getDeleteList();
 | 
			
		||||
				logDeleteListContainer.innerHTML = "";
 | 
			
		||||
				deleteList.forEach((item, i) => {
 | 
			
		||||
					const tr = document.createElement("tr");
 | 
			
		||||
					tr.innerHTML = `
 | 
			
		||||
				<td>${item.id}</td>
 | 
			
		||||
				<td>${item.pid}</td>
 | 
			
		||||
				<td>${item.version}</td>
 | 
			
		||||
				<td>${item.command}</td>
 | 
			
		||||
				<td style="text-align: center;"><button type="button" class="danger-btn remove-item-btn" data-index="${i}" title="Remove">🗑</button></td>
 | 
			
		||||
			`;
 | 
			
		||||
					logDeleteListContainer.appendChild(tr);
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			logDeleteListContainer.addEventListener("click", (e) => {
 | 
			
		||||
				if (e.target.classList.contains("remove-item-btn")) {
 | 
			
		||||
					const index = parseInt(e.target.getAttribute("data-index"));
 | 
			
		||||
					const list = getDeleteList();
 | 
			
		||||
					list.splice(index, 1);
 | 
			
		||||
					saveDeleteList(list);
 | 
			
		||||
					renderDeleteModal();
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Open delete modal
 | 
			
		||||
			logConfirmDeleteBtn.addEventListener("click", () => {
 | 
			
		||||
				renderDeleteModal();
 | 
			
		||||
				logDeleteModal.classList.remove("hidden");
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Open close modal
 | 
			
		||||
			logCloseModalBtn.addEventListener("click", () => {
 | 
			
		||||
				logDeleteModal.classList.add("hidden");
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Submit delete
 | 
			
		||||
			logSubmitDeleteBtn.addEventListener("click", async () => {
 | 
			
		||||
				const deleteList = getDeleteList();
 | 
			
		||||
				if (deleteList.length === 0) {
 | 
			
		||||
					alert("Please select at least one item to delete.");
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if (
 | 
			
		||||
					confirm(
 | 
			
		||||
						`Are you sure you want to delete ${deleteList.length} item(s)?`,
 | 
			
		||||
					)
 | 
			
		||||
				) {
 | 
			
		||||
					const deletedItems = deleteList.map((item) => {
 | 
			
		||||
						return {
 | 
			
		||||
							id: item.id,
 | 
			
		||||
							command: item.command,
 | 
			
		||||
						};
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					const token = localStorage.getItem("token");
 | 
			
		||||
 | 
			
		||||
					await fetch("/api/delete-logs", {
 | 
			
		||||
						method: "POST",
 | 
			
		||||
						headers: {
 | 
			
		||||
							"Content-Type": "application/json",
 | 
			
		||||
							Authorization: `Bearer ${token}`,
 | 
			
		||||
						},
 | 
			
		||||
						body: JSON.stringify({ items: deletedItems }),
 | 
			
		||||
					})
 | 
			
		||||
						.then((res) => res.json())
 | 
			
		||||
						.then((data) => {
 | 
			
		||||
							alert("Deleted successfully!");
 | 
			
		||||
							localStorage.removeItem(STORAGE_KEY);
 | 
			
		||||
							location.reload();
 | 
			
		||||
						})
 | 
			
		||||
						.catch((err) => {
 | 
			
		||||
							console.error(err);
 | 
			
		||||
							alert("Delete failed.");
 | 
			
		||||
						});
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			updateDeleteBtn();
 | 
			
		||||
 | 
			
		||||
			function showToast(message = "Announce something") {
 | 
			
		||||
				const toast = document.getElementById("logToast");
 | 
			
		||||
				toast.textContent = message;
 | 
			
		||||
				toast.classList.add("show");
 | 
			
		||||
				toast.classList.remove("hidden");
 | 
			
		||||
 | 
			
		||||
				setTimeout(() => {
 | 
			
		||||
					toast.classList.remove("show");
 | 
			
		||||
					toast.classList.add("hidden");
 | 
			
		||||
				}, 2000);
 | 
			
		||||
			}
 | 
			
		||||
		</script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 2.1 MiB  | 
| 
						 | 
				
			
			@ -1,522 +0,0 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <title>PID Log Viewer</title>
 | 
			
		||||
    <style>
 | 
			
		||||
      * {
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :root {
 | 
			
		||||
        --primary: #1e88e5;
 | 
			
		||||
        --primary-light: #e3f2fd;
 | 
			
		||||
        --gray-light: #f8f9fa;
 | 
			
		||||
        --gray-border: #e0e0e0;
 | 
			
		||||
        --text-color: #333;
 | 
			
		||||
        --bg-color: #ffffff;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      body {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        font-family: "Segoe UI", Roboto, sans-serif;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        height: 100vh;
 | 
			
		||||
        background-color: var(--bg-color);
 | 
			
		||||
        color: var(--text-color);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .sidebar {
 | 
			
		||||
        width: 280px;
 | 
			
		||||
        background-color: var(--gray-light);
 | 
			
		||||
        border-right: 1px solid var(--gray-border);
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        padding: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .sidebar-header {
 | 
			
		||||
        padding: 16px;
 | 
			
		||||
        background: white;
 | 
			
		||||
        border-bottom: 1px solid #ddd;
 | 
			
		||||
        flex-shrink: 0;
 | 
			
		||||
        z-index: 1;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .sidebar-list {
 | 
			
		||||
        flex: 1;
 | 
			
		||||
        overflow-y: auto;
 | 
			
		||||
        padding: 12px 16px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .pid-button {
 | 
			
		||||
        display: block;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        padding: 8px 12px;
 | 
			
		||||
        margin: 4px 0;
 | 
			
		||||
        text-align: left;
 | 
			
		||||
        border: 1px solid transparent;
 | 
			
		||||
        background: #fff;
 | 
			
		||||
        border-radius: 8px;
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
        transition: all 0.2s ease;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .pid-button:hover {
 | 
			
		||||
        background-color: var(--primary-light);
 | 
			
		||||
        border-color: var(--primary);
 | 
			
		||||
        color: var(--primary);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .pid-button.selected {
 | 
			
		||||
        background-color: var(--primary);
 | 
			
		||||
        color: white;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .content {
 | 
			
		||||
        flex: 1;
 | 
			
		||||
        padding: 24px;
 | 
			
		||||
        overflow-y: auto;
 | 
			
		||||
        background-color: #fefefe;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      select,
 | 
			
		||||
      input[type="text"] {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        padding: 6px;
 | 
			
		||||
        /* margin-bottom: 16px; */
 | 
			
		||||
        border: 1px solid #ccc;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        outline: none;
 | 
			
		||||
        transition: border-color 0.2s;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      select:focus,
 | 
			
		||||
      input[type="text"]:focus {
 | 
			
		||||
        border-color: var(--primary);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .log-block {
 | 
			
		||||
        background: #fafafa;
 | 
			
		||||
        padding: 12px;
 | 
			
		||||
        margin-bottom: 12px;
 | 
			
		||||
        border-left: 4px solid var(--primary);
 | 
			
		||||
        font-family: monospace;
 | 
			
		||||
        white-space: pre-wrap;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .command-section {
 | 
			
		||||
        margin-top: 32px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .command-section h3 {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: space-between;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        margin-bottom: 12px;
 | 
			
		||||
        font-size: 17px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .command-section h3 > div {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        gap: 6px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .next-btn {
 | 
			
		||||
        font-size: 13px;
 | 
			
		||||
        background: var(--primary);
 | 
			
		||||
        color: white;
 | 
			
		||||
        border: none;
 | 
			
		||||
        padding: 5px 10px;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
        transition: background 0.2s ease;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .next-btn:hover:not(:disabled) {
 | 
			
		||||
        background-color: #1565c0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .next-btn:disabled {
 | 
			
		||||
        background-color: #ccc;
 | 
			
		||||
        cursor: not-allowed;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      table {
 | 
			
		||||
        border-collapse: collapse;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        background: white;
 | 
			
		||||
        border-radius: 8px;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
 | 
			
		||||
        margin-bottom: 20px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      table thead {
 | 
			
		||||
        background: var(--primary);
 | 
			
		||||
        color: white;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      table th,
 | 
			
		||||
      table td {
 | 
			
		||||
        padding: 12px 14px;
 | 
			
		||||
        text-align: left;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        border-bottom: 1px solid #eee;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      table tbody tr:hover {
 | 
			
		||||
        background-color: #f1f8ff;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      code {
 | 
			
		||||
        background: #f4f4f4;
 | 
			
		||||
        padding: 2px 6px;
 | 
			
		||||
        border-radius: 4px;
 | 
			
		||||
        font-size: 13px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      mark {
 | 
			
		||||
        background-color: #ffdb38;
 | 
			
		||||
        padding: 0 2px;
 | 
			
		||||
        border-radius: 2px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      #selectAndVersionDiv {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: space-between;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      #selectedPIDDiv {
 | 
			
		||||
        width: 23%;
 | 
			
		||||
      }
 | 
			
		||||
      #selectedVersionDiv {
 | 
			
		||||
        width: 53%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .action-btn {
 | 
			
		||||
        background-color: #8d7df3;
 | 
			
		||||
        color: white;
 | 
			
		||||
        border: none;
 | 
			
		||||
        padding: 8px 14px;
 | 
			
		||||
        margin: 5px;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        font-weight: 500;
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
        transition: background-color 0.2s ease, box-shadow 0.2s ease;
 | 
			
		||||
        box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .action-btn:hover {
 | 
			
		||||
        background-color: #0056b3;
 | 
			
		||||
        box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .action-btn:active {
 | 
			
		||||
        background-color: #004a99;
 | 
			
		||||
        transform: scale(0.98);
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div class="sidebar">
 | 
			
		||||
      <div class="sidebar-header">
 | 
			
		||||
        <h3>PIDs (<span id="pidCount">0</span>)</h3>
 | 
			
		||||
        <input type="text" id="pidSearch" placeholder="🔍 Search PID..." />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="sidebar-list" id="pidListQuick"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="content">
 | 
			
		||||
      <!-- <h3>📊 PID Log Viewer</h3> -->
 | 
			
		||||
      <div id="selectAndVersionDiv">
 | 
			
		||||
        <div id="selectedPIDDiv">
 | 
			
		||||
          <label for="pidList">PID</label>
 | 
			
		||||
          <select id="pidList" disabled></select>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="selectedVersionDiv">
 | 
			
		||||
          <label for="versionList">Select Version</label>
 | 
			
		||||
          <select id="versionList"></select>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style="width: 23%; align-items: center">
 | 
			
		||||
          <button id="copyBtn" class="action-btn">📋 Copy</button>
 | 
			
		||||
          <button id="downloadBtn" class="action-btn">⬇️ Download</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div id="errorTableContainer" class="command-section">
 | 
			
		||||
        <h3>⚠️ Error History for PID</h3>
 | 
			
		||||
        <table border="1" cellspacing="0" cellpadding="5" width="100%">
 | 
			
		||||
          <thead>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <th style="text-align: left">Logging</th>
 | 
			
		||||
              <th style="text-align: left">Pattern</th>
 | 
			
		||||
              <th style="text-align: left">Description</th>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </thead>
 | 
			
		||||
          <tbody id="errorTableBody"></tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="logSections"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <script>
 | 
			
		||||
      const pidSearchInput = document.getElementById("pidSearch");
 | 
			
		||||
      const pidList = document.getElementById("pidList");
 | 
			
		||||
      const versionList = document.getElementById("versionList");
 | 
			
		||||
      const pidListQuick = document.getElementById("pidListQuick");
 | 
			
		||||
      const logSections = document.getElementById("logSections");
 | 
			
		||||
      const commands = ["inventory", "version", "license", "logging"];
 | 
			
		||||
 | 
			
		||||
      const logsCache = {}; // Store logs and current index for each command
 | 
			
		||||
 | 
			
		||||
      async function loadPIDs() {
 | 
			
		||||
        const res = await fetch("/api/pids");
 | 
			
		||||
        const pids = await res.json();
 | 
			
		||||
        document.getElementById("pidCount").textContent = pids.length;
 | 
			
		||||
        pidListQuick.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
        pids.forEach((pid) => {
 | 
			
		||||
          const btn = document.createElement("button");
 | 
			
		||||
          btn.className = "pid-button";
 | 
			
		||||
          btn.textContent = pid;
 | 
			
		||||
          btn.onclick = () => selectPID(pid);
 | 
			
		||||
          pidListQuick.appendChild(btn);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      async function selectPID(pid) {
 | 
			
		||||
        document
 | 
			
		||||
          .querySelectorAll(".pid-button")
 | 
			
		||||
          .forEach((b) => b.classList.remove("selected"));
 | 
			
		||||
        const target = [...document.querySelectorAll(".pid-button")].find(
 | 
			
		||||
          (b) => b.textContent === pid
 | 
			
		||||
        );
 | 
			
		||||
        if (target) target.classList.add("selected");
 | 
			
		||||
 | 
			
		||||
        const res = await fetch(`/api/pid/${encodeURIComponent(pid)}/versions`);
 | 
			
		||||
        const versions = await res.json();
 | 
			
		||||
 | 
			
		||||
        pidList.innerHTML = `<option value="${pid}">${pid}</option>`;
 | 
			
		||||
        versionList.innerHTML = versions
 | 
			
		||||
          .map((v) => {
 | 
			
		||||
            const cmds = Object.entries(v.commands)
 | 
			
		||||
              .filter(([_, count]) => count > 0)
 | 
			
		||||
              .map(([cmd, count]) => `${cmd} (${count})`)
 | 
			
		||||
              .join(", ");
 | 
			
		||||
            return `<option value="${v.version}">${v.version} - ${
 | 
			
		||||
              cmds || "No commands"
 | 
			
		||||
            }</option>`;
 | 
			
		||||
          })
 | 
			
		||||
          .join("");
 | 
			
		||||
 | 
			
		||||
        // ✅ Default chọn version đầu tiên
 | 
			
		||||
        if (versions.length > 0) {
 | 
			
		||||
          versionList.value = versions[0].version;
 | 
			
		||||
          versionList.dispatchEvent(new Event("change"));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await loadErrorTable(pid);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      pidSearchInput.addEventListener("input", async () => {
 | 
			
		||||
        const keyword = pidSearchInput.value.toLowerCase().trim();
 | 
			
		||||
        const res = await fetch("/api/pids");
 | 
			
		||||
        const pids = await res.json();
 | 
			
		||||
        const filtered = pids.filter((pid) =>
 | 
			
		||||
          pid.toLowerCase().includes(keyword)
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        pidListQuick.innerHTML = "";
 | 
			
		||||
        filtered.forEach((pid) => {
 | 
			
		||||
          const btn = document.createElement("button");
 | 
			
		||||
          btn.className = "pid-button";
 | 
			
		||||
          btn.textContent = pid;
 | 
			
		||||
          btn.onclick = () => selectPID(pid);
 | 
			
		||||
          pidListQuick.appendChild(btn);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        pidList.innerHTML = filtered
 | 
			
		||||
          .map((pid) => `<option value="${pid}">${pid}</option>`)
 | 
			
		||||
          .join("");
 | 
			
		||||
        if (filtered.length > 0) {
 | 
			
		||||
          pidList.value = filtered[0];
 | 
			
		||||
          selectPID(filtered[0]);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      pidList.addEventListener("change", () => {
 | 
			
		||||
        const pid = pidList.value;
 | 
			
		||||
        if (pid) selectPID(pid);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      versionList.addEventListener("change", () => {
 | 
			
		||||
        const pid = pidList.value;
 | 
			
		||||
        const version = versionList.value;
 | 
			
		||||
        if (pid && version) loadLogs(pid, version);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      async function loadLogs(pid, version) {
 | 
			
		||||
        logSections.innerHTML = "";
 | 
			
		||||
        logsCache[`${pid}_${version}`] = {};
 | 
			
		||||
 | 
			
		||||
        for (const cmd of commands) {
 | 
			
		||||
          const res = await fetch(
 | 
			
		||||
            `/api/device/${encodeURIComponent(pid)}/${encodeURIComponent(
 | 
			
		||||
              version
 | 
			
		||||
            )}/${cmd}`
 | 
			
		||||
          );
 | 
			
		||||
          const data = await res.json();
 | 
			
		||||
          if (!data || data.length === 0) continue;
 | 
			
		||||
 | 
			
		||||
          logsCache[`${pid}_${version}`][cmd] = {
 | 
			
		||||
            logs: data,
 | 
			
		||||
            index: 0,
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          const section = document.createElement("div");
 | 
			
		||||
          section.className = "command-section";
 | 
			
		||||
          section.id = `section-${cmd}`;
 | 
			
		||||
          section.innerHTML = `
 | 
			
		||||
            <h3>
 | 
			
		||||
                ${"show " + cmd} (${data.length})
 | 
			
		||||
                <div>
 | 
			
		||||
                <button class="next-btn" id="btn-prev-${cmd}">Previous</button>
 | 
			
		||||
                <span id="page-info-${cmd}">Output 1/${data.length}</span>
 | 
			
		||||
                <button class="next-btn" id="btn-next-${cmd}">Next</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </h3>
 | 
			
		||||
            <div class="log-block" id="log-${cmd}">
 | 
			
		||||
                <b>${data[0].filename}</b>\n${data[0].output}
 | 
			
		||||
            </div>
 | 
			
		||||
            `;
 | 
			
		||||
 | 
			
		||||
          logSections.appendChild(section);
 | 
			
		||||
 | 
			
		||||
          // Navigation logic
 | 
			
		||||
          const updateDisplay = () => {
 | 
			
		||||
            const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
            const curr = info.logs[info.index];
 | 
			
		||||
            document.getElementById(
 | 
			
		||||
              `log-${cmd}`
 | 
			
		||||
            ).innerHTML = `<b>${curr.filename}</b>\n${curr.output}`;
 | 
			
		||||
            document.getElementById(`page-info-${cmd}`).textContent = `Output ${
 | 
			
		||||
              info.index + 1
 | 
			
		||||
            }/${info.logs.length}`;
 | 
			
		||||
            document.getElementById(`btn-prev-${cmd}`).disabled =
 | 
			
		||||
              info.index === 0;
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          document
 | 
			
		||||
            .getElementById(`btn-next-${cmd}`)
 | 
			
		||||
            .addEventListener("click", () => {
 | 
			
		||||
              const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
              info.index = (info.index + 1) % info.logs.length;
 | 
			
		||||
              updateDisplay();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
          document
 | 
			
		||||
            .getElementById(`btn-prev-${cmd}`)
 | 
			
		||||
            .addEventListener("click", () => {
 | 
			
		||||
              const info = logsCache[`${pid}_${version}`][cmd];
 | 
			
		||||
              info.index =
 | 
			
		||||
                (info.index - 1 + info.logs.length) % info.logs.length;
 | 
			
		||||
              updateDisplay();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      loadPIDs();
 | 
			
		||||
 | 
			
		||||
      const copyBtn = document.getElementById("copyBtn");
 | 
			
		||||
      const downloadBtn = document.getElementById("downloadBtn");
 | 
			
		||||
 | 
			
		||||
      function getExportText() {
 | 
			
		||||
        const pid = pidList.value || "";
 | 
			
		||||
        const version = versionList.value || "";
 | 
			
		||||
        const inventory = getCommandOutput("inventory");
 | 
			
		||||
        const versionOut = getCommandOutput("version");
 | 
			
		||||
        const logging = getCommandOutput("logging");
 | 
			
		||||
        const license = getCommandOutput("license");
 | 
			
		||||
 | 
			
		||||
        return `PID: ${pid}
 | 
			
		||||
Version: ${version}
 | 
			
		||||
 | 
			
		||||
${inventory}
 | 
			
		||||
 | 
			
		||||
${versionOut}
 | 
			
		||||
 | 
			
		||||
${logging}
 | 
			
		||||
 | 
			
		||||
${license}`.trim();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Assume you store outputs in memory or fetch based on UI state
 | 
			
		||||
      function getCommandOutput(command) {
 | 
			
		||||
        const pid = pidList.value;
 | 
			
		||||
        const version = versionList.value;
 | 
			
		||||
        const key = `${pid}_${version}`;
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          logsCache[key] &&
 | 
			
		||||
          logsCache[key][command] &&
 | 
			
		||||
          logsCache[key][command].logs.length > 0
 | 
			
		||||
        ) {
 | 
			
		||||
          const { logs, index } = logsCache[key][command];
 | 
			
		||||
          return logs[index].output || "";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return "No data";
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      copyBtn.addEventListener("click", async () => {
 | 
			
		||||
        try {
 | 
			
		||||
          await navigator.clipboard.writeText(getExportText());
 | 
			
		||||
          alert("Copied to clipboard!");
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
          alert("Failed to copy.");
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      downloadBtn.addEventListener("click", () => {
 | 
			
		||||
        const content = getExportText();
 | 
			
		||||
        const blob = new Blob([content], { type: "text/plain" });
 | 
			
		||||
        const a = document.createElement("a");
 | 
			
		||||
        a.href = URL.createObjectURL(blob);
 | 
			
		||||
        a.download = `log-${pidList.value || "unknown"}.txt`;
 | 
			
		||||
        a.click();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      async function loadErrorTable(pid) {
 | 
			
		||||
        const res = await fetch(`/api/errors/${encodeURIComponent(pid)}`);
 | 
			
		||||
        const data = await res.json();
 | 
			
		||||
 | 
			
		||||
        const tbody = document.getElementById("errorTableBody");
 | 
			
		||||
        tbody.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
        if (data.length === 0) {
 | 
			
		||||
          tbody.innerHTML = `<tr><td colspan="5">✅ No known issues for this PID</td></tr>`;
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const err of data) {
 | 
			
		||||
          const tr = document.createElement("tr");
 | 
			
		||||
          tr.innerHTML = `
 | 
			
		||||
      <td><code>${err.highlighted_message}</code></td>
 | 
			
		||||
      <td><code>${err.regex_pattern}</code></td>
 | 
			
		||||
      <td><code>${err.description}</code></td>
 | 
			
		||||
      `;
 | 
			
		||||
          tbody.appendChild(tr);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    </script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,97 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
	<head>
 | 
			
		||||
		<meta charset="UTF-8" />
 | 
			
		||||
		<title>Login</title>
 | 
			
		||||
 | 
			
		||||
		<!-- Link to CSS file -->
 | 
			
		||||
		<link rel="stylesheet" href="/css/style.css" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<section class="login-section">
 | 
			
		||||
			<div class="login-container">
 | 
			
		||||
				<div class="form-wrapper">
 | 
			
		||||
					<div class="form-box">
 | 
			
		||||
						<h1 class="form-title">Login</h1>
 | 
			
		||||
						<form id="loginForm">
 | 
			
		||||
							<div class="form-group">
 | 
			
		||||
								<label for="email">Email</label>
 | 
			
		||||
								<input
 | 
			
		||||
									type="email"
 | 
			
		||||
									id="email"
 | 
			
		||||
									name="email"
 | 
			
		||||
									placeholder="user@gmail.com"
 | 
			
		||||
									required
 | 
			
		||||
								/>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div class="form-group">
 | 
			
		||||
								<label for="password">Password</label>
 | 
			
		||||
								<input
 | 
			
		||||
									type="password"
 | 
			
		||||
									id="password"
 | 
			
		||||
									name="password"
 | 
			
		||||
									placeholder="Your password"
 | 
			
		||||
									required
 | 
			
		||||
								/>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div
 | 
			
		||||
								class="error-msg text-error"
 | 
			
		||||
								style="display: none"
 | 
			
		||||
							></div>
 | 
			
		||||
 | 
			
		||||
							<button type="submit" class="primary-btn">
 | 
			
		||||
								Login
 | 
			
		||||
							</button>
 | 
			
		||||
						</form>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</section>
 | 
			
		||||
 | 
			
		||||
		<script>
 | 
			
		||||
			if (localStorage.getItem("token")) {
 | 
			
		||||
				window.location.href = "/";
 | 
			
		||||
			}
 | 
			
		||||
		</script>
 | 
			
		||||
 | 
			
		||||
		<script>
 | 
			
		||||
			document
 | 
			
		||||
				.getElementById("loginForm")
 | 
			
		||||
				.addEventListener("submit", async (e) => {
 | 
			
		||||
					e.preventDefault();
 | 
			
		||||
 | 
			
		||||
					const email = document.getElementById("email").value;
 | 
			
		||||
					const password = document.getElementById("password").value;
 | 
			
		||||
					const errorMsg = document.querySelector(".error-msg");
 | 
			
		||||
 | 
			
		||||
					try {
 | 
			
		||||
						const res = await fetch("/api/login", {
 | 
			
		||||
							method: "POST",
 | 
			
		||||
							headers: { "Content-Type": "application/json" },
 | 
			
		||||
							body: JSON.stringify({ email, password }),
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
						const data = await res.json();
 | 
			
		||||
 | 
			
		||||
						if (!res.ok) {
 | 
			
		||||
							errorMsg.textContent =
 | 
			
		||||
								data.message || "Login failed";
 | 
			
		||||
							errorMsg.style.display = "block";
 | 
			
		||||
							return;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						localStorage.setItem("token", data.token);
 | 
			
		||||
						localStorage.setItem("user", JSON.stringify(data.user));
 | 
			
		||||
 | 
			
		||||
						window.location.href = "/";
 | 
			
		||||
					} catch (err) {
 | 
			
		||||
						console.error("Login failed:", err);
 | 
			
		||||
						errorMsg.textContent = "Something went wrong.";
 | 
			
		||||
						errorMsg.style.display = "block";
 | 
			
		||||
					}
 | 
			
		||||
				});
 | 
			
		||||
		</script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
const express = require("express");
 | 
			
		||||
const path = require("path");
 | 
			
		||||
const bcrypt = require("bcrypt");
 | 
			
		||||
const { createToken } = require("../utils/jwt");
 | 
			
		||||
 | 
			
		||||
let router = express.Router();
 | 
			
		||||
 | 
			
		||||
module.exports = (app, db) => {
 | 
			
		||||
	router.get("/", (_, res) => {
 | 
			
		||||
		res.sendFile(path.join(__dirname, "../public/dashboard.html"));
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.get("/login", (_, res) => {
 | 
			
		||||
		res.sendFile(path.join(__dirname, "../public/login.html"));
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.post("/api/login", async (req, res) => {
 | 
			
		||||
		const { email, password } = req.body;
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const [rows] = await db.query(
 | 
			
		||||
				"SELECT * FROM users WHERE email = ?",
 | 
			
		||||
				[email],
 | 
			
		||||
			);
 | 
			
		||||
			const user = rows[0];
 | 
			
		||||
			const isMatch = await bcrypt.compare(
 | 
			
		||||
				password,
 | 
			
		||||
				user?.password || "!@#",
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			if (!user || !isMatch) {
 | 
			
		||||
				return res
 | 
			
		||||
					.status(401)
 | 
			
		||||
					.json({ message: "Invalid email or password" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const token = createToken({
 | 
			
		||||
				id: user.id,
 | 
			
		||||
				email: user.email,
 | 
			
		||||
				name: user.name,
 | 
			
		||||
			});
 | 
			
		||||
			return res
 | 
			
		||||
				.status(200)
 | 
			
		||||
				.json({ token, user: { name: user.name, email: user.email } });
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			console.error("Login error:", err);
 | 
			
		||||
			return res.status(500).json({ message: "Internal Server Error" });
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	app.use("/", router);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										410
									
								
								server.js
								
								
								
								
							
							
						
						
									
										410
									
								
								server.js
								
								
								
								
							| 
						 | 
				
			
			@ -4,43 +4,74 @@ const bodyParser = require("body-parser");
 | 
			
		|||
const dotenv = require("dotenv");
 | 
			
		||||
dotenv.config();
 | 
			
		||||
 | 
			
		||||
const stringSimilarity = require('string-similarity');
 | 
			
		||||
const stringSimilarity = require("string-similarity");
 | 
			
		||||
const bcrypt = require("bcrypt");
 | 
			
		||||
const inititalWebRoute = require("./route/web");
 | 
			
		||||
const { authenticateToken } = require("./middleware/auth");
 | 
			
		||||
 | 
			
		||||
// Nhận mảng message đầu vào, trả về mảng message đã loại bỏ trùng lặp
 | 
			
		||||
function deduplicateErrors(errors, threshold = 0.3) {
 | 
			
		||||
  const unique = [];
 | 
			
		||||
	const unique = [];
 | 
			
		||||
 | 
			
		||||
  for (const err of errors) {
 | 
			
		||||
    const isDuplicate = unique.some(u => {
 | 
			
		||||
      const similarity = stringSimilarity.compareTwoStrings(err.error_message, u.error_message);
 | 
			
		||||
      return similarity >= threshold;
 | 
			
		||||
    });
 | 
			
		||||
	for (const err of errors) {
 | 
			
		||||
		const isDuplicate = unique.some((u) => {
 | 
			
		||||
			const similarity = stringSimilarity.compareTwoStrings(
 | 
			
		||||
				err.error_message,
 | 
			
		||||
				u.error_message,
 | 
			
		||||
			);
 | 
			
		||||
			return similarity >= threshold;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    if (!isDuplicate) {
 | 
			
		||||
      unique.push(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
		if (!isDuplicate) {
 | 
			
		||||
			unique.push(err);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
  return unique;
 | 
			
		||||
	return unique;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const app = express();
 | 
			
		||||
const PORT = 4000;
 | 
			
		||||
 | 
			
		||||
app.use(bodyParser.json());
 | 
			
		||||
app.use(express.static("public"));
 | 
			
		||||
app.use(express.urlencoded({ extended: true }));
 | 
			
		||||
 | 
			
		||||
const db = mysql.createPool({
 | 
			
		||||
  host: "localhost",
 | 
			
		||||
  user: "root",
 | 
			
		||||
  password: "",
 | 
			
		||||
  database: "log_analysis",
 | 
			
		||||
	host: "localhost",
 | 
			
		||||
	user: "root",
 | 
			
		||||
	password: "",
 | 
			
		||||
	database: "log_analysis",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function columnExists(table, column) {
 | 
			
		||||
	const [rows] = await db.query(
 | 
			
		||||
		`SELECT COUNT(*) AS count
 | 
			
		||||
		 FROM INFORMATION_SCHEMA.COLUMNS
 | 
			
		||||
		 WHERE TABLE_NAME = ? AND COLUMN_NAME = ? AND TABLE_SCHEMA = DATABASE()`,
 | 
			
		||||
		[table, column],
 | 
			
		||||
	);
 | 
			
		||||
	return rows[0].count > 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const checkAndInsertAdminUser = async () => {
 | 
			
		||||
	const [rows] = await db.query(`SELECT id FROM users WHERE email = ?`, [
 | 
			
		||||
		"admin@apactech.io",
 | 
			
		||||
	]);
 | 
			
		||||
 | 
			
		||||
	if (rows.length === 0) {
 | 
			
		||||
		const hashedPassword = await bcrypt.hash("admin0312", 10);
 | 
			
		||||
 | 
			
		||||
		await db.query(
 | 
			
		||||
			`INSERT INTO users (name, email, password) VALUES (?, ?, ?)`,
 | 
			
		||||
			["Admin", "admin@apactech.io", hashedPassword],
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// === Tạo bảng
 | 
			
		||||
(async () => {
 | 
			
		||||
  await db.query(`
 | 
			
		||||
	await db.query(`
 | 
			
		||||
    CREATE TABLE IF NOT EXISTS devices (
 | 
			
		||||
      id INT AUTO_INCREMENT PRIMARY KEY,
 | 
			
		||||
      pid VARCHAR(100) NOT NULL,
 | 
			
		||||
| 
						 | 
				
			
			@ -53,8 +84,20 @@ const db = mysql.createPool({
 | 
			
		|||
    )
 | 
			
		||||
  `);
 | 
			
		||||
 | 
			
		||||
  const createOutputTable = async (command) => {
 | 
			
		||||
    await db.query(`
 | 
			
		||||
	if (!(await columnExists("devices", "updated_at"))) {
 | 
			
		||||
		await db.query(
 | 
			
		||||
			`ALTER TABLE devices ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`,
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!(await columnExists("devices", "updated_by"))) {
 | 
			
		||||
		await db.query(
 | 
			
		||||
			`ALTER TABLE devices ADD COLUMN updated_by VARCHAR(100) DEFAULT NULL`,
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const createOutputTable = async (command) => {
 | 
			
		||||
		await db.query(`
 | 
			
		||||
      CREATE TABLE IF NOT EXISTS ${command}_outputs (
 | 
			
		||||
        id INT AUTO_INCREMENT PRIMARY KEY,
 | 
			
		||||
        device_id INT NOT NULL,
 | 
			
		||||
| 
						 | 
				
			
			@ -65,148 +108,233 @@ const db = mysql.createPool({
 | 
			
		|||
        FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
 | 
			
		||||
      )
 | 
			
		||||
    `);
 | 
			
		||||
  };
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
  await createOutputTable("inventory");
 | 
			
		||||
  await createOutputTable("version");
 | 
			
		||||
  await createOutputTable("license");
 | 
			
		||||
  await createOutputTable("logging");
 | 
			
		||||
	await createOutputTable("inventory");
 | 
			
		||||
	await createOutputTable("version");
 | 
			
		||||
	await createOutputTable("license");
 | 
			
		||||
	await createOutputTable("logging");
 | 
			
		||||
 | 
			
		||||
	await db.query(`
 | 
			
		||||
    CREATE TABLE IF NOT EXISTS users (
 | 
			
		||||
		id INT AUTO_INCREMENT PRIMARY KEY,
 | 
			
		||||
		name VARCHAR(100) NOT NULL,
 | 
			
		||||
		email VARCHAR(100) NOT NULL UNIQUE,
 | 
			
		||||
		password VARCHAR(255) NOT NULL,
 | 
			
		||||
		confirm_count INT DEFAULT 0,
 | 
			
		||||
		created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
		updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
 | 
			
		||||
		)
 | 
			
		||||
	`);
 | 
			
		||||
 | 
			
		||||
	await checkAndInsertAdminUser();
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
// === APIs ===
 | 
			
		||||
 | 
			
		||||
app.get("/api/pids", async (req, res) => {
 | 
			
		||||
  const [rows] = await db.query("SELECT DISTINCT pid FROM devices ORDER BY pid");
 | 
			
		||||
  res.json(rows.map((r) => r.pid));
 | 
			
		||||
	const [rows] = await db.query(
 | 
			
		||||
		"SELECT DISTINCT pid FROM devices ORDER BY pid",
 | 
			
		||||
	);
 | 
			
		||||
	res.json(rows.map((r) => r.pid));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/pid/:pid/versions", async (req, res) => {
 | 
			
		||||
    const { pid } = req.params;
 | 
			
		||||
  
 | 
			
		||||
    // Lấy danh sách các version tương ứng với PID
 | 
			
		||||
    const [devices] = await db.query(
 | 
			
		||||
      "SELECT id, version FROM devices WHERE pid = ?",
 | 
			
		||||
      [pid]
 | 
			
		||||
    );
 | 
			
		||||
  
 | 
			
		||||
    const results = [];
 | 
			
		||||
  
 | 
			
		||||
    for (const device of devices) {
 | 
			
		||||
      const deviceId = device.id;
 | 
			
		||||
      const version = device.version;
 | 
			
		||||
  
 | 
			
		||||
      const [[inv]] = await db.query("SELECT COUNT(*) AS c FROM inventory_outputs WHERE device_id = ?", [deviceId]);
 | 
			
		||||
      const [[ver]] = await db.query("SELECT COUNT(*) AS c FROM version_outputs WHERE device_id = ?", [deviceId]);
 | 
			
		||||
      const [[lic]] = await db.query("SELECT COUNT(*) AS c FROM license_outputs WHERE device_id = ?", [deviceId]);
 | 
			
		||||
      const [[log]] = await db.query("SELECT COUNT(*) AS c FROM logging_outputs WHERE device_id = ?", [deviceId]);
 | 
			
		||||
  
 | 
			
		||||
      results.push({
 | 
			
		||||
        version,
 | 
			
		||||
        commands: {
 | 
			
		||||
          inventory: inv.c,
 | 
			
		||||
          version: ver.c,
 | 
			
		||||
          license: lic.c,
 | 
			
		||||
          logging: log.c,
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    res.json(results);
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  
 | 
			
		||||
	const { pid } = req.params;
 | 
			
		||||
 | 
			
		||||
	// Lấy danh sách các version tương ứng với PID
 | 
			
		||||
	const [devices] = await db.query(
 | 
			
		||||
		"SELECT id, version FROM devices WHERE pid = ?",
 | 
			
		||||
		[pid],
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	const results = [];
 | 
			
		||||
 | 
			
		||||
	for (const device of devices) {
 | 
			
		||||
		const deviceId = device.id;
 | 
			
		||||
		const version = device.version;
 | 
			
		||||
 | 
			
		||||
		const [[inv]] = await db.query(
 | 
			
		||||
			"SELECT COUNT(*) AS c FROM inventory_outputs WHERE device_id = ?",
 | 
			
		||||
			[deviceId],
 | 
			
		||||
		);
 | 
			
		||||
		const [[ver]] = await db.query(
 | 
			
		||||
			"SELECT COUNT(*) AS c FROM version_outputs WHERE device_id = ?",
 | 
			
		||||
			[deviceId],
 | 
			
		||||
		);
 | 
			
		||||
		const [[lic]] = await db.query(
 | 
			
		||||
			"SELECT COUNT(*) AS c FROM license_outputs WHERE device_id = ?",
 | 
			
		||||
			[deviceId],
 | 
			
		||||
		);
 | 
			
		||||
		const [[log]] = await db.query(
 | 
			
		||||
			"SELECT COUNT(*) AS c FROM logging_outputs WHERE device_id = ?",
 | 
			
		||||
			[deviceId],
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		results.push({
 | 
			
		||||
			version,
 | 
			
		||||
			commands: {
 | 
			
		||||
				inventory: inv.c,
 | 
			
		||||
				version: ver.c,
 | 
			
		||||
				license: lic.c,
 | 
			
		||||
				logging: log.c,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res.json(results);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/device/:pid/:version/:command", async (req, res) => {
 | 
			
		||||
  const { pid, version, command } = req.params;
 | 
			
		||||
	const { pid, version, command } = req.params;
 | 
			
		||||
 | 
			
		||||
  const [[device]] = await db.query(
 | 
			
		||||
    `SELECT id FROM devices WHERE pid = ? AND version = ?`,
 | 
			
		||||
    [pid, version]
 | 
			
		||||
  );
 | 
			
		||||
  if (!device) return res.status(404).json({ message: "Not found" });
 | 
			
		||||
	const [[device]] = await db.query(
 | 
			
		||||
		`SELECT id FROM devices WHERE pid = ? AND version = ?`,
 | 
			
		||||
		[pid, version],
 | 
			
		||||
	);
 | 
			
		||||
	if (!device) return res.status(404).json({ message: "Not found" });
 | 
			
		||||
 | 
			
		||||
  const [logs] = await db.query(
 | 
			
		||||
    `SELECT filename, output, created_at FROM ${command}_outputs WHERE device_id = ? ORDER BY created_at DESC`,
 | 
			
		||||
    [device.id]
 | 
			
		||||
  );
 | 
			
		||||
	const [logs] = await db.query(
 | 
			
		||||
		`SELECT id, filename, output, created_at FROM ${command}_outputs WHERE device_id = ? ORDER BY created_at DESC`,
 | 
			
		||||
		[device.id],
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
  res.json(logs);
 | 
			
		||||
	res.json(logs);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.post("/api/delete-logs", authenticateToken, async (req, res) => {
 | 
			
		||||
	const items = req.body.items;
 | 
			
		||||
	const userId = req.user?.id;
 | 
			
		||||
 | 
			
		||||
	if (!Array.isArray(items)) {
 | 
			
		||||
		return res.status(400).json({ message: "Invalid payload" });
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		for (const item of items) {
 | 
			
		||||
			const { command, id } = item;
 | 
			
		||||
 | 
			
		||||
			if (!command || typeof id !== "number") continue;
 | 
			
		||||
 | 
			
		||||
			const allowedCommands = [
 | 
			
		||||
				"inventory",
 | 
			
		||||
				"version",
 | 
			
		||||
				"license",
 | 
			
		||||
				"logging",
 | 
			
		||||
			];
 | 
			
		||||
			if (!allowedCommands.includes(command)) continue;
 | 
			
		||||
 | 
			
		||||
			const [rows] = await db.query(
 | 
			
		||||
				`SELECT id FROM \`${command}_outputs\` WHERE id = ?`,
 | 
			
		||||
				[id],
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			if (rows.length === 0) {
 | 
			
		||||
				continue;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Delete log
 | 
			
		||||
			await db.query(`DELETE FROM \`${command}_outputs\` WHERE id = ?`, [
 | 
			
		||||
				id,
 | 
			
		||||
			]);
 | 
			
		||||
			await db.query(
 | 
			
		||||
				"UPDATE users SET confirm_count = confirm_count + 1 WHERE id = ?",
 | 
			
		||||
				[userId],
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		res.json({ success: true });
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Delete error:", error);
 | 
			
		||||
		res.status(500).json({ success: false, message: "Server error" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Danh sách regex lọc lỗi
 | 
			
		||||
const errorPatterns = [
 | 
			
		||||
    {
 | 
			
		||||
      regex: /%\w+-[123]-\w+:/i,
 | 
			
		||||
      description: 'Lỗi mức độ 1–3 (critical, error, alert) trong định dạng syslog Cisco',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      regex: /%\w+-4-\w+:/i,
 | 
			
		||||
      description: 'Lỗi mức độ cảnh báo (level 4 - warning)',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      regex: /(fail|error|unexpected|badcfg|invalid|denied|disabled|timeout|panic)/i,
 | 
			
		||||
      description: 'Từ khóa phổ biến của lỗi trong nội dung log',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      regex: /^\d{1,2}:\d{2}:\d{2}:.*?%\w+-[1-4]-\w+:/i,
 | 
			
		||||
      description: 'Dòng log bắt đầu bằng timestamp có mã lỗi Cisco',
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
  
 | 
			
		||||
  
 | 
			
		||||
  // API: Trả về các dòng lỗi từ bảng logging_outputs theo PID
 | 
			
		||||
  app.get('/api/errors/:pid', async (req, res) => {
 | 
			
		||||
    const pid = req.params.pid;
 | 
			
		||||
  
 | 
			
		||||
    try {
 | 
			
		||||
      const [rows] = await db.execute(`
 | 
			
		||||
	{
 | 
			
		||||
		regex: /%\w+-[123]-\w+:/i,
 | 
			
		||||
		description:
 | 
			
		||||
			"Lỗi mức độ 1–3 (critical, error, alert) trong định dạng syslog Cisco",
 | 
			
		||||
	},
 | 
			
		||||
	{
 | 
			
		||||
		regex: /%\w+-4-\w+:/i,
 | 
			
		||||
		description: "Lỗi mức độ cảnh báo (level 4 - warning)",
 | 
			
		||||
	},
 | 
			
		||||
	{
 | 
			
		||||
		regex: /(fail|error|unexpected|badcfg|invalid|denied|disabled|timeout|panic)/i,
 | 
			
		||||
		description: "Từ khóa phổ biến của lỗi trong nội dung log",
 | 
			
		||||
	},
 | 
			
		||||
	{
 | 
			
		||||
		regex: /^\d{1,2}:\d{2}:\d{2}:.*?%\w+-[1-4]-\w+:/i,
 | 
			
		||||
		description: "Dòng log bắt đầu bằng timestamp có mã lỗi Cisco",
 | 
			
		||||
	},
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
// API: Trả về các dòng lỗi từ bảng logging_outputs theo PID
 | 
			
		||||
app.get("/api/errors/:pid", async (req, res) => {
 | 
			
		||||
	const pid = req.params.pid;
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		const [rows] = await db.execute(
 | 
			
		||||
			`
 | 
			
		||||
        SELECT lo.output, d.pid, lo.filename
 | 
			
		||||
        FROM logging_outputs lo
 | 
			
		||||
        JOIN devices d ON lo.device_id = d.id
 | 
			
		||||
        WHERE d.pid = ?
 | 
			
		||||
      `, [pid]);
 | 
			
		||||
  
 | 
			
		||||
      const matchedLines = [];
 | 
			
		||||
  
 | 
			
		||||
      for (const row of rows) {
 | 
			
		||||
        const lines = row.output.split(/\r?\n/);
 | 
			
		||||
  
 | 
			
		||||
        for (const line of lines.filter((l) => l.includes("*") || l.includes("%"))) {
 | 
			
		||||
          for (const pattern of errorPatterns) {
 | 
			
		||||
            const regex = new RegExp(pattern.regex.source, 'gi');
 | 
			
		||||
            if (regex.test(line)) {
 | 
			
		||||
              // Reset regex to start (because `.test()` may advance position in some cases)
 | 
			
		||||
              regex.lastIndex = 0;
 | 
			
		||||
              const highlighted = line.replace(regex, match => `<mark>${match}</mark>`);
 | 
			
		||||
  
 | 
			
		||||
              matchedLines.push({
 | 
			
		||||
                pid: row.pid,
 | 
			
		||||
                filename: row.filename,
 | 
			
		||||
                error_message: line.trim(),
 | 
			
		||||
                highlighted_message: highlighted.trim(),
 | 
			
		||||
                regex_pattern: pattern.regex.source,
 | 
			
		||||
                description: pattern.description,
 | 
			
		||||
              });
 | 
			
		||||
  
 | 
			
		||||
              break;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      const filtered = deduplicateErrors(matchedLines, 0.6);
 | 
			
		||||
      res.json(filtered);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err);
 | 
			
		||||
      res.status(500).json({ error: 'Internal server error' });
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  app.get('/api/test', async (req, res) => {
 | 
			
		||||
    console.log(req)
 | 
			
		||||
    res.send("ok")
 | 
			
		||||
  })
 | 
			
		||||
  
 | 
			
		||||
      `,
 | 
			
		||||
			[pid],
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		const matchedLines = [];
 | 
			
		||||
 | 
			
		||||
		for (const row of rows) {
 | 
			
		||||
			const lines = row.output.split(/\r?\n/);
 | 
			
		||||
 | 
			
		||||
			for (const line of lines.filter(
 | 
			
		||||
				(l) => l.includes("*") || l.includes("%"),
 | 
			
		||||
			)) {
 | 
			
		||||
				for (const pattern of errorPatterns) {
 | 
			
		||||
					const regex = new RegExp(pattern.regex.source, "gi");
 | 
			
		||||
					if (regex.test(line)) {
 | 
			
		||||
						// Reset regex to start (because `.test()` may advance position in some cases)
 | 
			
		||||
						regex.lastIndex = 0;
 | 
			
		||||
						const highlighted = line.replace(
 | 
			
		||||
							regex,
 | 
			
		||||
							(match) => `<mark>${match}</mark>`,
 | 
			
		||||
						);
 | 
			
		||||
 | 
			
		||||
						matchedLines.push({
 | 
			
		||||
							pid: row.pid,
 | 
			
		||||
							filename: row.filename,
 | 
			
		||||
							error_message: line.trim(),
 | 
			
		||||
							highlighted_message: highlighted.trim(),
 | 
			
		||||
							regex_pattern: pattern.regex.source,
 | 
			
		||||
							description: pattern.description,
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const filtered = deduplicateErrors(matchedLines, 0.6);
 | 
			
		||||
		res.json(filtered);
 | 
			
		||||
	} catch (err) {
 | 
			
		||||
		console.error(err);
 | 
			
		||||
		res.status(500).json({ error: "Internal server error" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/test", async (req, res) => {
 | 
			
		||||
	console.log(req);
 | 
			
		||||
	res.send("ok");
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Web Route
 | 
			
		||||
inititalWebRoute(app, db);
 | 
			
		||||
 | 
			
		||||
app.listen(PORT, () =>
 | 
			
		||||
  console.log(`🚀 Server running at http://localhost:${PORT}`)
 | 
			
		||||
	console.log(`🚀 Server running at http://localhost:${PORT}`),
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										335
									
								
								server_old.js
								
								
								
								
							
							
						
						
									
										335
									
								
								server_old.js
								
								
								
								
							| 
						 | 
				
			
			@ -1,335 +0,0 @@
 | 
			
		|||
// === app.js ===
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const mysql = require("mysql2/promise");
 | 
			
		||||
const bodyParser = require("body-parser");
 | 
			
		||||
const axios = require("axios");
 | 
			
		||||
const cheerio = require("cheerio");
 | 
			
		||||
const dotenv = require("dotenv");
 | 
			
		||||
dotenv.config();
 | 
			
		||||
const app = express();
 | 
			
		||||
const PORT = 4000;
 | 
			
		||||
 | 
			
		||||
app.use(bodyParser.json());
 | 
			
		||||
app.use(express.static("public"));
 | 
			
		||||
 | 
			
		||||
// === MySQL Connection ===
 | 
			
		||||
const db = mysql.createPool({
 | 
			
		||||
  host: "localhost",
 | 
			
		||||
  user: "root",
 | 
			
		||||
  password: "",
 | 
			
		||||
  database: "log_analysis",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// === Create Table If Not Exists ===
 | 
			
		||||
(async () => {
 | 
			
		||||
  await db.query(`
 | 
			
		||||
    CREATE TABLE IF NOT EXISTS pid_logs (
 | 
			
		||||
      id INT AUTO_INCREMENT PRIMARY KEY,
 | 
			
		||||
      pid VARCHAR(100),
 | 
			
		||||
      version VARCHAR(100),
 | 
			
		||||
      log TEXT,
 | 
			
		||||
      commands JSON,
 | 
			
		||||
      errors LONGTEXT,
 | 
			
		||||
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 | 
			
		||||
    )
 | 
			
		||||
  `);
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
// === Regex-based command detection ===
 | 
			
		||||
function extractCommands(log) {
 | 
			
		||||
  return {
 | 
			
		||||
    inventory: /sh(ow)?\s+inv(entory)?/i.test(log),
 | 
			
		||||
    version: /sh(ow)?\s+ver(sion)?/i.test(log),
 | 
			
		||||
    license: /sh(ow)?\s+lic(ense)?/i.test(log),
 | 
			
		||||
    logging: /sh(ow)?\s+log(ging)?/i.test(log),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function extractPIDVersion(log) {
 | 
			
		||||
  const pidMatch = log.match(/PID:\s*([A-Z0-9\-]+)/);
 | 
			
		||||
  const versionMatch = log.match(/Version\s+([\d\.A-Za-z\-]+)/);
 | 
			
		||||
  return {
 | 
			
		||||
    pid: pidMatch ? pidMatch[1] : "UNKNOWN",
 | 
			
		||||
    version: versionMatch ? versionMatch[1] : "UNKNOWN",
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function extractLogging(log) {
 | 
			
		||||
  return log
 | 
			
		||||
    .split("\n")
 | 
			
		||||
    .filter(
 | 
			
		||||
      (line) =>
 | 
			
		||||
        line.startsWith("$  *") ||
 | 
			
		||||
        line.startsWith("$  %") ||
 | 
			
		||||
        line.startsWith("%") ||
 | 
			
		||||
        line.includes("ALARM")
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function extractRelevantLog(log, commands) {
 | 
			
		||||
    const lines = log.split('\n');
 | 
			
		||||
    const commandPatterns = [
 | 
			
		||||
      { key: 'inventory', regex: /sh(ow)?\s+inv(entory)?/i },
 | 
			
		||||
      { key: 'version', regex: /sh(ow)?\s+ver(sion)?/i },
 | 
			
		||||
      { key: 'license', regex: /sh(ow)?\s+lic(ense)?/i },
 | 
			
		||||
      { key: 'logging', regex: /sh(ow)?\s+log(ging)?/i },
 | 
			
		||||
    ];
 | 
			
		||||
  
 | 
			
		||||
    let extracted = '';
 | 
			
		||||
    const included = new Set();
 | 
			
		||||
  
 | 
			
		||||
    for (let i = 0; i < lines.length; i++) {
 | 
			
		||||
      for (const cmd of commandPatterns) {
 | 
			
		||||
        const { key, regex } = cmd;
 | 
			
		||||
  
 | 
			
		||||
        if (commands[key] && !included.has(key) && regex.test(lines[i])) {
 | 
			
		||||
            included.add(key);
 | 
			
		||||
            // console.log(log);
 | 
			
		||||
            // Tách prompt và command
 | 
			
		||||
            const match = lines[i].match(/^(.+?[#>])\s*(sh(ow)?\s+\w+)/i);
 | 
			
		||||
            let prompt = '', command = '';
 | 
			
		||||
            if (match) {
 | 
			
		||||
              prompt = match[1];    // phần như "Switch#", "Router>", ...
 | 
			
		||||
              command = match[2];   // phần như "show inventory", ...
 | 
			
		||||
            }
 | 
			
		||||
            console.log({prompt, command, lines: [lines[i], lines[i+1]]});
 | 
			
		||||
            let block = [lines[i]];
 | 
			
		||||
            
 | 
			
		||||
          for (let j = i + 1; j < lines.length; j++) {
 | 
			
		||||
            // Nếu phát hiện dòng chứa command khác chưa được xử lý, thì dừng lại
 | 
			
		||||
            // const isNextCommand = commandPatterns.some(c =>
 | 
			
		||||
            //   commands[c.key] && !included.has(c.key) && c.regex.test(lines[j])
 | 
			
		||||
            // );
 | 
			
		||||
            if (lines[j].includes(prompt)) break;
 | 
			
		||||
            // if (isNextCommand) break;
 | 
			
		||||
  
 | 
			
		||||
            block.push(lines[j]);
 | 
			
		||||
          }
 | 
			
		||||
  
 | 
			
		||||
          extracted += block.join('\n') + '\n';
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    return extracted.trim();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
// === Search PID ===
 | 
			
		||||
app.get("/api/search", async (req, res) => {
 | 
			
		||||
  const { pid } = req.query;
 | 
			
		||||
  const [rows] = await db.query("SELECT * FROM pid_logs WHERE pid LIKE ?", [
 | 
			
		||||
    `%${pid}%`,
 | 
			
		||||
  ]);
 | 
			
		||||
  res.json(rows);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// === Add New Block ===
 | 
			
		||||
app.post("/api/add", async (req, res) => {
 | 
			
		||||
  const { pid, version, log } = req.body;
 | 
			
		||||
  const commands = extractCommands(log);
 | 
			
		||||
  const errors = extractLogging(log);
 | 
			
		||||
  const relevantLog = extractRelevantLog(log, commands);
 | 
			
		||||
  await db.query(
 | 
			
		||||
    `INSERT INTO pid_logs (pid, version, log, commands, errors) VALUES (?, ?, ?, ?, ?)`,
 | 
			
		||||
    [
 | 
			
		||||
      pid,
 | 
			
		||||
      version,
 | 
			
		||||
      `[${filename}]\n${relevantLog}`,
 | 
			
		||||
      JSON.stringify(commands),
 | 
			
		||||
      errors.join('\n'),
 | 
			
		||||
    ]
 | 
			
		||||
  );
 | 
			
		||||
  
 | 
			
		||||
  res.json({ success: true });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// === Ask AI to extract errors ===
 | 
			
		||||
app.post("/api/extract-errors", async (req, res) => {
 | 
			
		||||
  const { log } = req.body;
 | 
			
		||||
  const response = await axios.post(
 | 
			
		||||
    "https://api.openai.com/v1/chat/completions",
 | 
			
		||||
    {
 | 
			
		||||
      model: "gpt-4o-mini",
 | 
			
		||||
      messages: [
 | 
			
		||||
        {
 | 
			
		||||
          role: "system",
 | 
			
		||||
          content:
 | 
			
		||||
            "Extract and summarize hardware or configuration errors from this Cisco log block.",
 | 
			
		||||
        },
 | 
			
		||||
        { role: "user", content: log },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      headers: {
 | 
			
		||||
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
  res.json({ errors: response.data.choices[0].message.content });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Get versions for a specific PID
 | 
			
		||||
app.get("/api/pid/:pid/versions", async (req, res) => {
 | 
			
		||||
    const { pid } = req.params;
 | 
			
		||||
    const [rows] = await db.query(
 | 
			
		||||
      "SELECT version, commands FROM pid_logs WHERE pid = ?",
 | 
			
		||||
      [pid]
 | 
			
		||||
    );
 | 
			
		||||
  
 | 
			
		||||
    // Nhóm version nếu bị trùng, lấy unique
 | 
			
		||||
    const seen = new Set();
 | 
			
		||||
    const result = [];
 | 
			
		||||
  
 | 
			
		||||
    for (const row of rows) {
 | 
			
		||||
      if (seen.has(row.version)) continue;
 | 
			
		||||
      seen.add(row.version);
 | 
			
		||||
      result.push({
 | 
			
		||||
        version: row.version,
 | 
			
		||||
        commands: row.commands,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    res.json(result);
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  
 | 
			
		||||
  // Get full log + errors by pid and version
 | 
			
		||||
  app.get("/api/pid/:pid/version/:version", async (req, res) => {
 | 
			
		||||
    const { pid, version } = req.params;
 | 
			
		||||
    const [rows] = await db.query(
 | 
			
		||||
      "SELECT log, errors FROM pid_logs WHERE pid = ? AND version = ?",
 | 
			
		||||
      [pid, version]
 | 
			
		||||
    );
 | 
			
		||||
    if (rows.length === 0) return res.status(404).json({ message: "Not found" });
 | 
			
		||||
    res.json(rows[0]);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  app.get("/api/pids", async (req, res) => {
 | 
			
		||||
    const [rows] = await db.query("SELECT DISTINCT pid FROM pid_logs ORDER BY pid");
 | 
			
		||||
    res.json(rows.map((row) => row.pid));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
// === Auto crawl logs from internal directory ===
 | 
			
		||||
async function fetchLogFile(url) {
 | 
			
		||||
  const res = await axios.get(url);
 | 
			
		||||
  return res.data;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function crawlLogs() {
 | 
			
		||||
  const BASE_URL = "http://172.16.5.7:8080";
 | 
			
		||||
  console.log(`[${new Date().toISOString()}] Start crawl ${BASE_URL}`);
 | 
			
		||||
  const res = await axios.get(BASE_URL);
 | 
			
		||||
  const $ = cheerio.load(res.data);
 | 
			
		||||
  const links = $("a")
 | 
			
		||||
    .map((_, el) => $(el).attr("href"))
 | 
			
		||||
    .get()
 | 
			
		||||
    .filter((href) => href.endsWith(".log"));
 | 
			
		||||
 | 
			
		||||
    const splitBlocks = (logContent) =>
 | 
			
		||||
        logContent
 | 
			
		||||
          .split(/(?=^.*?[#>]\s*sh(ow)?\s+inv(entory)?)/gim)
 | 
			
		||||
          .map((b) => b?.trim())
 | 
			
		||||
          .filter(Boolean);
 | 
			
		||||
 | 
			
		||||
  for (const link of links) {
 | 
			
		||||
    const fileUrl = `${BASE_URL}/${link}`;
 | 
			
		||||
    const filename = link.replace(/\//g, "_");
 | 
			
		||||
    // const [exists] = await db.query(
 | 
			
		||||
    //   "SELECT COUNT(*) as count FROM pid_logs WHERE log LIKE ?",
 | 
			
		||||
    //   [`%${filename}%`]
 | 
			
		||||
    // );
 | 
			
		||||
    // if (exists[0].count > 0) {
 | 
			
		||||
    //   console.log(`🟡 Skipped: ${filename}`);
 | 
			
		||||
    //   continue;
 | 
			
		||||
    // }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const log = await fetchLogFile(fileUrl);
 | 
			
		||||
      const blocks = splitBlocks(log);
 | 
			
		||||
 | 
			
		||||
      for (const block of blocks) {
 | 
			
		||||
        const { pid, version } = extractPIDVersion(block);
 | 
			
		||||
        const commands = extractCommands(block);
 | 
			
		||||
        const errors = extractLogging(block);
 | 
			
		||||
      
 | 
			
		||||
        if (pid === "UNKNOWN" || version === "UNKNOWN") continue;
 | 
			
		||||
      
 | 
			
		||||
        const [existing] = await db.query(
 | 
			
		||||
          "SELECT * FROM pid_logs WHERE pid = ? AND version = ?",
 | 
			
		||||
          [pid, version]
 | 
			
		||||
        );
 | 
			
		||||
      
 | 
			
		||||
        // Nếu CHƯA CÓ -> INSERT mới toàn bộ
 | 
			
		||||
        if (!existing[0]) {
 | 
			
		||||
          const relevantLog = extractRelevantLog(block, commands);
 | 
			
		||||
      
 | 
			
		||||
          await db.query(
 | 
			
		||||
            `INSERT INTO pid_logs (pid, version, log, commands, errors) VALUES (?, ?, ?, ?, ?)`,
 | 
			
		||||
            [
 | 
			
		||||
              pid,
 | 
			
		||||
              version,
 | 
			
		||||
              `[${filename}]\n${relevantLog}`,
 | 
			
		||||
              JSON.stringify(commands),
 | 
			
		||||
              errors.join('\n'),
 | 
			
		||||
            ]
 | 
			
		||||
          );
 | 
			
		||||
      
 | 
			
		||||
          console.log(`✅ Added ${pid} ${version} (${filename})`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        // Nếu ĐÃ CÓ -> Kiểm tra và UPDATE nếu cần
 | 
			
		||||
        const oldCommands = existing[0].commands;
 | 
			
		||||
      
 | 
			
		||||
        const isSameCommands = Object.keys(commands).every(
 | 
			
		||||
          (key) => commands[key] === oldCommands[key]
 | 
			
		||||
        );
 | 
			
		||||
      
 | 
			
		||||
        if (isSameCommands) {
 | 
			
		||||
          console.log(`🟡 Skipped existing PID+Version: ${pid} ${version}`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        const newCommands = {};
 | 
			
		||||
        for (const key of Object.keys(commands)) {
 | 
			
		||||
          if (commands[key] === true && oldCommands[key] === false) {
 | 
			
		||||
            newCommands[key] = true;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        if (Object.keys(newCommands).length === 0) {
 | 
			
		||||
          console.log(`🟠 No new true commands → skip ${pid} ${version}`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        const newRelevantLog = extractRelevantLog(block, newCommands);
 | 
			
		||||
        const updatedCommands = { ...oldCommands, ...newCommands };
 | 
			
		||||
        const updatedLog = `${existing[0].log}\n[${filename}]\n${newRelevantLog}`;
 | 
			
		||||
      
 | 
			
		||||
        await db.query(
 | 
			
		||||
          `UPDATE pid_logs SET log = ?, commands = ?, errors = ? WHERE pid = ? AND version = ?`,
 | 
			
		||||
          [
 | 
			
		||||
            updatedLog,
 | 
			
		||||
            JSON.stringify(updatedCommands),
 | 
			
		||||
            `${existing[0].errors || ''}\n${errors.join('\n')}`.trim(),
 | 
			
		||||
            pid,
 | 
			
		||||
            version,
 | 
			
		||||
          ]
 | 
			
		||||
        );
 | 
			
		||||
      
 | 
			
		||||
        console.log(`🟢 Updated with new command(s) for ${pid} ${version}`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(`❌ Error ${filename}: ${err.message} ${err.stack}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// === Run crawl on start ===
 | 
			
		||||
// crawlLogs();
 | 
			
		||||
 | 
			
		||||
app.listen(PORT, () =>
 | 
			
		||||
  console.log(`Server running at http://localhost:${PORT}`)
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
const jwt = require("jsonwebtoken");
 | 
			
		||||
 | 
			
		||||
const JWT_SECRET = process.env.JWT_SECRET;
 | 
			
		||||
 | 
			
		||||
const createToken = (payload) => {
 | 
			
		||||
	return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const verifyToken = (token) => {
 | 
			
		||||
	return jwt.verify(token, JWT_SECRET);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = { createToken, verifyToken };
 | 
			
		||||
		Loading…
	
		Reference in New Issue