diff --git a/README.md b/README.md index 8c99a287..0de9fe43 100644 --- a/README.md +++ b/README.md @@ -1059,8 +1059,14 @@ ARGUMENTS APPLICATION Kuzzle PaaS application OPTIONS + -f, --follow Follow log output + -n, --tail=tail Number of lines to show from the end of the logs + -t, --timestamp Show timestamp --help show CLI help + --podName=podName Name of the pod to show logs from --project=project Current PaaS project + --since=since Display logs from a specific absolute (e.g. 2022/12/02 09:41) or relative (e.g. a minute ago) time + --until=until Display logs until a specific absolute (e.g. 2022/12/02 09:41) or relative (e.g. a minute ago) time ``` _See code: [src/commands/paas/logs.ts](src/commands/paas/logs.ts)_ diff --git a/package-lock.json b/package-lock.json index 9883a3a5..0a44ae48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@oclif/plugin-help": "3.2.2", "bluebird": "^3.7.2", "chalk": "^4.1.0", + "chrono-node": "^2.4.2", "cli-ux": "^5.5.1", "inquirer": "^8.0.0", "kepler-companion": "^1.1.3", @@ -56,7 +57,7 @@ "@typescript-eslint/parser": "^4.22.0", "chai": "^4.3.4", "cucumber": "^6.0.5", - "eslint": "^7.24.0", + "eslint": "^7.32.0", "globby": "^11.0.3", "mocha": "^8.3.2", "nyc": "^15.1.0", @@ -470,6 +471,26 @@ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1642,6 +1663,14 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true }, + "node_modules/chrono-node": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.4.2.tgz", + "integrity": "sha512-M55ndjyorMF0ZapxcGiSPj98lmdWTMpRLP5dfQ99SSzYSxan6Sc5G4aIBgZkx2u7Tc+0msiSbeqOyEMCpwh//g==", + "dependencies": { + "dayjs": "^1.10.0" + } + }, "node_modules/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -1984,6 +2013,11 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, + "node_modules/dayjs": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.6.tgz", + "integrity": "sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -2196,28 +2230,31 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "node_modules/eslint": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.24.0.tgz", - "integrity": "sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==", + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, "dependencies": { "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.0", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", "esquery": "^1.4.0", "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", + "glob-parent": "^5.1.2", "globals": "^13.6.0", "ignore": "^4.0.6", "import-fresh": "^3.0.0", @@ -2226,7 +2263,7 @@ "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", - "lodash": "^4.17.21", + "lodash.merge": "^4.6.2", "minimatch": "^3.0.4", "natural-compare": "^1.4.0", "optionator": "^0.9.1", @@ -2235,7 +2272,7 @@ "semver": "^7.2.1", "strip-ansi": "^6.0.0", "strip-json-comments": "^3.1.0", - "table": "^6.0.4", + "table": "^6.0.9", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, @@ -6551,6 +6588,23 @@ } } }, + "@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -7724,6 +7778,14 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true }, + "chrono-node": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.4.2.tgz", + "integrity": "sha512-M55ndjyorMF0ZapxcGiSPj98lmdWTMpRLP5dfQ99SSzYSxan6Sc5G4aIBgZkx2u7Tc+0msiSbeqOyEMCpwh//g==", + "requires": { + "dayjs": "^1.10.0" + } + }, "clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -8076,6 +8138,11 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, + "dayjs": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.6.tgz", + "integrity": "sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -8290,28 +8357,31 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "eslint": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.24.0.tgz", - "integrity": "sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==", + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.0", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", "esquery": "^1.4.0", "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", + "glob-parent": "^5.1.2", "globals": "^13.6.0", "ignore": "^4.0.6", "import-fresh": "^3.0.0", @@ -8320,7 +8390,7 @@ "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", - "lodash": "^4.17.21", + "lodash.merge": "^4.6.2", "minimatch": "^3.0.4", "natural-compare": "^1.4.0", "optionator": "^0.9.1", @@ -8329,7 +8399,7 @@ "semver": "^7.2.1", "strip-ansi": "^6.0.0", "strip-json-comments": "^3.1.0", - "table": "^6.0.4", + "table": "^6.0.9", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, diff --git a/package.json b/package.json index 8c24e025..c0f851cd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "bugs": "https://github.com/kuzzleio/kourou/issues", "scripts": { - "build": "tsc -b", + "build": "tsc --build tsconfig.json", "dev": "./bin/run", "postpack": "rm -f oclif.manifest.json", "version": "oclif-dev readme && git add README.md", @@ -26,6 +26,7 @@ "@oclif/plugin-help": "3.2.2", "bluebird": "^3.7.2", "chalk": "^4.1.0", + "chrono-node": "^2.4.2", "cli-ux": "^5.5.1", "inquirer": "^8.0.0", "kepler-companion": "^1.1.3", @@ -63,7 +64,7 @@ "@typescript-eslint/parser": "^4.22.0", "chai": "^4.3.4", "cucumber": "^6.0.5", - "eslint": "^7.24.0", + "eslint": "^7.32.0", "globby": "^11.0.3", "mocha": "^8.3.2", "nyc": "^15.1.0", diff --git a/src/commands/paas/logs.ts b/src/commands/paas/logs.ts index 3ea24fe4..b25c0872 100644 --- a/src/commands/paas/logs.ts +++ b/src/commands/paas/logs.ts @@ -1,17 +1,68 @@ -import { flags } from "@oclif/command"; -import { PaasKommand } from "../../support/PaasKommand"; import fs from "fs"; -import PaasLogin from "./login"; +import * as readline from "readline"; + +import { flags } from "@oclif/command"; +import chalk from "chalk"; +import * as chrono from "chrono-node"; import { cli } from "cli-ux"; +import PaasLogin from "./login"; +import { PaasKommand } from "../../support/PaasKommand"; + +/** + * Data contained a PaaS log, as given by the API. + */ +type PaasLogData = { + /** + * Contents of the log. + */ + content: string; + + /** + * Timestamp of the log. + */ + timeStamp: string; + + /** + * Whether this is the last log of the stream. + */ + last: boolean; + + /** + * Name of the pod that generated the log. + */ + podName: string; +}; + class PaasLogs extends PaasKommand { public static description = "Show logs of the targeted application"; public static flags = { + follow: flags.boolean({ + char: "f", + description: "Follow log output", + }), + timestamp: flags.boolean({ + char: "t", + description: "Show timestamp", + }), help: flags.help(), project: flags.string({ description: "Current PaaS project", }), + tail: flags.integer({ + char: "n", + description: "Number of lines to show from the end of the logs", + }), + podName: flags.string({ + description: "Name of the pod to show logs from", + }), + since: flags.string({ + description: "Display logs from a specific absolute (e.g. 2022/12/02 09:41) or relative (e.g. a minute ago) time", + }), + until: flags.string({ + description: "Display logs until a specific absolute (e.g. 2022/12/02 09:41) or relative (e.g. a minute ago) time", + }), }; static args = [ @@ -27,6 +78,22 @@ class PaasLogs extends PaasKommand { }, ]; + /** + * Allowed colors for pod names. + */ + private readonly allColors = [chalk.red, chalk.green, chalk.yellow, chalk.blue, chalk.magenta, chalk.cyan, chalk.gray]; + + /** + * Available colors for pod names. + */ + private availableColors = [...this.allColors]; + + /** + * The color to use for pod names. + * @private + */ + private podsColor = new Map(); + async runSafe() { const apiKey = await this.getCredentials(); @@ -34,20 +101,56 @@ class PaasLogs extends PaasKommand { const user = await this.paas.auth.getCurrentUser(); this.logInfo( - `Logged as "${user._id}" for project "${ - this.flags.project || this.getProject() + `Logged as "${user._id}" for project "${this.flags.project || this.getProject() }"` ); - const logs: any = await this.paas.query({ + const separator = "\t"; + + // Parse the time arguments + const since = this.flags.since ? chrono.parseDate(this.flags.since).toISOString() : undefined; + const until = this.flags.until ? chrono.parseDate(this.flags.until).toISOString() : undefined; + + // Perform the streamed request + const incomingMessage = await this.paas.queryHttpStream({ controller: "application", action: "logs", environmentId: this.args.environment, projectId: this.flags.project || this.getProject(), applicationId: this.args.application, + follow: this.flags.follow, + tailLines: this.flags.tail, + podName: this.flags.podName, + since, + until, }); - this.logOk(logs.result.join("\n ")); + // Read the response line by line + const lineStream = readline.createInterface({ + input: incomingMessage, + crlfDelay: Infinity, + terminal: false, + }); + + // Display the response + for await (const line of lineStream) { + // Parse the data + const data: PaasLogData = JSON.parse(line); + + // Exclude logs that are empty or that are not from a pod + if (!data.content || !data.podName) { + continue; + } + + // Get the pod color + const podColor = this.getPodColor(data.podName); + + // Display the log + const timestamp = this.flags.timestamp ? `[${new Date(data.timeStamp).toLocaleString()}] ` : ""; + const name = podColor(`${data.podName}${separator}`); + + this.log(`${timestamp}${name}| ${data.content}`); + } } async getCredentials() { @@ -74,6 +177,44 @@ class PaasLogs extends PaasKommand { return credentials.apiKey; } + + getNumberOfSpaces(names: string[], currentName: string) { + const end = 10; + let max = { name: '', length: 0 }; + + for (const name of names) { + if (max.length < name.length) { + max = { name: name, length: name.length }; + } + } + + return currentName === max.name ? end : end + (max.length - currentName.length); + } + + /** + * Returns the color to use for a pod name. + * @param podName Name of the pod. + * @returns The color to use. + */ + getPodColor(podName: string): chalk.Chalk { + // Attempt to get the color from the list + let podColor = this.podsColor.get(podName); + + if (!podColor) { + // Get a random color + const index = Math.floor(Math.random() * this.availableColors.length); + [podColor] = this.availableColors.splice(index, 1); + + this.podsColor.set(podName, podColor); + + // If all colors are used, reset the available colors + if (this.availableColors.length === 0) { + this.availableColors = [...this.allColors]; + } + } + + return podColor; + } } export default PaasLogs; diff --git a/src/support/kuzzle.ts b/src/support/kuzzle.ts index a834ee2b..dbb4758e 100644 --- a/src/support/kuzzle.ts +++ b/src/support/kuzzle.ts @@ -1,6 +1,7 @@ -import { flags } from "@oclif/command"; +import http from "http"; -import { Http, WebSocket, Kuzzle } from "kuzzle-sdk"; +import { flags } from "@oclif/command"; +import { Http, WebSocket, Kuzzle, JSONObject } from "kuzzle-sdk"; const SECOND = 1000; @@ -198,6 +199,45 @@ export class KuzzleSDK { return this.sdk.query(request); } + /** + * Query the Kuzzle API and return a streamed response. + * @param request The request to send to Kuzzle + * @returns The response stream + */ + public queryHttpStream(request: JSONObject): Promise { + // Ensure the protocol is HTTP + if (this.protocol !== "http") { + throw new TypeError("HTTP streaming is only available with the HTTP protocol"); + } + + // Construct the URL + const url = `${this.ssl ? "https" : "http"}://${this.host}:${this.port}/_query`; + + // Construct the request + const body = JSON.stringify(request); + + const options = { + method: "POST", + headers: { + "Authorization": `Bearer ${this.sdk.jwt}`, + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json", + }, + }; + + // Send the request + return new Promise((resolve, reject) => { + const req = http.request(url, options, (res) => { + resolve(res); + }); + + req.on("error", reject); + + req.write(body); + req.end(); + }); + } + get document() { return this.sdk.document; }