refactor(app/web): new features (#1124) [skip ci]

This commit is contained in:
Simon Lecoq
2022-07-06 04:37:39 +02:00
committed by GitHub
parent 7379fb21a8
commit 130c74b266
80 changed files with 1304 additions and 1103 deletions

View File

@@ -2,7 +2,7 @@
- source/app/action/** - source/app/action/**
- source/app/web/** - source/app/web/**
✨ metrics insights: ✨ metrics insights:
- source/app/web/statics/about/** - source/app/web/statics/insights/**
🧩 plugins: 🧩 plugins:
- source/plugins/** - source/plugins/**

View File

@@ -79,8 +79,8 @@ Generate metrics that can be embedded everywhere, including your GitHub profile
<th colspan="2"><h2>🦑 Try it now!</h2></th> <th colspan="2"><h2>🦑 Try it now!</h2></th>
</tr> </tr>
<tr> <tr>
<th><a href="https://metrics.lecoq.io">📊 Metrics embed</a></th> <th><a href="https://metrics.lecoq.io/embed">📊 Metrics embed</a></th>
<th><a href="https://metrics.lecoq.io/about">✨ Metrics insights</a></th> <th><a href="https://metrics.lecoq.io/insights">✨ Metrics insights</a></th>
</tr> </tr>
<tr> <tr>
<td align="center"> <td align="center">

View File

@@ -220,7 +220,8 @@ export default async function(
}, },
//Settings and tokens //Settings and tokens
{ {
enabled = false enabled = false,
extras = false,
} = {}) { } = {}) {
//Plugin execution //Plugin execution
try { try {
@@ -241,7 +242,7 @@ export default async function(
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error:{message:"An error occured", instance:error}} throw imports.format.error(error)
} }
} }
``` ```

View File

@@ -9,14 +9,12 @@ const __metrics = paths.join(paths.dirname(url.fileURLToPath(import.meta.url)),
const __templates = paths.join(paths.join(__metrics, "source/templates/")) const __templates = paths.join(paths.join(__metrics, "source/templates/"))
const __node_modules = paths.join(paths.join(__metrics, "node_modules")) const __node_modules = paths.join(paths.join(__metrics, "node_modules"))
const __web = paths.join(paths.join(__metrics, "source/app/web/statics")) const __web = paths.join(paths.join(__metrics, "source/app/web/statics"))
const __web_about = paths.join(paths.join(__web, "about"))
const __preview = paths.join(paths.join(__web, "preview")) const __preview = paths.join(paths.join(__web, "preview"))
const __preview_js = paths.join(__preview, ".js") const __preview_js = paths.join(__preview, ".js")
const __preview_css = paths.join(__preview, ".css") const __preview_css = paths.join(__preview, ".css")
const __preview_templates = paths.join(__preview, ".templates") const __preview_templates = paths.join(__preview, ".templates")
const __preview_templates_ = paths.join(__preview, ".templates_") const __preview_templates_ = paths.join(__preview, ".templates_")
const __preview_about = paths.join(__preview, "about/.statics")
//Extract from web server //Extract from web server
const {conf, Templates} = await setup({log: false}) const {conf, Templates} = await setup({log: false})
@@ -34,7 +32,6 @@ await fs.mkdir(__preview_js, {recursive: true})
await fs.mkdir(__preview_css, {recursive: true}) await fs.mkdir(__preview_css, {recursive: true})
await fs.mkdir(__preview_templates, {recursive: true}) await fs.mkdir(__preview_templates, {recursive: true})
await fs.mkdir(__preview_templates_, {recursive: true}) await fs.mkdir(__preview_templates_, {recursive: true})
await fs.mkdir(__preview_about, {recursive: true})
//Web //Web
fs.copyFile(paths.join(__web, "index.html"), paths.join(__preview, "index.html")) fs.copyFile(paths.join(__web, "index.html"), paths.join(__preview, "index.html"))
@@ -81,9 +78,15 @@ fs.copyFile(paths.join(__node_modules, "clipboard/dist/clipboard.min.js"), paths
//Meta //Meta
fs.writeFile(paths.join(__preview, ".version"), JSON.stringify(`${conf.package.version}-preview`)) fs.writeFile(paths.join(__preview, ".version"), JSON.stringify(`${conf.package.version}-preview`))
fs.writeFile(paths.join(__preview, ".hosted"), JSON.stringify({by: "metrics", link: "https://github.com/lowlighter/metrics"})) fs.writeFile(paths.join(__preview, ".hosted"), JSON.stringify({by: "metrics", link: "https://github.com/lowlighter/metrics"}))
//About //Insights
fs.copyFile(paths.join(__web, "about", "index.html"), paths.join(__preview, "about", "index.html")) for (const insight of ["insights", "about"]) {
for (const file of await fs.readdir(__web_about)) { const __web_insights = paths.join(paths.join(__web, insight))
if (file !== ".statics") const __preview_insights = paths.join(__preview, `${insight}/.statics`)
fs.copyFile(paths.join(__web_about, file), paths.join(__preview_about, file)) await fs.mkdir(__preview_insights, {recursive: true})
}
fs.copyFile(paths.join(__web, insight, "index.html"), paths.join(__preview, insight, "index.html"))
for (const file of await fs.readdir(__web_insights)) {
if (file !== ".statics")
fs.copyFile(paths.join(__web_insights, file), paths.join(__preview_insights, file))
}
}

View File

@@ -1,15 +1,15 @@
//Setup //Setup
export default async function({login, q, imports, data, computed, rest, graphql, queries, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, computed, rest, graphql, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.<%= name %>)) if ((!enabled) || (!q.<%= name %>) || (!imports.metadata.plugins.<%= name %>.extras("enabled", {extras})))
return null return null
//Results //Results
return {} return {}
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error:{message:"An error occured", instance:error}} throw imports.format.error(error)
} }
} }

View File

@@ -24,7 +24,7 @@ export default async function metrics({login, q}, {graphql, rest, plugins, conf,
//Initialization //Initialization
const pending = [] const pending = []
const {queries} = conf const {queries} = conf
const extras = {css: (conf.settings.extras?.css ?? conf.settings.extras?.default) ? q["extras.css"] ?? "" : "", js: (conf.settings.extras?.js ?? conf.settings.extras?.default) ? q["extras.js"] ?? "" : ""} const extras = {css: imports.metadata.plugins.core.extras("extras_css", {...conf.settings, error:false}) ? q["extras.css"] ?? "" : "", js: imports.metadata.plugins.core.extras("extras_js", {...conf.settings, error:false}) ? q["extras.js"] ?? "" : ""}
const data = {q, animated: true, large: false, base: {}, config: {}, errors: [], plugins: {}, computed: {}, extras, postscripts: []} const data = {q, animated: true, large: false, base: {}, config: {}, errors: [], plugins: {}, computed: {}, extras, postscripts: []}
const imports = { const imports = {
plugins: Plugins, plugins: Plugins,
@@ -184,7 +184,7 @@ export default async function metrics({login, q}, {graphql, rest, plugins, conf,
if ((conf.settings?.optimize === true) || (conf.settings?.optimize?.includes?.("svg"))) if ((conf.settings?.optimize === true) || (conf.settings?.optimize?.includes?.("svg")))
rendered = await imports.svg.optimize.svg(rendered, q, experimental) rendered = await imports.svg.optimize.svg(rendered, q, experimental)
//Verify svg //Verify svg
if (verify) { if ((verify)&&(imports.metadata.plugins.core.extras("verify", {...conf.settings, error:false}))) {
console.debug(`metrics/compute/${login} > verify SVG`) console.debug(`metrics/compute/${login} > verify SVG`)
let libxmljs = null let libxmljs = null
try { try {
@@ -281,9 +281,9 @@ metrics.insights.output = async function({login, imports, conf}, {graphql, rest,
console.debug(`metrics/compute/${login} > insights > generating data`) console.debug(`metrics/compute/${login} > insights > generating data`)
const result = await metrics.insights({login}, {graphql, rest, conf}, {Plugins, Templates}) const result = await metrics.insights({login}, {graphql, rest, conf}, {Plugins, Templates})
const json = JSON.stringify(result) const json = JSON.stringify(result)
await page.goto(`${server}/about/${login}?embed=1&localstorage=1`) await page.goto(`${server}/insights/${login}?embed=1&localstorage=1`)
await page.evaluate(async json => localStorage.setItem("local.metrics", json), json) //eslint-disable-line no-undef await page.evaluate(async json => localStorage.setItem("local.metrics", json), json) //eslint-disable-line no-undef
await page.goto(`${server}/about/${login}?embed=1&localstorage=1`) await page.goto(`${server}/insights/${login}?embed=1&localstorage=1`)
await page.waitForSelector(".container .user", {timeout: 10 * 60 * 1000}) await page.waitForSelector(".container .user", {timeout: 10 * 60 * 1000})
//Rendering //Rendering
@@ -297,7 +297,7 @@ metrics.insights.output = async function({login, imports, conf}, {graphql, rest,
</head> </head>
<body> <body>
${await page.evaluate(() => document.querySelector("main").outerHTML)} ${await page.evaluate(() => document.querySelector("main").outerHTML)}
${(await Promise.all([".css/style.vars.css", ".css/style.css", "about/.statics/style.css"].map(path => utils.axios.get(`${server}/${path}`)))).map(({data: style}) => `<style>${style}</style>`).join("\n")} ${(await Promise.all([".css/style.vars.css", ".css/style.css", "insights/.statics/style.css"].map(path => utils.axios.get(`${server}/${path}`)))).map(({data: style}) => `<style>${style}</style>`).join("\n")}
</body> </body>
</html>` </html>`
await browser.close() await browser.close()

View File

@@ -111,7 +111,7 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
if (account !== "bypass") { if (account !== "bypass") {
const context = q.repo ? "repository" : account const context = q.repo ? "repository" : account
if (!meta.supports?.includes(context)) if (!meta.supports?.includes(context))
throw {error: {message: `Not supported for: ${context}`, instance: new Error()}} throw {error: {message: `Unsupported context ${context}`, instance: new Error()}}
} }
//Special values replacer //Special values replacer
const replacer = value => { const replacer = value => {
@@ -214,33 +214,59 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
//Extra features parser //Extra features parser
{ {
meta.extras = function(input, {extras = {}}) { meta.extras = function(input, {extras = {}, error = true}) {
//Required permissions const key = metadata.to.yaml(input, {name})
const required = inputs[metadata.to.yaml(input, {name})]?.extras ?? null try {
if (!required) //Required permissions
const required = inputs[key]?.extras ?? null
if (!required)
return true
console.debug(`metrics/extras > ${name} > ${key} > require [${required}]`)
//Legacy handling
const enabled = extras?.features ?? extras?.default ?? false
if (typeof enabled === "boolean") {
console.debug(`metrics/extras > ${name} > ${key} > extras features is set to ${enabled}`)
if (!enabled)
throw new Error()
return enabled
}
if (!Array.isArray(required)) {
console.debug(`metrics/extras > ${name} > ${key} > extras is not a permission array, skipping`)
return false
}
//Legacy options handling
if (!Array.isArray(extras.features))
throw new Error(`metrics/extras > ${name} > ${key} > extras.features is not an array`)
if (extras.css) {
console.warn(`metrics/extras > ${name} > ${key} > extras.css is deprecated, use extras.features with "metrics.run.puppeteer.user.css" instead`)
extras.features.push("metrics.run.puppeteer.user.css")
}
if (extras.js) {
console.warn(`metrics/extras > ${name} > ${key} > extras.js is deprecated, use extras.features with "metrics.run.puppeteer.user.js" instead`)
extras.features.push("metrics.run.puppeteer.user.js")
}
if (extras.presets) {
console.warn(`metrics/extras > ${name} > ${key} > extras.presets is deprecated, use extras.features with "metrics.setup.community.presets" instead`)
extras.features.push("metrics.setup.community.presets")
}
//Check permissions
const missing = required.filter(permission => !extras.features.includes(permission))
if (missing.length > 0) {
console.debug(`metrics/extras > ${name} > ${key} > missing permissions [${missing}]`)
throw new Error()
}
return true return true
console.debug(`metrics/extras > ${name} > ${input} > require [${required}]`)
//Legacy handling
const enabled = extras?.features ?? extras?.default ?? false
if (typeof enabled === "boolean") {
console.debug(`metrics/extras > ${name} > ${input} > extras features is set to ${enabled}`)
return enabled
} }
if (!Array.isArray(required)) { catch {
console.debug(`metrics/extras > ${name} > ${input} > extras is not a permission array, skipping`) if (!error) {
return false console.debug(`metrics/extras > ${name} > ${key} > skipping (no error mode)`)
return false
}
throw Object.assign(new Error(`Unsupported option "${key}"`), {extras:true})
} }
//Check permissions
if (!Array.isArray(extras.features))
throw new Error(`metrics/extras > ${name} > ${input} > extras.features is not an array`)
const missing = required.filter(permission => !extras.features.includes(permission))
if (missing.length > 0) {
console.debug(`metrics/extras > ${name} > ${input} > missing permissions [${missing}], skipping`)
return false
}
return true
} }
} }
@@ -576,7 +602,9 @@ metadata.to = {
return name ? key.replace(new RegExp(`^(${name}.)`, "g"), "") : key return name ? key.replace(new RegExp(`^(${name}.)`, "g"), "") : key
}, },
yaml(key, {name = ""} = {}) { yaml(key, {name = ""} = {}) {
const parts = [key.replaceAll(".", "_")] const parts = []
if (key !== "enabled")
parts.unshift(key.replaceAll(".", "_"))
if (name) if (name)
parts.unshift((name === "base") ? name : `plugin_${name}`) parts.unshift((name === "base") ? name : `plugin_${name}`)
return parts.join("_") return parts.join("_")

View File

@@ -78,63 +78,68 @@ export default async function({log = true, sandbox = false, community = {}} = {}
logger("metrics/setup > load package.json > success") logger("metrics/setup > load package.json > success")
//Load community templates //Load community templates
if ((typeof conf.settings.community.templates === "string") && (conf.settings.community.templates.length)) { if ((conf.settings.extras?.features?.includes("metrics.setup.community.templates"))||(conf.settings.extras?.features === true)||(conf.settings.extras?.default)) {
logger("metrics/setup > parsing community templates list") if ((typeof conf.settings.community.templates === "string") && (conf.settings.community.templates.length)) {
conf.settings.community.templates = [...new Set([...decodeURIComponent(conf.settings.community.templates).split(",").map(v => v.trim().toLocaleLowerCase()).filter(v => v)])] logger("metrics/setup > parsing community templates list")
} conf.settings.community.templates = [...new Set([...decodeURIComponent(conf.settings.community.templates).split(",").map(v => v.trim().toLocaleLowerCase()).filter(v => v)])]
if ((Array.isArray(conf.settings.community.templates)) && (conf.settings.community.templates.length)) { }
//Clean remote repository if ((Array.isArray(conf.settings.community.templates)) && (conf.settings.community.templates.length)) {
logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`) //Clean remote repository
await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`)
//Download community templates await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true})
for (const template of conf.settings.community.templates) { //Download community templates
try { for (const template of conf.settings.community.templates) {
//Parse community template try {
logger(`metrics/setup > load community template ${template}`) //Parse community template
const {repo, branch, name, trust = false} = template.match(/^(?<repo>[\s\S]+?)@(?<branch>[\s\S]+?):(?<name>[\s\S]+?)(?<trust>[+]trust)?$/)?.groups ?? null logger(`metrics/setup > load community template ${template}`)
const command = `git clone --single-branch --branch ${branch} https://github.com/${repo}.git ${path.join(__templates, ".community")}` const {repo, branch, name, trust = false} = template.match(/^(?<repo>[\s\S]+?)@(?<branch>[\s\S]+?):(?<name>[\s\S]+?)(?<trust>[+]trust)?$/)?.groups ?? null
logger(`metrics/setup > run ${command}`) const command = `git clone --single-branch --branch ${branch} https://github.com/${repo}.git ${path.join(__templates, ".community")}`
//Clone remote repository logger(`metrics/setup > run ${command}`)
processes.execSync(command, {stdio: "ignore"}) //Clone remote repository
//Extract template processes.execSync(command, {stdio: "ignore"})
logger(`metrics/setup > extract ${name} from ${repo}@${branch}`) //Extract template
await fs.promises.rm(path.join(__templates, `@${name}`), {recursive: true, force: true}) logger(`metrics/setup > extract ${name} from ${repo}@${branch}`)
await fs.promises.rename(path.join(__templates, ".community/source/templates", name), path.join(__templates, `@${name}`)) await fs.promises.rm(path.join(__templates, `@${name}`), {recursive: true, force: true})
//JavaScript file await fs.promises.rename(path.join(__templates, ".community/source/templates", name), path.join(__templates, `@${name}`))
if (trust) //JavaScript file
logger(`metrics/setup > keeping @${name}/template.mjs (unsafe mode is enabled)`) if (trust)
else if (fs.existsSync(path.join(__templates, `@${name}`, "template.mjs"))) { logger(`metrics/setup > keeping @${name}/template.mjs (unsafe mode is enabled)`)
logger(`metrics/setup > removing @${name}/template.mjs`) else if (fs.existsSync(path.join(__templates, `@${name}`, "template.mjs"))) {
await fs.promises.unlink(path.join(__templates, `@${name}`, "template.mjs")) logger(`metrics/setup > removing @${name}/template.mjs`)
const inherit = yaml.load(`${fs.promises.readFile(path.join(__templates, `@${name}`, "metadata.yml"))}`).extends ?? null await fs.promises.unlink(path.join(__templates, `@${name}`, "template.mjs"))
if (inherit) { const inherit = yaml.load(`${fs.promises.readFile(path.join(__templates, `@${name}`, "metadata.yml"))}`).extends ?? null
logger(`metrics/setup > @${name} extends from ${inherit}`) if (inherit) {
if (fs.existsSync(path.join(__templates, inherit, "template.mjs"))) { logger(`metrics/setup > @${name} extends from ${inherit}`)
logger(`metrics/setup > @${name} extended from ${inherit}`) if (fs.existsSync(path.join(__templates, inherit, "template.mjs"))) {
await fs.promises.copyFile(path.join(__templates, inherit, "template.mjs"), path.join(__templates, `@${name}`, "template.mjs")) logger(`metrics/setup > @${name} extended from ${inherit}`)
} await fs.promises.copyFile(path.join(__templates, inherit, "template.mjs"), path.join(__templates, `@${name}`, "template.mjs"))
else { }
logger(`metrics/setup > @${name} could not extends ${inherit} as it does not exist`) else {
logger(`metrics/setup > @${name} could not extends ${inherit} as it does not exist`)
}
} }
} }
} else {
else { logger(`metrics/setup > @${name}/template.mjs does not exist`)
logger(`metrics/setup > @${name}/template.mjs does not exist`) }
}
//Clean remote repository //Clean remote repository
logger(`metrics/setup > clean ${repo}@${branch}`) logger(`metrics/setup > clean ${repo}@${branch}`)
await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true})
logger(`metrics/setup > loaded community template ${name}`) logger(`metrics/setup > loaded community template ${name}`)
} }
catch (error) { catch (error) {
logger(`metrics/setup > failed to load community template ${template}`) logger(`metrics/setup > failed to load community template ${template}`)
logger(error) logger(error)
}
} }
} }
else {
logger("metrics/setup > no community templates to install")
}
} }
else { else {
logger("metrics/setup > no community templates to install") logger("metrics/setup > community templates are disabled")
} }
//Load templates //Load templates
@@ -188,6 +193,18 @@ export default async function({log = true, sandbox = false, community = {}} = {}
//Load metadata //Load metadata
conf.metadata = await metadata({log}) conf.metadata = await metadata({log})
//Modes
if ((!conf.settings.modes)||(!conf.settings.modes.length))
conf.settings.modes = ["embed", "insights"]
logger(`metrics/setup > setup > enabled modes ${JSON.stringify(conf.settings.modes)}`)
//Allowed outputs formats
if ((!conf.settings.outputs)||(!conf.settings.outputs.length))
conf.settings.outputs = metadata.inputs.config_output.values
else
conf.settings.outputs = conf.settings.outputs.filter(format => metadata.inputs.config_output.values.includes(format))
logger(`metrics/setup > setup > allowed outputs ${JSON.stringify(conf.settings.outputs)}`)
//Store authenticated user //Store authenticated user
if (conf.settings.token) { if (conf.settings.token) {
try { try {

View File

@@ -125,6 +125,49 @@ export function formatters({timeZone} = {}) {
return license.nickname ?? license.spdxId ?? license.name return license.nickname ?? license.spdxId ?? license.name
} }
/**Error formatter */
format.error = function(error, {descriptions = {}, ...attributes} = {}) {
try {
//Extras features error
if (error.extras)
throw {error: {message: error.message, instance: error}}
//Already formatted error
if (error.error?.message)
throw error
//Custom description
let message = "Unexpected error"
if (descriptions.custom) {
const description = descriptions.custom(error)
if (description)
message += ` (${description})`
}
//Axios error
if (error.isAxiosError) {
//Error code
const status = error.response?.status
message = `API error: ${status}`
//Error description (optional)
if ((descriptions)&&(descriptions[status]))
message += ` (${descriptions[status]})`
else {
const description = error.response?.data?.errors?.[0]?.message ?? error.response.data?.error_description ?? error.response?.data?.message ?? null
if (description)
message += ` (${description})`
}
//Error data
console.debug(error.response.data)
error = error.response?.data ?? null
throw {error: {message, instance: error}}
}
throw {error: {message, instance: error}}
}
catch (error) {
return Object.assign(error, attributes)
}
}
return {format} return {format}
} }

View File

@@ -130,15 +130,12 @@ export default async function({sandbox = false} = {}) {
app.get("/.templates/:template", limiter, (req, res) => req.params.template in conf.templates ? res.status(200).json(conf.templates[req.params.template]) : res.sendStatus(404)) app.get("/.templates/:template", limiter, (req, res) => req.params.template in conf.templates ? res.status(200).json(conf.templates[req.params.template]) : res.sendStatus(404))
for (const template in conf.templates) for (const template in conf.templates)
app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`)) app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`))
//Placeholders
app.use("/.placeholders", express.static(`${conf.paths.statics}/placeholders`))
//Styles //Styles
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.css`)) app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.css`))
app.get("/.css/style.vars.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.vars.css`)) app.get("/.css/style.vars.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.vars.css`))
app.get("/.css/style.prism.css", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/themes/prism-tomorrow.css`)) app.get("/.css/style.prism.css", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/themes/prism-tomorrow.css`))
//Scripts //Scripts
app.get("/.js/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/app.js`)) app.get("/.js/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/app.js`))
app.get("/.js/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/app.placeholder.js`))
app.get("/.js/ejs.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/ejs/ejs.min.js`)) app.get("/.js/ejs.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/ejs/ejs.min.js`))
app.get("/.js/faker.min.js", limiter, (req, res) => res.set({"Content-Type": "text/javascript"}).send("import {faker} from '/.js/faker/index.mjs';globalThis.faker=faker;globalThis.placeholder.init(globalThis)")) app.get("/.js/faker.min.js", limiter, (req, res) => res.set({"Content-Type": "text/javascript"}).send("import {faker} from '/.js/faker/index.mjs';globalThis.faker=faker;globalThis.placeholder.init(globalThis)"))
app.use("/.js/faker", express.static(`${conf.paths.node_modules}/@faker-js/faker/dist/esm`)) app.use("/.js/faker", express.static(`${conf.paths.node_modules}/@faker-js/faker/dist/esm`))
@@ -176,217 +173,258 @@ export default async function({sandbox = false} = {}) {
//Pending requests //Pending requests
const pending = new Map() const pending = new Map()
//About routes //Metrics insights
app.use("/about/.statics/", express.static(`${conf.paths.statics}/about`)) if (conf.settings.modes.includes("insights")) {
app.get("/about/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) console.debug("metrics/app > setup insights mode")
app.get("/about/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) //Legacy routes
app.get("/about/:login", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) app.get("/about/*", (req, res) => res.redirect(req.path.replace("/about/", "/insights/")))
app.get("/about/query/:login/:plugin/", async (req, res) => { //Static routes
//Check username app.get("/insights/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/insights/index.html`))
const login = req.params.login?.replace(/[\n\r]/g, "") app.get("/insights/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/insights/index.html`))
if (!/^[-\w]+$/i.test(login)) { app.use("/insights/.statics/", express.static(`${conf.paths.statics}/insights`))
console.debug(`metrics/app/${login}/insights > 400 (invalid username)`) //App routes
return res.status(400).send("Bad request: username seems invalid") app.get("/insights/:login", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/insights/index.html`))
} app.get("/insights/query/:login/:plugin/", async (req, res) => {
//Check plugin //Check username
const plugin = req.params.plugin?.replace(/[\n\r]/g, "") const login = req.params.login?.replace(/[\n\r]/g, "")
if (!/^\w+$/i.test(plugin)) { if (!/^[-\w]+$/i.test(login)) {
console.debug(`metrics/app/${login}/insights > 400 (invalid plugin name)`) console.debug(`metrics/app/${login}/insights > 400 (invalid username)`)
return res.status(400).send("Bad request: plugin name seems invalid") return res.status(400).send("Bad request: username seems invalid")
}
if (cache.get(`about.${login}.${plugin}`))
return res.send(cache.get(`about.${login}.${plugin}`))
return res.status(204).send("No content: no data fetched yet")
})
app.get("/about/query/:login/", ...middlewares, async (req, res) => {
//Check username
const login = req.params.login?.replace(/[\n\r]/g, "")
if (!/^[-\w]+$/i.test(login)) {
console.debug(`metrics/app/${login}/insights > 400 (invalid username)`)
return res.status(400).send("Bad request: username seems invalid")
}
//Compute metrics
let solve = null
try {
//Prevent multiples requests
if ((!debug) && (!mock) && (pending.has(`about.${login}`))) {
console.debug(`metrics/app/${login}/insights > awaiting pending request`)
await pending.get(`about.${login}`)
} }
else { //Check plugin
pending.set(`about.${login}`, new Promise(_solve => solve = _solve)) const plugin = req.params.plugin?.replace(/[\n\r]/g, "")
if (!/^\w+$/i.test(plugin)) {
console.debug(`metrics/app/${login}/insights > 400 (invalid plugin name)`)
return res.status(400).send("Bad request: plugin name seems invalid")
} }
//Read cached data if possible if (cache.get(`insights.${login}.${plugin}`))
if ((!debug) && (cached) && (cache.get(`about.${login}`))) { return res.send(cache.get(`insights.${login}.${plugin}`))
console.debug(`metrics/app/${login}/insights > using cached results`) return res.status(204).send("No content: no data fetched yet")
return res.send(cache.get(`about.${login}`)) })
app.get("/insights/query/:login/", ...middlewares, async (req, res) => {
//Check username
const login = req.params.login?.replace(/[\n\r]/g, "")
if (!/^[-\w]+$/i.test(login)) {
console.debug(`metrics/app/${login}/insights > 400 (invalid username)`)
return res.status(400).send("Bad request: username seems invalid")
} }
//Compute metrics //Compute metrics
console.debug(`metrics/app/${login}/insights > compute insights`) let solve = null
const callbacks = { try {
async plugin(login, plugin, success, result) { //Prevent multiples requests
console.debug(`metrics/app/${login}/insights/plugins > ${plugin} > ${success ? "success" : "failure"}`) if ((!debug) && (!mock) && (pending.has(`insights.${login}`))) {
cache.put(`about.${login}.${plugin}`, result) console.debug(`metrics/app/${login}/insights > awaiting pending request`)
}, await pending.get(`insights.${login}`)
} }
;(async () => { else {
try { pending.set(`insights.${login}`, new Promise(_solve => solve = _solve))
const json = await metrics.insights({login}, {graphql, rest, conf, callbacks}, {Plugins, Templates}) }
//Cache //Read cached data if possible
cache.put(`about.${login}`, json) if ((!debug) && (cached) && (cache.get(`insights.${login}`))) {
if ((!debug) && (cached)) { console.debug(`metrics/app/${login}/insights > using cached results`)
const maxage = Math.round(Number(req.query.cache)) return res.send(cache.get(`insights.${login}`))
cache.put(`about.${login}`, json, maxage > 0 ? maxage : cached) }
//Compute metrics
console.debug(`metrics/app/${login}/insights > compute insights`)
const callbacks = {
async plugin(login, plugin, success, result) {
console.debug(`metrics/app/${login}/insights/plugins > ${plugin} > ${success ? "success" : "failure"}`)
cache.put(`insights.${login}.${plugin}`, result)
},
}
;(async () => {
try {
const json = await metrics.insights({login}, {graphql, rest, conf, callbacks}, {Plugins, Templates})
//Cache
cache.put(`insights.${login}`, json)
if ((!debug) && (cached)) {
const maxage = Math.round(Number(req.query.cache))
cache.put(`insights.${login}`, json, maxage > 0 ? maxage : cached)
}
} }
catch (error) {
console.error(`metrics/app/${login}/insights > error > ${error}`)
}
})()
console.debug(`metrics/app/${login}/insights > accepted request`)
return res.status(202).json({processing: true, plugins: Object.keys(metrics.insights.plugins)})
}
//Internal error
catch (error) {
//Not found user
if ((error instanceof Error) && (/^user not found$/.test(error.message))) {
console.debug(`metrics/app/${login} > 404 (user/organization not found)`)
return res.status(404).send("Not found: unknown user or organization")
} }
catch (error) { //GitHub failed request
console.error(`metrics/app/${login}/insights > error > ${error}`) if ((error instanceof Error) && (/this may be the result of a timeout, or it could be a GitHub bug/i.test(error.errors?.[0]?.message))) {
console.debug(`metrics/app/${login} > 502 (bad gateway from GitHub)`)
const request = encodeURIComponent(error.errors[0].message.match(/`(?<request>[\w:]+)`/)?.groups?.request ?? "").replace(/%3A/g, ":")
return res.status(500).send(`Internal Server Error: failed to execute request ${request} (this may be the result of a timeout, or it could be a GitHub bug)`)
} }
})() //General error
console.debug(`metrics/app/${login}/insights > accepted request`) console.error(error)
return res.status(202).json({processing: true, plugins: Object.keys(metrics.insights.plugins)}) return res.status(500).send("Internal Server Error: failed to process metrics correctly")
}
//Internal error
catch (error) {
//Not found user
if ((error instanceof Error) && (/^user not found$/.test(error.message))) {
console.debug(`metrics/app/${login} > 404 (user/organization not found)`)
return res.status(404).send("Not found: unknown user or organization")
} }
//GitHub failed request finally {
if ((error instanceof Error) && (/this may be the result of a timeout, or it could be a GitHub bug/i.test(error.errors?.[0]?.message))) { solve?.()
console.debug(`metrics/app/${login} > 502 (bad gateway from GitHub)`) _requests_refresh = true
const request = encodeURIComponent(error.errors[0].message.match(/`(?<request>[\w:]+)`/)?.groups?.request ?? "").replace(/%3A/g, ":")
return res.status(500).send(`Internal Server Error: failed to execute request ${request} (this may be the result of a timeout, or it could be a GitHub bug)`)
} }
//General error })
console.error(error) }
return res.status(500).send("Internal Server Error: failed to process metrics correctly") else {
} app.get("/about/*", (req, res) => res.redirect(req.path.replace("/about/", "/insights/")))
finally { app.get("/insights/*", (req, res) => res.status(405).send("Method not allowed: this endpoint is not available"))
solve?.() }
_requests_refresh = true
}
})
//Metrics //Metrics embed
app.get("/:login/:repository?", ...middlewares, async (req, res) => { if (conf.settings.modes.includes("embed")) {
//Request params console.debug("metrics/app > setup embed mode")
const login = req.params.login?.replace(/[\n\r]/g, "") //Static routes
const repository = req.params.repository?.replace(/[\n\r]/g, "") app.get("/embed/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/index.html`))
let solve = null app.get("/embed/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/index.html`))
//Check username app.use("/.placeholders", express.static(`${conf.paths.statics}/embed/placeholders`))
if (!/^[-\w]+$/i.test(login)) { app.get("/.js/embed/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.js`))
console.debug(`metrics/app/${login} > 400 (invalid username)`) app.get("/.js/embed/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.placeholder.js`))
return res.status(400).send("Bad request: username seems invalid") //App routes
} app.get("/:login/:repository?", ...middlewares, async (req, res) => {
//Allowed list check //Request params
if ((restricted.length) && (!restricted.includes(login))) { const login = req.params.login?.replace(/[\n\r]/g, "")
console.debug(`metrics/app/${login} > 403 (not in allowed users)`) const repository = req.params.repository?.replace(/[\n\r]/g, "")
return res.status(403).send("Forbidden: username not in allowed list") let solve = null
} //Check username
//Prevent multiples requests if (!/^[-\w]+$/i.test(login)) {
if ((!debug) && (!mock) && (pending.has(login))) { console.debug(`metrics/app/${login} > 400 (invalid username)`)
console.debug(`metrics/app/${login} > awaiting pending request`) return res.status(400).send("Bad request: username seems invalid")
await pending.get(login) }
} //Allowed list check
else { if ((restricted.length) && (!restricted.includes(login))) {
pending.set(login, new Promise(_solve => solve = _solve)) console.debug(`metrics/app/${login} > 403 (not in allowed users)`)
} return res.status(403).send("Forbidden: username not in allowed list")
}
//Prevent multiples requests
if ((!debug) && (!mock) && (pending.has(login))) {
console.debug(`metrics/app/${login} > awaiting pending request`)
await pending.get(login)
}
else {
pending.set(login, new Promise(_solve => solve = _solve))
}
//Read cached data if possible //Read cached data if possible
if ((!debug) && (cached) && (cache.get(login))) { if ((!debug) && (cached) && (cache.get(login))) {
console.debug(`metrics/app/${login} > using cached image`) console.debug(`metrics/app/${login} > using cached image`)
const {rendered, mime} = cache.get(login) const {rendered, mime} = cache.get(login)
res.header("Content-Type", mime) res.header("Content-Type", mime)
return res.send(rendered) return res.send(rendered)
} }
//Maximum simultaneous users //Maximum simultaneous users
if ((maxusers) && (cache.size() + 1 > maxusers)) { if ((maxusers) && (cache.size() + 1 > maxusers)) {
console.debug(`metrics/app/${login} > 503 (maximum users reached)`) console.debug(`metrics/app/${login} > 503 (maximum users reached)`)
return res.status(503).send("Service Unavailable: maximum number of users reached, only cached metrics are available") return res.status(503).send("Service Unavailable: maximum number of users reached, only cached metrics are available")
} }
//Repository alias //Repository alias
if (repository) { if (repository) {
console.debug(`metrics/app/${login} > compute repository metrics`) console.debug(`metrics/app/${login} > compute repository metrics`)
if (!req.query.template) if (!req.query.template)
req.query.template = "repository" req.query.template = "repository"
req.query.repo = repository req.query.repo = repository
} }
//Compute rendering //Compute rendering
try { try {
//Render //Render
const q = req.query const q = req.query
console.debug(`metrics/app/${login} > ${util.inspect(q, {depth: Infinity, maxStringLength: 256})}`) console.debug(`metrics/app/${login} > ${util.inspect(q, {depth: Infinity, maxStringLength: 256})}`)
if ((q["config.presets"]) && (conf.settings.extras?.presets ?? conf.settings.extras?.default ?? false)) { if ((q["config.presets"]) && ((conf.settings.extras?.features?.includes("metrics.setup.community.presets"))||(conf.settings.extras?.features === true)||(conf.settings.extras?.default))) {
console.debug(`metrics/app/${login} > presets have been specified, loading them`) console.debug(`metrics/app/${login} > presets have been specified, loading them`)
Object.assign(q, await presets(q["config.presets"])) Object.assign(q, await presets(q["config.presets"]))
}
const convert = conf.settings.outputs.includes(q["config.output"]) ? q["config.output"] : conf.settings.outputs[0]
const {rendered, mime} = await metrics({login, q}, {
graphql,
rest,
plugins,
conf,
die: q["plugins.errors.fatal"] ?? false,
verify: q.verify ?? false,
convert: convert !== "auto" ? convert : null,
}, {Plugins, Templates})
//Cache
if ((!debug) && (cached)) {
const maxage = Math.round(Number(req.query.cache))
cache.put(login, {rendered, mime}, maxage > 0 ? maxage : cached)
}
//Send response
res.header("Content-Type", mime)
return res.send(rendered)
} }
const {rendered, mime} = await metrics({login, q}, { //Internal error
graphql, catch (error) {
rest, //Not found user
plugins, if ((error instanceof Error) && (/^user not found$/.test(error.message))) {
conf, console.debug(`metrics/app/${login} > 404 (user/organization not found)`)
die: q["plugins.errors.fatal"] ?? false, return res.status(404).send("Not found: unknown user or organization")
verify: q.verify ?? false, }
convert: ["svg", "jpeg", "png", "json", "markdown", "markdown-pdf", "insights"].includes(q["config.output"]) ? q["config.output"] : null, //Invalid template
}, {Plugins, Templates}) if ((error instanceof Error) && (/^unsupported template$/.test(error.message))) {
//Cache console.debug(`metrics/app/${login} > 400 (bad request)`)
if ((!debug) && (cached)) { return res.status(400).send("Bad request: unsupported template")
const maxage = Math.round(Number(req.query.cache)) }
cache.put(login, {rendered, mime}, maxage > 0 ? maxage : cached) //Unsupported output format or account type
if ((error instanceof Error) && (/^not supported for: [\s\S]*$/.test(error.message))) {
console.debug(`metrics/app/${login} > 406 (Not Acceptable)`)
return res.status(406).send("Not Acceptable: unsupported output format or account type for specified parameters")
}
//GitHub failed request
if ((error instanceof Error) && (/this may be the result of a timeout, or it could be a GitHub bug/i.test(error.errors?.[0]?.message))) {
console.debug(`metrics/app/${login} > 502 (bad gateway from GitHub)`)
const request = encodeURIComponent(error.errors[0].message.match(/`(?<request>[\w:]+)`/)?.groups?.request ?? "").replace(/%3A/g, ":")
return res.status(500).send(`Internal Server Error: failed to execute request ${request} (this may be the result of a timeout, or it could be a GitHub bug)`)
}
//General error
console.error(error)
return res.status(500).send("Internal Server Error: failed to process metrics correctly")
} }
//Send response finally {
res.header("Content-Type", mime) //After rendering
return res.send(rendered) solve?.()
} _requests_refresh = true
//Internal error
catch (error) {
//Not found user
if ((error instanceof Error) && (/^user not found$/.test(error.message))) {
console.debug(`metrics/app/${login} > 404 (user/organization not found)`)
return res.status(404).send("Not found: unknown user or organization")
} }
//Invalid template })
if ((error instanceof Error) && (/^unsupported template$/.test(error.message))) { }
console.debug(`metrics/app/${login} > 400 (bad request)`) else {
return res.status(400).send("Bad request: unsupported template") app.get("/embed/*", (req, res) => res.status(405).send("Method not allowed: this endpoint is not available"))
} }
//Unsupported output format or account type
if ((error instanceof Error) && (/^not supported for: [\s\S]*$/.test(error.message))) {
console.debug(`metrics/app/${login} > 406 (Not Acceptable)`)
return res.status(406).send("Not Acceptable: unsupported output format or account type for specified parameters")
}
//GitHub failed request
if ((error instanceof Error) && (/this may be the result of a timeout, or it could be a GitHub bug/i.test(error.errors?.[0]?.message))) {
console.debug(`metrics/app/${login} > 502 (bad gateway from GitHub)`)
const request = encodeURIComponent(error.errors[0].message.match(/`(?<request>[\w:]+)`/)?.groups?.request ?? "").replace(/%3A/g, ":")
return res.status(500).send(`Internal Server Error: failed to execute request ${request} (this may be the result of a timeout, or it could be a GitHub bug)`)
}
//General error
console.error(error)
return res.status(500).send("Internal Server Error: failed to process metrics correctly")
}
finally {
//After rendering
solve?.()
_requests_refresh = true
}
})
//Listen //Listen
app.listen(port, () => app.listen(port, () =>
console.log([ console.log([
`Listening on port │ ${port}`, "───────────────────────────────────────────────────────────────────",
`Debug mode │ ${debug}`, "── Server configuration ───────────────────────────────────────────",
`Mocked data │ ${conf.settings.mocked ?? false}`, `Listening on port │ ${port}`,
`Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`, `Modes │ ${conf.settings.modes}`,
`Cached time │ ${cached} seconds`, "── Server capacity ───────────────────────────────────────────────",
`Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth: Infinity, maxStringLength: 256}) : "(enabled)"}`, `Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`,
`Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`, `Max simultaneous users ${maxusers ? `${maxusers} users` : "(unrestricted)"}`,
`Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`, `Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth: Infinity, maxStringLength: 256}) : "(enabled)"}`,
`SVG optimization ${conf.settings.optimize ?? false}`, `Max repositories per user${conf.settings.repositories}`,
"── Render settings ────────────────────────────────────────────────",
`Cached time │ ${cached} seconds`,
`SVG optimization │ ${conf.settings.optimize ?? false}`,
`Allowed outputs │ ${conf.settings.outputs.join(", ")}`,
`Padding │ ${conf.settings.padding}`,
"── Sandbox ────────────────────────────────────────────────────────",
`Debug │ ${debug}`,
`Debug (puppeteer) │ ${conf.settings["debug.headless"] ?? false}`,
`Mocked data │ ${conf.settings.mocked ?? false}`,
"── Content ────────────────────────────────────────────────────────",
`Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`,
`Templates enabled │ ${templates.filter(({enabled}) => enabled).map(({name}) => name).join(", ")}`,
"── Extras ─────────────────────────────────────────────────────────",
`Default │ ${conf.settings.extras.default ?? false}`,
`Features │ ${conf.settings.extras.features ?? "(none)"}`,
"───────────────────────────────────────────────────────────────────",
"Server ready !", "Server ready !",
].join("\n"))) ].join("\n")))
} }

View File

@@ -2,38 +2,53 @@
"//": "Example of configuration for metrics web instance", "//": "Example of configuration for metrics web instance",
"//": "====================================================================", "//": "====================================================================",
"token": "MY GITHUB API TOKEN", "//": "GitHub Personal Token (required)", "token": "GITHUB API TOKEN", "//": "GitHub Personal Token (required)",
"restricted": [], "//": "Authorized users (empty to disable)", "modes": ["embed", "insights"], "//": "Web instance enabled modes",
"maxusers": 0, "//": "Maximum users, (0 to disable)", "restricted": [], "//": "Authorized users (empty to disable)",
"cached": 3600000, "//": "Cache time rendered metrics (0 to disable)", "maxusers": 0, "//": "Maximum users, (0 to disable)",
"ratelimiter": null, "//": "Rate limiter (see express-rate-limit documentation)", "cached": 3600000, "//": "Cache time rendered metrics (0 to disable)",
"port": 3000, "//": "Listening port", "ratelimiter": null, "//": "Rate limiter (see express-rate-limit documentation)",
"optimize": true, "//": "SVG optimization", "port": 3000, "//": "Listening port",
"debug": false, "//": "Debug logs", "optimize": true, "//": "SVG optimization",
"debug.headless": false, "//": "Debug puppeteer process", "debug": false, "//": "Debug logs",
"mocked": false, "//": "Use mocked data instead of live APIs (use 'force' to use mocked token even if real token are defined)", "debug.headless": false, "//": "Debug puppeteer process",
"repositories": 100, "//": "Number of repositories to use", "mocked": false, "//": "Use mocked data instead of live APIs (use 'force' to use mocked token even if real token are defined)",
"padding": ["0", "8 + 11%"], "//": "Image padding (default)", "repositories": 100, "//": "Number of repositories to use",
"padding": ["0", "8 + 11%"], "//": "Image padding (default)",
"outputs": ["svg", "png", "json"], "//": "Image output formats (empty to enable all)",
"hosted": { "hosted": {
"by": "", "//": "Web instance host (displayed in footer)", "by": "", "//": "Web instance host (displayed in footer)",
"link": "", "//": "Web instance host link (displayed in footer)" "link": "", "//": "Web instance host link (displayed in footer)"
}, },
"community": { "community": {
"templates": [], "//": "Additional community templates to setup" "templates": [], "//": "Additional community templates to setup"
}, },
"templates": { "templates": {
"default": "classic", "//": "Default template", "default": "classic", "//": "Default template",
"enabled": [], "//": "Enabled templates (empty to enable all)" "enabled": [], "//": "Enabled templates (empty to enable all)"
}, },
"extras": { "extras": {
"default": false, "//": "Default extras state (advised to let 'false' unless in debug mode)", "default": false, "//": "Default extras state (advised to let 'false' unless in debug mode)",
"presets": false, "//": "Allow use of 'config.presets' option", "presets": false, "//": "Allow use of 'config.presets' option",
"css": false, "//": "Allow use of 'extras.css' option", "features": false, "//": "Enable extra features (advised to let 'false' on web instances), see below for supported features",
"js": false, "//": "Allow use of 'extras.js' option", "//": "________________________________________________________________________",
"features": false, "//": "Enable extra features (advised to let 'false' on web instances)" "//": "metrics.setup.community.templates | Allow community templates download",
"//": "metrics.setup.community.presets | Allow community presets usage",
"//": "metrics.api.github.overuse | Allow GitHub API intensive requests",
"//": "metrics.cpu.overuse | Allow CPU intensive requests",
"//": "metrics.run.tempdir | Allow access to temporary directory (I/O operations may be performed)",
"//": "metrics.run.git | Allow to run git (needs to be installed)",
"//": "metrics.run.licensed | Allow to run licensed (needs to be installed)",
"//": "metrics.run.user.cmd | Allow to run ANY command by user (USE WITH CAUTION!)",
"//": "metrics.run.puppeteer.scrapping | Allow to run puppeteer to scrape data",
"//": "metrics.run.puppeteer.user.css | Allow to run CSS by user during puppeteer render",
"//": "metrics.run.puppeteer.user.js | Allow to run JavaScript by user during puppeteer render",
"//": "metrics.npm.optional.chartist | Allow use of chartist (needs to be installed)",
"//": "metrics.npm.optional.gifencoder | Allow use of gifencoder (needs to be installed)",
"//": "metrics.npm.optional.libxmljs2 | Allow use of libxmljs2 (needs to be installed)"
}, },
"plugins.default": false, "//": "Default plugin state (advised to let 'false' unless in debug mode)", "plugins.default": false, "//": "Default plugin state (advised to let 'false' unless in debug mode)",
"plugins": { "//": "Global plugin configuration", "plugins": { "//": "Global plugin configuration",
<% for (const name of Object.keys(plugins).filter(v => !["base", "core"].includes(v))) { -%> <% for (const name of Object.keys(plugins).filter(v => !["base", "core"].includes(v))) { -%>
"<%= name %>":{ "<%= name %>":{
<%- JSON.stringify(Object.fromEntries(Object.entries(plugins[name].inputs).filter(([key, {type}]) => type === "token").map(([key, {description:value}]) => [key.replace(new RegExp(`^plugin_${name}_`), ""), value.split("\n")[0].trim()])), null, 6).replace(/^[{]/gm, "").replace(/^\s*[}]$/gm, "").replace(/": "/gm, `${'": null,'.padEnd(22)} "//": "`).replace(/"$/gm, '",').trimStart().replace(/\n$/gm, "\n ") %>"enabled": false, "//": "<%= plugins[name].inputs[`plugin_${name}`].description.trim() %>" <%- JSON.stringify(Object.fromEntries(Object.entries(plugins[name].inputs).filter(([key, {type}]) => type === "token").map(([key, {description:value}]) => [key.replace(new RegExp(`^plugin_${name}_`), ""), value.split("\n")[0].trim()])), null, 6).replace(/^[{]/gm, "").replace(/^\s*[}]$/gm, "").replace(/": "/gm, `${'": null,'.padEnd(22)} "//": "`).replace(/"$/gm, '",').trimStart().replace(/\n$/gm, "\n ") %>"enabled": false, "//": "<%= plugins[name].inputs[`plugin_${name}`].description.trim() %>"

View File

@@ -1,8 +1,4 @@
;(async function() { ;(async function() {
//Init
const {data: metadata} = await axios.get("/.plugins.metadata")
delete metadata.core.web.output
delete metadata.core.web.twemojis
//App //App
return new Vue({ return new Vue({
//Initialization //Initialization
@@ -62,7 +58,6 @@
} }
}, 100) }, 100)
}, },
components: {Prism: PrismComponent},
//Watchers //Watchers
watch: { watch: {
tab: { tab: {
@@ -86,244 +81,20 @@
data: { data: {
version: "", version: "",
user: "", user: "",
mode: "metrics",
tab: "overview", tab: "overview",
palette: "light", palette: "light",
clipboard: null, clipboard: null,
requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}}, requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}},
cached: new Map(), cached: new Map(),
config: Object.fromEntries(Object.entries(metadata.core.web).map(([key, {defaulted}]) => [key, defaulted])),
metadata: Object.fromEntries(Object.entries(metadata).map(([key, {web}]) => [key, web])),
hosted: null, hosted: null,
docs: {
overview: {
link: "https://github.com/lowlighter/metrics#-documentation",
name: "Complete documentation",
},
markdown: {
link: "https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/shared.md",
name: "Setup using the shared instance",
},
action: {
link: "https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md",
name: "Setup using GitHub Action on a profile repository",
},
},
plugins: {
base: {},
list: [],
categories: [],
enabled: {},
descriptions: {
base: "🗃️ Base content",
"base.header": "Header",
"base.activity": "Account activity",
"base.community": "Community stats",
"base.repositories": "Repositories metrics",
"base.metadata": "Metadata",
...Object.fromEntries(Object.entries(metadata).map(([key, {name}]) => [key, name])),
},
options: {
descriptions: {...(Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)))},
...(Object.fromEntries(
Object.entries(
Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)),
)
.map(([key, {defaulted}]) => [key, defaulted]),
)),
},
},
templates: {
list: [],
selected: "classic",
placeholder: {
timeout: null,
image: "",
},
descriptions: {
classic: "Classic template",
terminal: "Terminal template",
markdown: "(hidden)",
repository: "(hidden)",
},
},
generated: {
pending: false,
content: "",
error: false,
},
}, },
//Computed data //Computed data
computed: { computed: {
//Unusable plugins
unusable() {
return this.plugins.list.filter(({name}) => this.plugins.enabled[name]).filter(({enabled}) => !enabled).map(({name}) => name)
},
//User's avatar
avatar() {
return this.generated.content ? `https://github.com/${this.user}.png` : null
},
//User's repository
repo() {
return `https://github.com/${this.user}/${this.user}`
},
//Endpoint to use for computed metrics
url() {
//Plugins enabled
const plugins = Object.entries(this.plugins.enabled)
.flatMap(([key, value]) => key === "base" ? Object.entries(value).map(([key, value]) => [`base.${key}`, value]) : [[key, value]])
.filter(([key, value]) => /^base[.]\w+$/.test(key) ? !value : value)
.map(([key, value]) => `${key}=${+value}`)
//Plugins options
const options = Object.entries(this.plugins.options)
.filter(([key, value]) => `${value}`.length)
.filter(([key, value]) => this.plugins.enabled[key.split(".")[0]])
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
//Base options
const base = Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web) && (value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
//Config
const config = Object.entries(this.config).filter(([key, value]) => (value) && (value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`)
//Template
const template = (this.templates.selected !== this.templates.list[0]) ? [`template=${this.templates.selected}`] : []
//Generated url
const params = [...template, ...base, ...plugins, ...options, ...config].join("&")
return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}`
},
//Embedded generated code
embed() {
return `![Metrics](${this.url})`
},
//Token scopes
scopes() {
return new Set([
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base") && (value)).flatMap(([key]) => metadata[key].scopes),
...(Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).length ? metadata.base.scopes : []),
])
},
//GitHub action auto-generated code
action() {
return [
`# Visit https://github.com/lowlighter/metrics/blob/master/action.yml for full reference`,
`name: Metrics`,
`on:`,
` # Schedule updates (each hour)`,
` schedule: [{cron: "0 * * * *"}]`,
` # Lines below let you run workflow manually and on each commit`,
` workflow_dispatch:`,
` push: {branches: ["master", "main"]}`,
`jobs:`,
` github-metrics:`,
` runs-on: ubuntu-latest`,
` permissions:`,
` contents: write`,
` steps:`,
` - uses: lowlighter/metrics@latest`,
` with:`,
...(this.scopes.size
? [
` # Your GitHub token`,
` # The following scopes are required:`,
...[...this.scopes].map(scope => ` # - ${scope}${scope === "public_access" ? " (default scope)" : ""}`),
` # The following additional scopes may be required:`,
` # - read:org (for organization related metrics)`,
` # - read:user (for user related data)`,
` # - read:packages (for some packages related data)`,
` # - repo (optional, if you want to include private repositories)`,
]
: [
` # Current configuration doesn't require a GitHub token`,
]),
` token: ${this.scopes.size ? `${"$"}{{ secrets.METRICS_TOKEN }}` : "NOT_NEEDED"}`,
``,
` # Options`,
` user: ${this.user}`,
` template: ${this.templates.selected}`,
` base: ${Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).map(([key]) => key).join(", ") || '""'}`,
...[
...Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web) && (value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => ` ${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base") && (value)).map(([key]) => ` plugin_${key}: yes`),
...Object.entries(this.plugins.options).filter(([key, value]) => (value) && (!(key in metadata.base.web))).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
...Object.entries(this.config).filter(([key, value]) => (value) && (value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => ` config_${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
].sort(),
].join("\n")
},
//Configurable plugins
configure() {
//Check enabled plugins
const enabled = Object.entries(this.plugins.enabled).filter(([key, value]) => (value) && (key !== "base")).map(([key, value]) => key)
const filter = new RegExp(`^(?:${enabled.join("|")})[.]`)
//Search related options
const entries = Object.entries(this.plugins.options.descriptions).filter(([key, value]) => (filter.test(key)) && (!(key in metadata.base.web)))
entries.push(...enabled.map(key => [key, this.plugins.descriptions[key]]))
entries.sort((a, b) => a[0].localeCompare(b[0]))
//Return object
const configure = Object.fromEntries(entries)
return Object.keys(configure).length ? configure : null
},
//Is in preview mode //Is in preview mode
preview() { preview() {
return /-preview$/.test(this.version) return /-preview$/.test(this.version)
}, },
//Rate limit reset
rlreset() {
const reset = new Date(Math.max(this.requests.graphql.reset, this.requests.rest.reset))
return `${reset.getHours()}:${reset.getMinutes()}`
},
},
//Methods
methods: {
//Refresh computed properties
async refresh() {
const keys = {action: ["scopes", "action"], markdown: ["url", "embed"]}[this.tab]
if (keys) {
for (const key of keys)
this._computedWatchers[key]?.run()
this.$forceUpdate()
}
},
//Load and render placeholder image
async mock({timeout = 600} = {}) {
this.refresh()
clearTimeout(this.templates.placeholder.timeout)
this.templates.placeholder.timeout = setTimeout(async () => {
this.templates.placeholder.image = await placeholder(this)
this.generated.content = ""
this.generated.error = null
}, timeout)
},
//Resize mock image
mockresize() {
const svg = document.querySelector(".preview .image svg")
if ((svg) && (svg.getAttribute("height") == 99999)) {
const height = svg.querySelector("#metrics-end")?.getBoundingClientRect()?.y - svg.getBoundingClientRect()?.y
if (Number.isFinite(height))
svg.setAttribute("height", height)
}
},
//Generate metrics and flush cache
async generate() {
//Avoid requests spamming
if (this.generated.pending)
return
this.generated.pending = true
//Compute metrics
try {
await axios.get(`/.uncache?&token=${(await axios.get(`/.uncache?user=${this.user}`)).data.token}`)
this.generated.content = (await axios.get(this.url)).data
this.generated.error = null
}
catch (error) {
this.generated.error = {code: error.response.status, message: error.response.data}
}
finally {
this.generated.pending = false
try {
const {data: requests} = await axios.get("/.requests")
this.requests = requests
}
catch {}
}
},
}, },
}) })
})() })()

View File

@@ -0,0 +1,328 @@
;(async function() {
//Init
const {data: metadata} = await axios.get("/.plugins.metadata")
delete metadata.core.web.output
delete metadata.core.web.twemojis
//App
return new Vue({
//Initialization
el: "main",
async mounted() {
//Interpolate config from browser
try {
this.config.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
this.palette = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
catch (error) {}
//Init
await Promise.all([
//GitHub limit tracker
(async () => {
const {data: requests} = await axios.get("/.requests")
this.requests = requests
})(),
//Templates
(async () => {
const {data: templates} = await axios.get("/.templates")
templates.sort((a, b) => (a.name.startsWith("@") ^ b.name.startsWith("@")) ? (a.name.startsWith("@") ? 1 : -1) : a.name.localeCompare(b.name))
this.templates.list = templates
this.templates.selected = templates[0]?.name || "classic"
})(),
//Plugins
(async () => {
const {data: plugins} = await axios.get("/.plugins")
this.plugins.list = plugins.filter(({name}) => metadata[name]?.supports.includes("user") || metadata[name]?.supports.includes("organization"))
const categories = [...new Set(this.plugins.list.map(({category}) => category))]
this.plugins.categories = Object.fromEntries(categories.map(category => [category, this.plugins.list.filter(value => category === value.category)]))
})(),
//Base
(async () => {
const {data: base} = await axios.get("/.plugins.base")
this.plugins.base = base
this.plugins.enabled.base = Object.fromEntries(base.map(key => [key, true]))
})(),
//Version
(async () => {
const {data: version} = await axios.get("/.version")
this.version = `v${version}`
})(),
//Hosted
(async () => {
const {data: hosted} = await axios.get("/.hosted")
this.hosted = hosted
})(),
])
//Generate placeholder
this.mock({timeout: 200})
setInterval(() => {
const marker = document.querySelector("#metrics-end")
if (marker) {
this.mockresize()
marker.remove()
}
}, 100)
},
components: {Prism: PrismComponent},
//Watchers
watch: {
tab: {
immediate: true,
handler(current) {
if (current === "action")
this.clipboard = new ClipboardJS(".copy-action")
else
this.clipboard?.destroy()
},
},
palette: {
immediate: true,
handler(current, previous) {
document.querySelector("body").classList.remove(previous)
document.querySelector("body").classList.add(current)
},
},
},
//Data initialization
data: {
version: "",
user: "",
tab: "overview",
palette: "light",
clipboard: null,
requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}},
cached: new Map(),
config: Object.fromEntries(Object.entries(metadata.core.web).map(([key, {defaulted}]) => [key, defaulted])),
metadata: Object.fromEntries(Object.entries(metadata).map(([key, {web}]) => [key, web])),
hosted: null,
docs: {
overview: {
link: "https://github.com/lowlighter/metrics#-documentation",
name: "Complete documentation",
},
markdown: {
link: "https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/shared.md",
name: "Setup using the shared instance",
},
action: {
link: "https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md",
name: "Setup using GitHub Action on a profile repository",
},
},
plugins: {
base: {},
list: [],
categories: [],
enabled: {},
descriptions: {
base: "🗃️ Base content",
"base.header": "Header",
"base.activity": "Account activity",
"base.community": "Community stats",
"base.repositories": "Repositories metrics",
"base.metadata": "Metadata",
...Object.fromEntries(Object.entries(metadata).map(([key, {name}]) => [key, name])),
},
options: {
descriptions: {...(Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)))},
...(Object.fromEntries(
Object.entries(
Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)),
)
.map(([key, {defaulted}]) => [key, defaulted]),
)),
},
},
templates: {
list: [],
selected: "classic",
placeholder: {
timeout: null,
image: "",
},
descriptions: {
classic: "Classic template",
terminal: "Terminal template",
markdown: "(hidden)",
repository: "(hidden)",
},
},
generated: {
pending: false,
content: "",
error: false,
},
},
//Computed data
computed: {
//Unusable plugins
unusable() {
return this.plugins.list.filter(({name}) => this.plugins.enabled[name]).filter(({enabled}) => !enabled).map(({name}) => name)
},
//User's avatar
avatar() {
return this.generated.content ? `https://github.com/${this.user}.png` : null
},
//User's repository
repo() {
return `https://github.com/${this.user}/${this.user}`
},
//Endpoint to use for computed metrics
url() {
//Plugins enabled
const plugins = Object.entries(this.plugins.enabled)
.flatMap(([key, value]) => key === "base" ? Object.entries(value).map(([key, value]) => [`base.${key}`, value]) : [[key, value]])
.filter(([key, value]) => /^base[.]\w+$/.test(key) ? !value : value)
.map(([key, value]) => `${key}=${+value}`)
//Plugins options
const options = Object.entries(this.plugins.options)
.filter(([key, value]) => `${value}`.length)
.filter(([key, value]) => this.plugins.enabled[key.split(".")[0]])
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
//Base options
const base = Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web) && (value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
//Config
const config = Object.entries(this.config).filter(([key, value]) => (value) && (value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`)
//Template
const template = (this.templates.selected !== this.templates.list[0]) ? [`template=${this.templates.selected}`] : []
//Generated url
const params = [...template, ...base, ...plugins, ...options, ...config].join("&")
return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}`
},
//Embedded generated code
embed() {
return `![Metrics](${this.url})`
},
//Token scopes
scopes() {
return new Set([
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base") && (value)).flatMap(([key]) => metadata[key].scopes),
...(Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).length ? metadata.base.scopes : []),
])
},
//GitHub action auto-generated code
action() {
return [
`# Visit https://github.com/lowlighter/metrics#-documentation for full reference`,
`name: Metrics`,
`on:`,
` # Schedule updates (each hour)`,
` schedule: [{cron: "0 * * * *"}]`,
` # Lines below let you run workflow manually and on each commit`,
` workflow_dispatch:`,
` push: {branches: ["master", "main"]}`,
`jobs:`,
` github-metrics:`,
` runs-on: ubuntu-latest`,
` permissions:`,
` contents: write`,
` steps:`,
` - uses: lowlighter/metrics@latest`,
` with:`,
...(this.scopes.size
? [
` # Your GitHub token`,
` # The following scopes are required:`,
...[...this.scopes].map(scope => ` # - ${scope}${scope === "public_access" ? " (default scope)" : ""}`),
` # The following additional scopes may be required:`,
` # - read:org (for organization related metrics)`,
` # - read:user (for user related data)`,
` # - read:packages (for some packages related data)`,
` # - repo (optional, if you want to include private repositories)`,
]
: [
` # Current configuration doesn't require a GitHub token`,
]),
` token: ${this.scopes.size ? `${"$"}{{ secrets.METRICS_TOKEN }}` : "NOT_NEEDED"}`,
``,
` # Options`,
...(this.user ? [` user: ${this.user}`] : []),
` template: ${this.templates.selected}`,
` base: ${Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).map(([key]) => key).join(", ") || '""'}`,
...[
...Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web) && (value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => ` ${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base") && (value)).map(([key]) => ` plugin_${key}: yes`),
...Object.entries(this.plugins.options).filter(([key, value]) => (value) && (!(key in metadata.base.web))).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
...Object.entries(this.config).filter(([key, value]) => (value) && (value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => ` config_${key.replace(/[.]/g, "_")}: ${typeof value === "boolean" ? {true: "yes", false: "no"}[value] : value}`),
].sort(),
].join("\n")
},
//Configurable plugins
configure() {
//Check enabled plugins
const enabled = Object.entries(this.plugins.enabled).filter(([key, value]) => (value) && (key !== "base")).map(([key, value]) => key)
const filter = new RegExp(`^(?:${enabled.join("|")})[.]`)
//Search related options
const entries = Object.entries(this.plugins.options.descriptions).filter(([key, value]) => (filter.test(key)) && (!(key in metadata.base.web)))
entries.push(...enabled.map(key => [key, this.plugins.descriptions[key]]))
entries.sort((a, b) => a[0].localeCompare(b[0]))
//Return object
const configure = Object.fromEntries(entries)
return Object.keys(configure).length ? configure : null
},
//Is in preview mode
preview() {
return /-preview$/.test(this.version)
},
//Rate limit reset
rlreset() {
const reset = new Date(Math.max(this.requests.graphql.reset, this.requests.rest.reset))
return `${reset.getHours()}:${reset.getMinutes()}`
},
},
//Methods
methods: {
//Refresh computed properties
async refresh() {
const keys = {action: ["scopes", "action"], markdown: ["url", "embed"]}[this.tab]
if (keys) {
for (const key of keys)
this._computedWatchers[key]?.run()
this.$forceUpdate()
}
},
//Load and render placeholder image
async mock({timeout = 600} = {}) {
this.refresh()
clearTimeout(this.templates.placeholder.timeout)
this.templates.placeholder.timeout = setTimeout(async () => {
this.templates.placeholder.image = await placeholder(this)
this.generated.content = ""
this.generated.error = null
}, timeout)
},
//Resize mock image
mockresize() {
const svg = document.querySelector(".preview .image svg")
if ((svg) && (svg.getAttribute("height") == 99999)) {
const height = svg.querySelector("#metrics-end")?.getBoundingClientRect()?.y - svg.getBoundingClientRect()?.y
if (Number.isFinite(height))
svg.setAttribute("height", height)
}
},
//Generate metrics and flush cache
async generate() {
//Avoid requests spamming
if (this.generated.pending)
return
this.generated.pending = true
//Compute metrics
try {
await axios.get(`/.uncache?&token=${(await axios.get(`/.uncache?user=${this.user}`)).data.token}`)
this.generated.content = (await axios.get(this.url)).data
this.generated.error = null
}
catch (error) {
this.generated.error = {code: error.response.status, message: error.response.data}
}
finally {
this.generated.pending = false
try {
const {data: requests} = await axios.get("/.requests")
this.requests = requests
}
catch {}
}
},
},
})
})()

View File

@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Metrics</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="An image generator with 20+ metrics about your GitHub account such as activity, community, repositories, coding habits, website performances, music played, starred topics, etc. that you can put on your profile or elsewhere !">
<meta name="author" content="lowlighter">
<meta property="og:image" content="/.opengraph.png">
<link rel="icon" href="/.favicon.png">
<link rel="stylesheet" href="/.css/style.vars.css">
<link rel="stylesheet" href="/.css/style.css">
<link rel="stylesheet" href="/.css/style.prism.css" />
</head>
<body>
<!-- Vue app -->
<main :class="[palette]">
<template>
<header>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>
<a href="https://github.com/lowlighter/metrics">Metrics {{ version }}</a>
</header>
<div class="ui top">
<aside></aside>
<nav>
<div @click="tab = 'overview'" :class="{active:tab === 'overview'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 1.75a.75.75 0 00-1.5 0v12.5c0 .414.336.75.75.75h14.5a.75.75 0 000-1.5H1.5V1.75zm14.28 2.53a.75.75 0 00-1.06-1.06L10 7.94 7.53 5.47a.75.75 0 00-1.06 0L3.22 8.72a.75.75 0 001.06 1.06L7 7.06l2.47 2.47a.75.75 0 001.06 0l5.25-5.25z"></path></svg>
Metrics preview
</div>
<div @click="tab = 'action'" :class="{active:tab === 'action'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>
Action code
</div>
<div @click="tab = 'markdown'" :class="{active:tab === 'markdown'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5zm7.5-.188V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011zM5.72 6.72a.75.75 0 000 1.06l1.47 1.47-1.47 1.47a.75.75 0 101.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0zM3.28 7.78a.75.75 0 00-1.06-1.06l-2 2a.75.75 0 000 1.06l2 2a.75.75 0 001.06-1.06L1.81 9.25l1.47-1.47z"></path></svg>
Markdown code
</div>
</nav>
</div>
<div class="ui">
<aside>
<div class="ui-avatar" :style="{backgroundImage:avatar ? `url(${avatar})` : 'none'}"></div>
<input type="text" v-model="user" placeholder="Your GitHub username" :disabled="generated.pending" @keyup.enter="(!user)||(generated.pending)||(unusable.length > 0)||(!requests.rest.remaining)||(!requests.graphql.remaining) ? null : generate()">
<button @click="generate" :disabled="(!user)||(generated.pending)||(unusable.length > 0)||(!requests.rest.remaining)||(!requests.graphql.remaining)">
<template v-if="generated.pending">
Generating metrics<span class="loading"></span>
</template>
<template v-else>
Generate your metrics!
</template>
</button>
<small :class="{'error-text':(!requests.rest.remaining)||(!requests.graphql.remaining)}">Remaining GitHub requests:</small>
<small>{{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL</small>
<small class="warning" v-if="preview">
Metrics are rendered by <a href="https://metrics.lecoq.io/">metrics.lecoq.io</a> in preview mode.
Any backend editions won't be reflected but client-side rendering can still be tested.
</small>
<div class="warning" v-if="unusable.length">
The following plugins are not available on this web instance: {{ unusable.join(", ") }}
</div>
<div class="warning" v-if="(!requests.rest.remaining)||(!requests.graphql.remaining)">
This web instance has run out of GitHub API requests.
Please wait until {{ rlreset }} to generate metrics again.
</div>
<div class="configuration">
<b>🖼️ Template</b>
<label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template.name] !== '(hidden)'" :class="{'not-available':!template.enabled}" :title="!template.enabled ? 'This template is not enabled on this web instance, use GitHub actions instead!' : ''">
<input type="radio" v-model="templates.selected" :value="template.name" @change="mock" :disabled="generated.pending">
{{ templates.descriptions[template.name] || template.name }}
</label>
</div>
<div class="configuration" v-if="plugins.base.length">
<b>🗃️ Base content</b>
<label v-for="part in plugins.base" :key="part">
<input type="checkbox" v-model="plugins.enabled.base[part]" @change="mock" :disabled="generated.pending">
<span>{{ plugins.descriptions[`base.${part}`] || `base.${part}` }}</span>
</label>
</div>
<div class="configuration plugins" v-if="plugins.list.length">
<b>🧩 Additional plugins</b>
<template v-for="(category, name) in plugins.categories" :key="category">
<details open>
<summary>{{ name }}</summary>
<label v-for="plugin in category" :class="{'not-available':!plugin.enabled}" :title="!plugin.enabled ? 'This plugin is not enabled on web instance, use it with GitHub actions !' : ''">
<input type="checkbox" v-model="plugins.enabled[plugin.name]" @change="mock" :disabled="generated.pending">
<div>{{ plugins.descriptions[plugin.name] || plugin.name }}</div>
</label>
</details>
</template>
</div>
<div class="configuration" v-if="configure">
<b>🔧 Configure plugins</b>
<template v-for="(input, key) in configure">
<b v-if="typeof input === 'string'">{{ input }}</b>
<label v-else class="option">
<i>{{ input.text.split("\n")[0] }}</i>
<input type="checkbox" v-if="input.type === 'boolean'" v-model="plugins.options[key]" @change="mock">
<input type="number" v-else-if="input.type === 'number'" v-model="plugins.options[key]" @change="mock" :min="input.min" :max="input.max">
<select v-else-if="input.type === 'select'" v-model="plugins.options[key]" @change="mock">
<option v-for="value in input.values" :value="value">{{ value }}</option>
</select>
<input type="text" v-else v-model="plugins.options[key]" @change="mock" :placeholder="input.placeholder">
</label>
</template>
</div>
<div class="configuration">
<details>
<summary><b>⚙️ Additional settings</b></summary>
<template v-for="{key, target} in [{key:'base', target:plugins.options}, {key:'core', target:config}]">
<template v-for="(input, key) in metadata[key]">
<label class="option">
<i>{{ input.text.split("\n")[0] }}</i>
<input type="checkbox" v-if="input.type === 'boolean'" v-model="target[key]" @change="mock">
<input type="number" v-else-if="input.type === 'number'" v-model="target[key]" @change="mock" :min="input.min" :max="input.max">
<select v-else-if="input.type === 'select'" v-model="target[key]" @change="mock">
<option v-for="value in input.values" :value="value">{{ value }}</option>
</select>
<input type="text" v-else v-model="target[key]" @change="mock" :placeholder="input.placeholder">
</label>
</template>
</template>
</details>
</div>
</aside>
<div class="preview">
<div class="readmes">
<div class="readme">
<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M1.326 1.973a1.2 1.2 0 011.49-.832c.387.112.977.307 1.575.602.586.291 1.243.71 1.7 1.296.022.027.042.056.061.084A13.22 13.22 0 018 3c.67 0 1.289.037 1.861.108l.051-.07c.457-.586 1.114-1.004 1.7-1.295a9.654 9.654 0 011.576-.602 1.2 1.2 0 011.49.832c.14.493.356 1.347.479 2.29.079.604.123 1.28.07 1.936.541.977.773 2.11.773 3.301C16 13 14.5 15 8 15s-8-2-8-5.5c0-1.034.238-2.128.795-3.117-.08-.712-.034-1.46.052-2.12.122-.943.34-1.797.479-2.29zM8 13.065c6 0 6.5-2 6-4.27C13.363 5.905 11.25 5 8 5s-5.363.904-6 3.796c-.5 2.27 0 4.27 6 4.27z"></path><path d="M4 8a1 1 0 012 0v1a1 1 0 01-2 0V8zm2.078 2.492c-.083-.264.146-.492.422-.492h3c.276 0 .505.228.422.492C9.67 11.304 8.834 12 8 12c-.834 0-1.669-.696-1.922-1.508zM10 8a1 1 0 112 0v1a1 1 0 11-2 0V8z"></path></svg>
<span>{{ user }}</span><span class="slash">/</span>README<span class="md">.md</span>
</div>
<div class="readme" v-if="tab in docs">
<a :href="docs[tab].link">{{ docs[tab].name }}</a>
</div>
<div class="readme">
<a href="https://github.com/lowlighter/metrics/discussions" target="_blank">Send feedback</a>
</div>
</div>
<div v-if="tab == 'overview'">
<div class="alert error" v-if="generated.error">
An error occurred while generating your metrics :(<br>
<small>{{ generated.error.message }}</small>
</div>
<div class="image" :class="{pending:generated.pending}" v-html="generated.content||templates.placeholder.image"></div>
</div>
<div v-else-if="tab == 'markdown'">
Add the markdown below to your <i>README.md</i> <template v-if="user">at <a :href="repo">{{ user }}/{{ user }}</a></template>
<div class="code">
<Prism language="markdown" :code="embed"></Prism>
</div>
</div>
<div v-else-if="tab == 'action'">
<div>
<button class="copy-action" data-clipboard-target=".code">Copy Action Code</button>
</div>
Create a new workflow with the following content <template v-if="user">at <a :href="repo">{{ user }}/{{ user }}</a></template>
<div class="code">
<Prism language="yaml" :code="action"></Prism>
</div>
</div>
</div>
</div>
<footer>
<a href="https://github.com/lowlighter/metrics">Repository</a>
<a href="https://github.com/lowlighter/metrics/blob/master/LICENSE">License</a>
<a href="https://github.com/marketplace/actions/metrics-embed">GitHub Action</a>
<span v-if="hosted">Hosted with ❤️ by <a :href="hosted.link">{{ hosted.by }}</a></span>
</footer>
</template>
</main>
<!-- Scripts -->
<script src="/.js/axios.min.js"></script>
<script src="/.js/prism.min.js"></script>
<script src="/.js/prism.markdown.min.js"></script>
<script src="/.js/prism.yaml.min.js"></script>
<script src="/.js/ejs.min.js"></script>
<script src="/.js/faker.min.js?v=7.x" type="module"></script>
<script src="/.js/vue.min.js"></script>
<script src="/.js/vue.prism.min.js"></script>
<script src="/.js/clipboard.min.js"></script>
<script src="/.js/embed/app.placeholder.js?v=3.26"></script>
<script src="/.js/embed/app.js?v=3.26"></script>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -10,7 +10,6 @@
<link rel="icon" href="/.favicon.png"> <link rel="icon" href="/.favicon.png">
<link rel="stylesheet" href="/.css/style.vars.css"> <link rel="stylesheet" href="/.css/style.vars.css">
<link rel="stylesheet" href="/.css/style.css"> <link rel="stylesheet" href="/.css/style.css">
<link rel="stylesheet" href="/.css/style.prism.css" />
</head> </head>
<body> <body>
<!-- Vue app --> <!-- Vue app -->
@@ -22,165 +21,11 @@
<a href="https://github.com/lowlighter/metrics">Metrics {{ version }}</a> <a href="https://github.com/lowlighter/metrics">Metrics {{ version }}</a>
</header> </header>
<div class="ui top"> <main>
<aside></aside> <section class="container center">
<nav> Hi
<div @click="mode = 'metrics', tab = 'overview'" :class="{active:tab === 'overview'}"> </section>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 1.75a.75.75 0 00-1.5 0v12.5c0 .414.336.75.75.75h14.5a.75.75 0 000-1.5H1.5V1.75zm14.28 2.53a.75.75 0 00-1.06-1.06L10 7.94 7.53 5.47a.75.75 0 00-1.06 0L3.22 8.72a.75.75 0 001.06 1.06L7 7.06l2.47 2.47a.75.75 0 001.06 0l5.25-5.25z"></path></svg> </main>
Metrics preview
</div>
<div @click="(user)&&(mode === 'metrics') ? tab = 'action' : null" :class="{active:tab === 'action', disabled:(!user)||(mode !== 'metrics')}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>
Action code
</div>
<div @click="(user)&&(mode === 'metrics') ? tab = 'markdown' : null" :class="{active:tab === 'markdown', disabled:(!user)||(mode !== 'metrics')}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5zm7.5-.188V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011zM5.72 6.72a.75.75 0 000 1.06l1.47 1.47-1.47 1.47a.75.75 0 101.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0zM3.28 7.78a.75.75 0 00-1.06-1.06l-2 2a.75.75 0 000 1.06l2 2a.75.75 0 001.06-1.06L1.81 9.25l1.47-1.47z"></path></svg>
Markdown code
</div>
<div @click="mode = 'insights', tab = 'insights'" :class="{active:tab === 'insights'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M14.184 1.143a1.75 1.75 0 00-2.502-.57L.912 7.916a1.75 1.75 0 00-.53 2.32l.447.775a1.75 1.75 0 002.275.702l11.745-5.656a1.75 1.75 0 00.757-2.451l-1.422-2.464zm-1.657.669a.25.25 0 01.358.081l1.422 2.464a.25.25 0 01-.108.35l-2.016.97-1.505-2.605 1.85-1.26zM9.436 3.92l1.391 2.41-5.42 2.61-.942-1.63 4.97-3.39zM3.222 8.157l-1.466 1a.25.25 0 00-.075.33l.447.775a.25.25 0 00.325.1l1.598-.769-.83-1.436zm6.253 2.306a.75.75 0 00-.944-.252l-1.809.87a.75.75 0 00-.293.253L4.38 14.326a.75.75 0 101.238.848l1.881-2.75v2.826a.75.75 0 001.5 0v-2.826l1.881 2.75a.75.75 0 001.238-.848l-2.644-3.863z"></path></svg>
Metrics Insights
</div>
</nav>
</div>
<div class="ui" v-if="mode === 'metrics'">
<aside>
<div class="ui-avatar" :style="{backgroundImage:avatar ? `url(${avatar})` : 'none'}"></div>
<input type="text" v-model="user" placeholder="Your GitHub username" :disabled="generated.pending" @keyup.enter="(!user)||(generated.pending)||(unusable.length > 0)||(!requests.rest.remaining)||(!requests.graphql.remaining) ? null : generate()">
<button @click="generate" :disabled="(!user)||(generated.pending)||(unusable.length > 0)||(!requests.rest.remaining)||(!requests.graphql.remaining)">
<template v-if="generated.pending">
Generating metrics<span class="loading"></span>
</template>
<template v-else>
Generate your metrics!
</template>
</button>
<small :class="{'error-text':(!requests.rest.remaining)||(!requests.graphql.remaining)}">Remaining GitHub requests:</small>
<small>{{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL</small>
<small class="warning" v-if="preview">
Metrics are rendered by <a href="https://metrics.lecoq.io/">metrics.lecoq.io</a> in preview mode.
Any backend editions won't be reflected but client-side rendering can still be tested.
</small>
<div class="warning" v-if="unusable.length">
The following plugins are not available on this web instance: {{ unusable.join(", ") }}
</div>
<div class="warning" v-if="(!requests.rest.remaining)||(!requests.graphql.remaining)">
This web instance has run out of GitHub API requests.
Please wait until {{ rlreset }} to generate metrics again.
</div>
<div class="configuration">
<b>🖼️ Template</b>
<label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template.name] !== '(hidden)'" :class="{'not-available':!template.enabled}" :title="!template.enabled ? 'This template is not enabled on this web instance, use GitHub actions instead!' : ''">
<input type="radio" v-model="templates.selected" :value="template.name" @change="mock" :disabled="generated.pending">
{{ templates.descriptions[template.name] || template.name }}
</label>
</div>
<div class="configuration" v-if="plugins.base.length">
<b>🗃️ Base content</b>
<label v-for="part in plugins.base" :key="part">
<input type="checkbox" v-model="plugins.enabled.base[part]" @change="mock" :disabled="generated.pending">
<span>{{ plugins.descriptions[`base.${part}`] || `base.${part}` }}</span>
</label>
</div>
<div class="configuration plugins" v-if="plugins.list.length">
<b>🧩 Additional plugins</b>
<template v-for="(category, name) in plugins.categories" :key="category">
<details open>
<summary>{{ name }}</summary>
<label v-for="plugin in category" :class="{'not-available':!plugin.enabled}" :title="!plugin.enabled ? 'This plugin is not enabled on web instance, use it with GitHub actions !' : ''">
<input type="checkbox" v-model="plugins.enabled[plugin.name]" @change="mock" :disabled="generated.pending">
<div>{{ plugins.descriptions[plugin.name] || plugin.name }}</div>
</label>
</details>
</template>
</div>
<div class="configuration" v-if="configure">
<b>🔧 Configure plugins</b>
<template v-for="(input, key) in configure">
<b v-if="typeof input === 'string'">{{ input }}</b>
<label v-else class="option">
<i>{{ input.text.split("\n")[0] }}</i>
<input type="checkbox" v-if="input.type === 'boolean'" v-model="plugins.options[key]" @change="mock">
<input type="number" v-else-if="input.type === 'number'" v-model="plugins.options[key]" @change="mock" :min="input.min" :max="input.max">
<select v-else-if="input.type === 'select'" v-model="plugins.options[key]" @change="mock">
<option v-for="value in input.values" :value="value">{{ value }}</option>
</select>
<input type="text" v-else v-model="plugins.options[key]" @change="mock" :placeholder="input.placeholder">
</label>
</template>
</div>
<div class="configuration">
<details>
<summary><b>⚙️ Additional settings</b></summary>
<template v-for="{key, target} in [{key:'base', target:plugins.options}, {key:'core', target:config}]">
<template v-for="(input, key) in metadata[key]">
<label class="option">
<i>{{ input.text.split("\n")[0] }}</i>
<input type="checkbox" v-if="input.type === 'boolean'" v-model="target[key]" @change="mock">
<input type="number" v-else-if="input.type === 'number'" v-model="target[key]" @change="mock" :min="input.min" :max="input.max">
<select v-else-if="input.type === 'select'" v-model="target[key]" @change="mock">
<option v-for="value in input.values" :value="value">{{ value }}</option>
</select>
<input type="text" v-else v-model="target[key]" @change="mock" :placeholder="input.placeholder">
</label>
</template>
</template>
</details>
</div>
</aside>
<div class="preview">
<div class="readmes">
<div class="readme">
<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M1.326 1.973a1.2 1.2 0 011.49-.832c.387.112.977.307 1.575.602.586.291 1.243.71 1.7 1.296.022.027.042.056.061.084A13.22 13.22 0 018 3c.67 0 1.289.037 1.861.108l.051-.07c.457-.586 1.114-1.004 1.7-1.295a9.654 9.654 0 011.576-.602 1.2 1.2 0 011.49.832c.14.493.356 1.347.479 2.29.079.604.123 1.28.07 1.936.541.977.773 2.11.773 3.301C16 13 14.5 15 8 15s-8-2-8-5.5c0-1.034.238-2.128.795-3.117-.08-.712-.034-1.46.052-2.12.122-.943.34-1.797.479-2.29zM8 13.065c6 0 6.5-2 6-4.27C13.363 5.905 11.25 5 8 5s-5.363.904-6 3.796c-.5 2.27 0 4.27 6 4.27z"></path><path d="M4 8a1 1 0 012 0v1a1 1 0 01-2 0V8zm2.078 2.492c-.083-.264.146-.492.422-.492h3c.276 0 .505.228.422.492C9.67 11.304 8.834 12 8 12c-.834 0-1.669-.696-1.922-1.508zM10 8a1 1 0 112 0v1a1 1 0 11-2 0V8z"></path></svg>
<span>{{ user }}</span><span class="slash">/</span>README<span class="md">.md</span>
</div>
<div class="readme" v-if="tab in docs">
<a :href="docs[tab].link">{{ docs[tab].name }}</a>
</div>
<div class="readme">
<a href="https://github.com/lowlighter/metrics/discussions" target="_blank">Send feedback</a>
</div>
</div>
<div v-if="tab == 'overview'">
<div class="error" v-if="generated.error">
An error occurred while generating your metrics :(<br>
<small>{{ generated.error.message }}</small>
</div>
<div class="image" :class="{pending:generated.pending}" v-html="generated.content||templates.placeholder.image"></div>
</div>
<div v-else-if="tab == 'markdown'">
Add the markdown below to your <i>README.md</i> at <a :href="repo">{{ user }}/{{ user }}</a>
<div class="code">
<Prism language="markdown" :code="embed"></Prism>
</div>
</div>
<div v-else-if="tab == 'action'">
<div>
<button class="copy-action" data-clipboard-target=".code">Copy Action Code</button>
</div>
Create a new workflow with the following content at <a :href="repo">{{ user }}/{{ user }}</a>
<div class="code">
<Prism language="yaml" :code="action"></Prism>
</div>
</div>
</div>
</div>
<iframe v-else src="/about?embed=1" frameborder="0"></iframe>
<footer> <footer>
<a href="https://github.com/lowlighter/metrics">Repository</a> <a href="https://github.com/lowlighter/metrics">Repository</a>
@@ -193,15 +38,7 @@
</main> </main>
<!-- Scripts --> <!-- Scripts -->
<script src="/.js/axios.min.js"></script> <script src="/.js/axios.min.js"></script>
<script src="/.js/prism.min.js"></script>
<script src="/.js/prism.markdown.min.js"></script>
<script src="/.js/prism.yaml.min.js"></script>
<script src="/.js/ejs.min.js"></script>
<script src="/.js/faker.min.js?v=7.x" type="module"></script>
<script src="/.js/vue.min.js"></script> <script src="/.js/vue.min.js"></script>
<script src="/.js/vue.prism.min.js"></script>
<script src="/.js/clipboard.min.js"></script>
<script src="/.js/app.placeholder.js?v=3.26"></script>
<script src="/.js/app.js?v=3.26"></script> <script src="/.js/app.js?v=3.26"></script>
</body> </body>
</html> </html>

View File

@@ -10,7 +10,7 @@
<link rel="icon" href="/.favicon.png"> <link rel="icon" href="/.favicon.png">
<link rel="stylesheet" href="/.css/style.vars.css"> <link rel="stylesheet" href="/.css/style.vars.css">
<link rel="stylesheet" href="/.css/style.css"> <link rel="stylesheet" href="/.css/style.css">
<link rel="stylesheet" href="/about/.statics/style.css"> <link rel="stylesheet" href="/insights/.statics/style.css">
</head> </head>
<body> <body>
<!-- Vue app --> <!-- Vue app -->
@@ -585,6 +585,6 @@
<!-- Scripts --> <!-- Scripts -->
<script src="/.js/axios.min.js"></script> <script src="/.js/axios.min.js"></script>
<script src="/.js/vue.min.js"></script> <script src="/.js/vue.min.js"></script>
<script src="/about/.statics/script.js?v=3.26"></script> <script src="/insights/.statics/script.js?v=3.26"></script>
</body> </body>
</html> </html>

View File

@@ -15,7 +15,7 @@
this.localstorage = !!(new URLSearchParams(location.search).get("localstorage")) this.localstorage = !!(new URLSearchParams(location.search).get("localstorage"))
//User //User
const user = location.pathname.split("/").pop() const user = location.pathname.split("/").pop()
if ((user) && (user !== "about")) { if ((user) && (!["about", "insights"].includes(user))) {
this.user = user this.user = user
await this.search() await this.search()
} }
@@ -99,7 +99,7 @@
this.loaded = ["base", ...Object.keys(this.metrics?.rendered?.plugins ?? {})] this.loaded = ["base", ...Object.keys(this.metrics?.rendered?.plugins ?? {})]
return return
} }
const {processing, ...data} = (await axios.get(`/about/query/${this.user}`)).data const {processing, ...data} = (await axios.get(`/insights/query/${this.user}`)).data
if (processing) { if (processing) {
let completed = 0 let completed = 0
this.progress = 1 / (data.plugins.length + 1) this.progress = 1 / (data.plugins.length + 1)
@@ -109,7 +109,7 @@
return return
do { do {
try { try {
const {data} = await axios.get(`/about/query/${this.user}/${plugin}`) const {data} = await axios.get(`/insights/query/${this.user}/${plugin}`)
if (!data) if (!data)
throw new Error(`${plugin}: no data`) throw new Error(`${plugin}: no data`)
if (plugin === "base") if (plugin === "base")
@@ -220,7 +220,7 @@
return {login, name, avatar: this.metrics?.rendered.computed.avatar, type: this.metrics?.rendered.account} return {login, name, avatar: this.metrics?.rendered.computed.avatar, type: this.metrics?.rendered.account}
}, },
url() { url() {
return `${window.location.protocol}//${window.location.host}/about/${this.user}` return `${window.location.protocol}//${window.location.host}/insights/${this.user}`
}, },
preview() { preview() {
return /-preview$/.test(this.version) return /-preview$/.test(this.version)

View File

@@ -281,7 +281,7 @@
} }
/* Error */ /* Error */
.error { .alert.error {
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
background-image: linear-gradient(var(--color-alert-error-bg),var(--color-alert-error-bg)); background-image: linear-gradient(var(--color-alert-error-bg),var(--color-alert-error-bg));
color: var(--color-alert-error-text); color: var(--color-alert-error-text);

View File

@@ -2,11 +2,11 @@
import * as compute from "./list/index.mjs" import * as compute from "./list/index.mjs"
//Setup //Setup
export default async function({login, q, imports, data, computed, graphql, queries, rest, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, computed, graphql, queries, rest, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.achievements)) if ((!enabled) || (!q.achievements) || (!imports.metadata.plugins.achievements.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -37,7 +37,7 @@ export default async function({login, q, imports, data, computed, graphql, queri
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -18,6 +18,8 @@ inputs:
Enable achievements plugin Enable achievements plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_achievements_threshold: plugin_achievements_threshold:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, rest, q, account, imports}, {enabled = false, markdown = "inline"} = {}) { export default async function({login, data, rest, q, account, imports}, {enabled = false, markdown = "inline", extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.activity)) if ((!enabled) || (!q.activity) || (!imports.metadata.plugins.activity.extras("enabled", {extras})))
return null return null
//Context //Context
@@ -174,6 +174,6 @@ export default async function({login, data, rest, q, account, imports}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, queries, imports, q, account}, {enabled = false} = {}) { export default async function({login, data, queries, imports, q, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.anilist)) if ((!enabled) || (!q.anilist) || (!imports.metadata.plugins.anilist.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -122,14 +122,7 @@ export default async function({login, data, queries, imports, q, account}, {enab
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error)
if (error.isAxiosError) {
const status = error.response?.status
console.debug(error.response.data)
message = `API returned ${status}`
error = error.response?.data ?? null
}
throw {error: {message, instance: error}}
} }
} }

View File

@@ -18,6 +18,8 @@ inputs:
Enable aniList plugin Enable aniList plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_anilist_user: plugin_anilist_user:
type: string type: string

View File

@@ -8,7 +8,6 @@ export default async function({login, graphql, rest, data, q, queries, imports,
//Load inputs //Load inputs
console.debug(`metrics/compute/${login}/base > started`) console.debug(`metrics/compute/${login}/base > started`)
let {indepth, hireable, "repositories.forks": _forks, "repositories.affiliations": _affiliations, "repositories.batch": _batch} = imports.metadata.plugins.base.inputs({data, q, account: "bypass"}) let {indepth, hireable, "repositories.forks": _forks, "repositories.affiliations": _affiliations, "repositories.batch": _batch} = imports.metadata.plugins.base.inputs({data, q, account: "bypass"})
const extras = conf.settings.extras?.features ?? conf.settings.extras?.default
const repositories = conf.settings.repositories || 100 const repositories = conf.settings.repositories || 100
const forks = _forks ? "" : ", isFork: false" const forks = _forks ? "" : ", isFork: false"
const affiliations = _affiliations?.length ? `, ownerAffiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]${conf.authenticated === login ? `, affiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]` : ""}` : "" const affiliations = _affiliations?.length ? `, ownerAffiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]${conf.authenticated === login ? `, affiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]` : ""}` : ""
@@ -90,7 +89,7 @@ export default async function({login, graphql, rest, data, q, queries, imports,
} }
//Query contributions collection over account lifetime instead of last year //Query contributions collection over account lifetime instead of last year
if (account === "user") { if (account === "user") {
if ((indepth) && (extras)) { if ((indepth) && (imports.metadata.plugins.base.extras("indepth", {...conf.settings, error:false}))) {
const fields = ["totalRepositoriesWithContributedCommits", "totalCommitContributions", "restrictedContributionsCount", "totalIssueContributions", "totalPullRequestContributions", "totalPullRequestReviewContributions"] const fields = ["totalRepositoriesWithContributedCommits", "totalCommitContributions", "restrictedContributionsCount", "totalIssueContributions", "totalPullRequestContributions", "totalPullRequestReviewContributions"]
const start = new Date(data.user.createdAt) const start = new Date(data.user.createdAt)
const end = new Date() const end = new Date()

View File

@@ -49,7 +49,6 @@ inputs:
default: no default: no
extras: extras:
- metrics.api.github.overuse - metrics.api.github.overuse
- plugins.base.indepth
base_hireable: base_hireable:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, data, imports, graphql, queries, account}, {enabled = false} = {}) { export default async function({login, q, data, imports, graphql, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.calendar)) if ((!enabled) || (!q.calendar) || (!imports.metadata.plugins.calendar.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -52,6 +52,6 @@ export default async function({login, q, data, imports, graphql, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, rest, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, rest, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.code)) if ((!enabled) || (!q.code) || (!imports.metadata.plugins.code.extras("enabled", {extras})))
return null return null
//Context //Context
@@ -79,6 +79,6 @@ export default async function({login, q, imports, data, rest, account}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -239,7 +239,8 @@ export default async function(
}, },
//Settings and tokens //Settings and tokens
{ {
enabled = false enabled = false,
extras = false,
} = {}) { } = {}) {
//Plugin execution //Plugin execution
try { try {

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({q, data, imports, account}, {enabled = false} = {}) { export default async function({q, data, imports, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.fortune)) if ((!enabled) || (!q.fortune) || (!imports.metadata.plugins.fortune.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -40,6 +40,6 @@ export default async function({q, data, imports, account}, {enabled = false} = {
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,16 +1,16 @@
//Setup //Setup
export default async function({q, imports, data, account}, {enabled = false} = {}) { export default async function({q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.nightscout)) if ((!enabled) || (!q.nightscout) || (!imports.metadata.plugins.nightscout.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {url, datapoints, lowalert, highalert, urgentlowalert, urgenthighalert} = imports.metadata.plugins.nightscout.inputs({data, account, q}) let {url, datapoints, lowalert, highalert, urgentlowalert, urgenthighalert} = imports.metadata.plugins.nightscout.inputs({data, account, q})
if (!url || url === "https://example.herokuapp.com") if (!url || url === "https://example.herokuapp.com")
throw {error: {message: "Nightscout site URL isn't set!"}} throw {error: {message: "Nightscout URL is not set"}}
if (url.substring(url.length - 1) !== "/") if (url.substring(url.length - 1) !== "/")
url += "/" url += "/"
if (url.substring(0, 7) === "http://") if (url.substring(0, 7) === "http://")
@@ -48,9 +48,7 @@ export default async function({q, imports, data, account}, {enabled = false} = {
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({q, imports, data, account}, {enabled = false, token = ""} = {}) { export default async function({q, imports, data, account}, {enabled = false, token = "", extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.poopmap)) if ((!enabled) || (!q.poopmap) || (!imports.metadata.plugins.poopmap.extras("enabled", {extras})))
return null return null
if (!token) if (!token)
@@ -32,6 +32,6 @@ export default async function({q, imports, data, account}, {enabled = false, tok
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,15 +1,15 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.screenshot)) if ((!enabled) || (!q.screenshot) || (!imports.metadata.plugins.screenshot.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {url, selector, title, background} = imports.metadata.plugins.screenshot.inputs({data, account, q}) let {url, selector, title, background} = imports.metadata.plugins.screenshot.inputs({data, account, q})
if (!url) if (!url)
throw {error: {message: "An url is required"}} throw {error: {message: "URL is not set"}}
//Start puppeteer and navigate to page //Start puppeteer and navigate to page
console.debug(`metrics/compute/${login}/plugins > screenshot > starting browser`) console.debug(`metrics/compute/${login}/plugins > screenshot > starting browser`)
@@ -37,8 +37,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error, {title:"Screenshot error"})
throw error
throw {title: "Screenshot error", error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -20,6 +20,8 @@ inputs:
Enable screenshot plugin Enable screenshot plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_screenshot_title: plugin_screenshot_title:
description: | description: |

View File

@@ -1,17 +1,17 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false, token} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false, token} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.stock)) if ((!enabled) || (!q.stock) || (!imports.metadata.plugins.stock.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {symbol, interval, duration} = imports.metadata.plugins.stock.inputs({data, account, q}) let {symbol, interval, duration} = imports.metadata.plugins.stock.inputs({data, account, q})
if (!token) if (!token)
throw {error: {message: "A token is required"}} throw {error: {message: "API token is not set"}}
if (!symbol) if (!symbol)
throw {error: {message: "A company stock symbol is required"}} throw {error: {message: "Company stock symbol is not set"}}
symbol = symbol.toLocaleUpperCase() symbol = symbol.toLocaleUpperCase()
//Query API for company informations //Query API for company informations
@@ -48,13 +48,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error)
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response?.data?.message ?? null
message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
}
throw {error: {message, instance: error}}
} }
} }

View File

@@ -3,7 +3,7 @@ export default async function({login, q, imports, data, rest, graphql, queries,
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.contributors)) if ((!enabled) || (!q.contributors) || (!imports.metadata.plugins.contributors.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -70,7 +70,7 @@ export default async function({login, q, imports, data, rest, graphql, queries,
//Contributions categories //Contributions categories
const types = Object.fromEntries([...new Set(Object.keys(categories))].map(type => [type, new Set()])) const types = Object.fromEntries([...new Set(Object.keys(categories))].map(type => [type, new Set()]))
if ((sections.includes("categories")) && (extras)) { if ((sections.includes("categories")) && (imports.metadata.plugins.contributors.extras("categories", {extras}))) {
//Temporary directory //Temporary directory
const repository = `${repo.owner}/${repo.repo}` const repository = `${repo.owner}/${repo.repo}`
const path = imports.paths.join(imports.os.tmpdir(), `${repository.replace(/[^\w]/g, "_")}`) const path = imports.paths.join(imports.os.tmpdir(), `${repository.replace(/[^\w]/g, "_")}`)
@@ -123,6 +123,6 @@ export default async function({login, q, imports, data, rest, graphql, queries,
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -154,7 +154,7 @@ inputs:
format: comma-separated format: comma-separated
default: "" default: ""
extras: extras:
- metrics.run.setup.community.templates - metrics.setup.community.templates
template: template:
description: | description: |
@@ -185,7 +185,7 @@ inputs:
type: string type: string
default: "" default: ""
extras: extras:
- metrics.run.user.css - metrics.run.puppeteer.user.css
extras_js: extras_js:
description: | description: |
@@ -199,7 +199,7 @@ inputs:
type: string type: string
default: "" default: ""
extras: extras:
- metrics.run.user.js - metrics.run.puppeteer.user.js
config_timezone: config_timezone:
description: | description: |
@@ -347,6 +347,8 @@ inputs:
default: "" default: ""
preset: no preset: no
example: "@lunar-red" example: "@lunar-red"
extras:
- metrics.setup.community.presets
retries: retries:
description: | description: |
@@ -497,6 +499,8 @@ inputs:
default: no default: no
testing: yes testing: yes
preset: no preset: no
extras:
- metrics.npm.optional.libxml2
debug_flags: debug_flags:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, graphql, queries, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, graphql, queries, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.discussions)) if ((!enabled) || (!q.discussions) || (!imports.metadata.plugins.discussions.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -64,6 +64,6 @@ export default async function({login, q, imports, graphql, queries, data, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -3,7 +3,7 @@ export default async function({login, data, computed, imports, q, graphql, queri
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.followup)) if ((!enabled) || (!q.followup) || (!imports.metadata.plugins.followup.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -56,37 +56,34 @@ export default async function({login, data, computed, imports, q, graphql, queri
}, },
} }
//Extras features //Indepth mode
if (extras) { if ((indepth)&&(imports.metadata.plugins.followup.extras("indepth", {extras}))) {
//Indepth mode console.debug(`metrics/compute/${login}/plugins > followup > indepth`)
if (indepth) { followup.indepth = {repositories: {}}
console.debug(`metrics/compute/${login}/plugins > followup > indepth`)
followup.indepth = {repositories: {}}
//Process repositories //Process repositories
for (const {name: repo, owner: {login: owner}} of data.user.repositories.nodes) { for (const {name: repo, owner: {login: owner}} of data.user.repositories.nodes) {
try { try {
console.debug(`metrics/compute/${login}/plugins > followup > processing ${owner}/${repo}`) console.debug(`metrics/compute/${login}/plugins > followup > processing ${owner}/${repo}`)
followup.indepth.repositories[`${owner}/${repo}`] = {stats: {}} followup.indepth.repositories[`${owner}/${repo}`] = {stats: {}}
//Fetch users with push access //Fetch users with push access
let {repository: {collaborators: {nodes: collaborators}}} = await graphql(queries.followup["repository.collaborators"]({repo, owner})).catch(() => ({repository: {collaborators: {nodes: [{login: owner}]}}})) let {repository: {collaborators: {nodes: collaborators}}} = await graphql(queries.followup["repository.collaborators"]({repo, owner})).catch(() => ({repository: {collaborators: {nodes: [{login: owner}]}}}))
console.debug(`metrics/compute/${login}/plugins > followup > found ${collaborators.length} collaborators`) console.debug(`metrics/compute/${login}/plugins > followup > found ${collaborators.length} collaborators`)
followup.indepth.repositories[`${owner}/${repo}`].collaborators = collaborators.map(({login}) => login) followup.indepth.repositories[`${owner}/${repo}`].collaborators = collaborators.map(({login}) => login)
//Fetch issues and pull requests created by collaborators //Fetch issues and pull requests created by collaborators
collaborators = collaborators.map(({login}) => `-author:${login}`).join(" ") collaborators = collaborators.map(({login}) => `-author:${login}`).join(" ")
const stats = await graphql(queries.followup.repository({repo, owner, collaborators})) const stats = await graphql(queries.followup.repository({repo, owner, collaborators}))
followup.indepth.repositories[`${owner}/${repo}`] = stats followup.indepth.repositories[`${owner}/${repo}`] = stats
//Aggregate global stats //Aggregate global stats
for (const [key, {issueCount: count}] of Object.entries(stats)) { for (const [key, {issueCount: count}] of Object.entries(stats)) {
const [section, type] = key.split("_") const [section, type] = key.split("_")
followup[section].collaborators[type] += count followup[section].collaborators[type] += count
}
}
catch (error) {
console.debug(error)
console.debug(`metrics/compute/${login}/plugins > followup > an error occured while processing ${owner}/${repo}, skipping...`)
} }
} }
catch (error) {
console.debug(error)
console.debug(`metrics/compute/${login}/plugins > followup > an error occured while processing ${owner}/${repo}, skipping...`)
}
} }
} }
@@ -120,6 +117,6 @@ export default async function({login, data, computed, imports, q, graphql, queri
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, graphql, q, imports, queries, account}, {enabled = false} = {}) { export default async function({login, data, graphql, q, imports, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.gists)) if ((!enabled) || (!q.gists) || (!imports.metadata.plugins.gists.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -45,8 +45,6 @@ export default async function({login, data, graphql, q, imports, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -6,7 +6,7 @@ export default async function({login, data, rest, imports, q, account}, {enabled
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.habits)) if ((!enabled) || (!q.habits) || (!imports.metadata.plugins.habits.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -97,7 +97,7 @@ export default async function({login, data, rest, imports, q, account}, {enabled
} }
//Linguist //Linguist
if ((extras) && (charts)) { if ((charts)&&((imports.metadata.plugins.habits.extras("charts", {extras, error:false})))) {
//Check if linguist exists //Check if linguist exists
console.debug(`metrics/compute/${login}/plugins > habits > searching recently used languages using linguist`) console.debug(`metrics/compute/${login}/plugins > habits > searching recently used languages using linguist`)
if (patches.length) { if (patches.length) {
@@ -113,7 +113,7 @@ export default async function({login, data, rest, imports, q, account}, {enabled
} }
//Generating charts with chartist //Generating charts with chartist
if (_charts === "chartist") { if ((_charts === "chartist")&&(imports.metadata.plugins.habits.extras("charts.type", {extras}))) {
console.debug(`metrics/compute/${login}/plugins > habits > generating charts`) console.debug(`metrics/compute/${login}/plugins > habits > generating charts`)
habits.charts = await Promise.all([ habits.charts = await Promise.all([
{type: "line", data: {...empty(24), ...Object.fromEntries(Object.entries(habits.commits.hours).filter(([k]) => !Number.isNaN(+k)))}, low: 0, high: habits.commits.hours.max}, {type: "line", data: {...empty(24), ...Object.fromEntries(Object.entries(habits.commits.hours).filter(([k]) => !Number.isNaN(+k)))}, low: 0, high: habits.commits.hours.max},
@@ -164,9 +164,7 @@ export default async function({login, data, rest, imports, q, account}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.introduction)) if ((!enabled) || (!q.introduction) || (!imports.metadata.plugins.introduction.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -26,6 +26,6 @@ export default async function({login, q, imports, data, graphql, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, graphql, q, imports, queries, account}, {enabled = false} = {}) { export default async function({login, data, graphql, q, imports, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.isocalendar)) if ((!enabled) || (!q.isocalendar) || (!imports.metadata.plugins.isocalendar.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -72,9 +72,7 @@ export default async function({login, data, graphql, q, imports, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -6,7 +6,7 @@ export default async function({login, data, imports, q, rest, account}, {enabled
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.languages)) if ((!enabled) || (!q.languages) || (!imports.metadata.plugins.languages.extras("enabled", {extras})))
return null return null
//Context //Context
@@ -63,51 +63,48 @@ export default async function({login, data, imports, q, rest, account}, {enabled
} }
} }
//Extras features //Recently used languages
if (extras) { if ((sections.includes("recently-used")) && (context.mode === "user") && (imports.metadata.plugins.languages.extras("indepth", {extras}))) {
//Recently used languages try {
if ((sections.includes("recently-used")) && (context.mode === "user")) { console.debug(`metrics/compute/${login}/plugins > languages > using recent analyzer`)
try { languages["stats.recent"] = await recent_analyzer({login, data, imports, rest, account}, {skipped, categories: _recent_categories ?? categories, days: _recent_days, load: _recent_load, timeout})
console.debug(`metrics/compute/${login}/plugins > languages > using recent analyzer`) Object.assign(languages.colors, languages["stats.recent"].colors)
languages["stats.recent"] = await recent_analyzer({login, data, imports, rest, account}, {skipped, categories: _recent_categories ?? categories, days: _recent_days, load: _recent_load, timeout})
Object.assign(languages.colors, languages["stats.recent"].colors)
}
catch (error) {
console.debug(`metrics/compute/${login}/plugins > languages > ${error}`)
}
} }
catch (error) {
console.debug(`metrics/compute/${login}/plugins > languages > ${error}`)
}
}
//Indepth mode //Indepth mode
if (indepth) { if ((indepth)&&(imports.metadata.plugins.languages.extras("indepth", {extras}))) {
//Fetch gpg keys (web-flow is GitHub's public key when making changes from web ui) //Fetch gpg keys (web-flow is GitHub's public key when making changes from web ui)
const gpg = [] const gpg = []
try { try {
for (const username of [login, "web-flow"]) { for (const username of [login, "web-flow"]) {
const {data: keys} = await rest.users.listGpgKeysForUser({username}) const {data: keys} = await rest.users.listGpgKeysForUser({username})
gpg.push(...keys.map(({key_id: id, raw_key: pub, emails}) => ({id, pub, emails}))) gpg.push(...keys.map(({key_id: id, raw_key: pub, emails}) => ({id, pub, emails})))
if (username === login) { if (username === login) {
for (const {email} of gpg.flatMap(({emails}) => emails)) { for (const {email} of gpg.flatMap(({emails}) => emails)) {
console.debug(`metrics/compute/${login}/plugins > languages > auto-adding ${email} to commits_authoring (fetched from gpg)`) console.debug(`metrics/compute/${login}/plugins > languages > auto-adding ${email} to commits_authoring (fetched from gpg)`)
data.shared["commits.authoring"].push(email) data.shared["commits.authoring"].push(email)
}
} }
} }
} }
catch (error) { }
console.debug(`metrics/compute/${login}/plugins > languages > ${error}`) catch (error) {
} console.debug(`metrics/compute/${login}/plugins > languages > ${error}`)
}
//Analyze languages //Analyze languages
try { try {
console.debug(`metrics/compute/${login}/plugins > languages > switching to indepth mode (this may take some time)`) console.debug(`metrics/compute/${login}/plugins > languages > switching to indepth mode (this may take some time)`)
const existingColors = languages.colors const existingColors = languages.colors
Object.assign(languages, await indepth_analyzer({login, data, imports, repositories, gpg}, {skipped, categories, timeout})) Object.assign(languages, await indepth_analyzer({login, data, imports, repositories, gpg}, {skipped, categories, timeout}))
Object.assign(languages.colors, existingColors) Object.assign(languages.colors, existingColors)
console.debug(`metrics/compute/${login}/plugins > languages > indepth analysis missed ${languages.missed.commits} commits`) console.debug(`metrics/compute/${login}/plugins > languages > indepth analysis missed ${languages.missed.commits} commits`)
} }
catch (error) { catch (error) {
console.debug(`metrics/compute/${login}/plugins > languages > ${error}`) console.debug(`metrics/compute/${login}/plugins > languages > ${error}`)
}
} }
} }
@@ -158,6 +155,6 @@ export default async function({login, data, imports, q, rest, account}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -113,7 +113,7 @@ inputs:
type: boolean type: boolean
default: false default: false
extras: extras:
- metrics.api.github.overuse - metrics.cpu.overuse
- metrics.run.tempdir - metrics.run.tempdir
- metrics.run.git - metrics.run.git
@@ -124,10 +124,6 @@ inputs:
default: 15 default: 15
min: 1 min: 1
max: 30 max: 30
extras:
- metrics.api.github.overuse
- metrics.run.tempdir
- metrics.run.git
plugin_languages_categories: plugin_languages_categories:
description: | description: |
@@ -140,10 +136,6 @@ inputs:
- programming - programming
- prose - prose
default: markup, programming default: markup, programming
extras:
- metrics.api.github.overuse
- metrics.run.tempdir
- metrics.run.git
plugin_languages_recent_categories: plugin_languages_recent_categories:
description: | description: |
@@ -156,10 +148,6 @@ inputs:
- programming - programming
- prose - prose
default: markup, programming default: markup, programming
extras:
- metrics.api.github.overuse
- metrics.run.tempdir
- metrics.run.git
plugin_languages_recent_load: plugin_languages_recent_load:
description: | description: |
@@ -168,10 +156,6 @@ inputs:
default: 300 default: 300
min: 100 min: 100
max: 1000 max: 1000
extras:
- metrics.api.github.overuse
- metrics.run.tempdir
- metrics.run.git
plugin_languages_recent_days: plugin_languages_recent_days:
description: | description: |
@@ -181,7 +165,3 @@ inputs:
min: 0 min: 0
max: 365 max: 365
zero: disable zero: disable
extras:
- metrics.api.github.overuse
- metrics.run.tempdir
- metrics.run.git

View File

@@ -3,7 +3,7 @@ export default async function({login, q, imports, data, graphql, queries, accoun
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!extras) || (!q.licenses)) if ((!enabled) || (!q.licenses) || (!imports.metadata.plugins.licenses.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -113,7 +113,7 @@ export default async function({login, q, imports, data, graphql, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -18,6 +18,7 @@ inputs:
type: boolean type: boolean
default: no default: no
extras: extras:
- metrics.cpu.overuse
- metrics.run.tempdir - metrics.run.tempdir
- metrics.run.git - metrics.run.git
- metrics.run.licensed - metrics.run.licensed

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, imports, rest, q, account}, {enabled = false} = {}) { export default async function({login, data, imports, rest, q, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.lines)) if ((!enabled) || (!q.lines) || (!imports.metadata.plugins.lines.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -42,6 +42,6 @@ export default async function({login, data, imports, rest, q, account}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -28,11 +28,11 @@ const modes = {
} }
//Setup //Setup
export default async function({login, imports, data, q, account}, {enabled = false, token = "", sandbox = false} = {}) { export default async function({login, imports, data, q, account}, {enabled = false, token = "", sandbox = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.music)) if ((!enabled) || (!q.music) || (!imports.metadata.plugins.music.extras("enabled", {extras})))
return null return null
//Initialization //Initialization
@@ -71,16 +71,16 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Provider //Provider
if (!(provider in providers)) if (!(provider in providers))
throw {error: {message: provider ? `Unsupported provider "${provider}"` : "Missing provider"}, ...raw} throw {error: {message: provider ? `Unsupported provider "${provider}"` : "Provider is not set"}, ...raw}
//Mode //Mode
if (!(mode in modes)) if (!(mode in modes))
throw {error: {message: `Unsupported mode "${mode}"`}, ...raw} throw {error: {message: `Unsupported mode "${mode}"`}, ...raw}
//Playlist mode //Playlist mode
if (mode === "playlist") { if (mode === "playlist") {
if (!playlist) if (!playlist)
throw {error: {message: "Missing playlist url"}, ...raw} throw {error: {message: "Playlist URL is not set"}, ...raw}
if (!providers[provider].embed.test(playlist)) if (!providers[provider].embed.test(playlist))
throw {error: {message: "Unsupported playlist url format"}, ...raw} throw {error: {message: "Unsupported playlist URL format"}, ...raw}
} }
//Limit //Limit
limit = Math.max(1, Math.min(100, Number(limit))) limit = Math.max(1, Math.min(100, Number(limit)))
@@ -177,7 +177,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
//Prepare credentials //Prepare credentials
const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim()) const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim())
if ((!client_id) || (!client_secret) || (!refresh_token)) if ((!client_id) || (!client_secret) || (!refresh_token))
throw {error: {message: "Spotify token must contain client id/secret and refresh token"}} throw {error: {message: "Token must contain client id, client secret and refresh token"}}
//API call and parse tracklist //API call and parse tracklist
try { try {
//Request access token //Request access token
@@ -309,14 +309,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.isAxiosError) { throw imports.format.error(error)
const status = error.response?.status
const description = error.response.data?.error_description ?? null
const message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
throw {error: {message, instance: error}, ...raw}
}
throw error
} }
break break
} }
@@ -364,9 +357,9 @@ export default async function({login, imports, data, q, account}, {enabled = fal
//Prepare credentials //Prepare credentials
const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim()) const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim())
if ((!client_id) || (!client_secret) || (!refresh_token)) if ((!client_id) || (!client_secret) || (!refresh_token))
throw {error: {message: "Spotify token must contain client id/secret and refresh token"}} throw {error: {message: "Token must contain client id, client secret and refresh token"}}
else if (limit > 50) else if (limit > 50)
throw {error: {message: "Spotify top limit cannot be greater than 50"}} throw {error: {message: "Top limit cannot exceed 50 for this provider"}}
//API call and parse tracklist //API call and parse tracklist
try { try {
@@ -422,14 +415,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.isAxiosError) { throw imports.format.error(error)
const status = error.response?.status
const description = error.response.data?.error_description ?? null
const message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
throw {error: {message, instance: error}, ...raw}
}
throw error
} }
break break
} }
@@ -473,14 +459,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.isAxiosError) { throw imports.format.error(error)
const status = error.response?.status
const description = error.response.data?.message ?? null
const message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
throw {error: {message, instance: error}, ...raw}
}
throw error
} }
break break
} }
@@ -513,13 +492,11 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Unhandled error //Unhandled error
throw {error: {message: "An error occured (could not retrieve tracks)"}} throw {error: {message: "Failed to retrieve tracks"}}
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -19,6 +19,8 @@ inputs:
Enable music plugin Enable music plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_music_provider: plugin_music_provider:
description: | description: |

View File

@@ -3,7 +3,7 @@ export default async function({login, q, imports, rest, graphql, data, account,
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.notable)) if ((!enabled) || (!q.notable) || (!imports.metadata.plugins.notable.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -32,75 +32,72 @@ export default async function({login, q, imports, rest, graphql, data, account,
contributions = await Promise.all(contributions.map(async ({handle, stars, issues, pulls, avatarUrl, organization}) => ({name: handle.split("/").shift(), handle, stars, issues, pulls, avatar: await imports.imgb64(avatarUrl), organization}))) contributions = await Promise.all(contributions.map(async ({handle, stars, issues, pulls, avatarUrl, organization}) => ({name: handle.split("/").shift(), handle, stars, issues, pulls, avatar: await imports.imgb64(avatarUrl), organization})))
console.debug(`metrics/compute/${login}/plugins > notable > found ${contributions.length} notable contributions`) console.debug(`metrics/compute/${login}/plugins > notable > found ${contributions.length} notable contributions`)
//Extras features //Indepth
if (extras) { if ((indepth)&&(imports.metadata.plugins.notable.extras("indepth", {extras}))) {
//Indepth console.debug(`metrics/compute/${login}/plugins > notable > indepth`)
if (indepth) {
console.debug(`metrics/compute/${login}/plugins > notable > indepth`)
//Fetch issues //Fetch issues
const issues = {} const issues = {}
if (types.includes("issue")) { if (types.includes("issue")) {
let cursor = null let cursor = null
let pushed = 0 let pushed = 0
do { do {
console.debug(`metrics/compute/${login}/plugins > notable > retrieving user issues after ${cursor}`) console.debug(`metrics/compute/${login}/plugins > notable > retrieving user issues after ${cursor}`)
const {user: {issues: {edges}}} = await graphql(queries.notable.issues({login, type: "issues", after: cursor ? `after: "${cursor}"` : ""})) const {user: {issues: {edges}}} = await graphql(queries.notable.issues({login, type: "issues", after: cursor ? `after: "${cursor}"` : ""}))
cursor = edges?.[edges?.length - 1]?.cursor cursor = edges?.[edges?.length - 1]?.cursor
edges.map(({node: {repository: {nameWithOwner: repository}}}) => issues[repository] = (issues[repositories] ?? 0) + 1) edges.map(({node: {repository: {nameWithOwner: repository}}}) => issues[repository] = (issues[repositories] ?? 0) + 1)
pushed = edges.length pushed = edges.length
} while ((pushed) && (cursor)) } while ((pushed) && (cursor))
} }
//Fetch pull requests //Fetch pull requests
const pulls = {} const pulls = {}
if (types.includes("pull_request")) { if (types.includes("pull_request")) {
let cursor = null let cursor = null
let pushed = 0 let pushed = 0
do { do {
console.debug(`metrics/compute/${login}/plugins > notable > retrieving user pull requests after ${cursor}`) console.debug(`metrics/compute/${login}/plugins > notable > retrieving user pull requests after ${cursor}`)
const {user: {pullRequests: {edges}}} = await graphql(queries.notable.issues({login, type: "pullRequests", after: cursor ? `after: "${cursor}"` : ""})) const {user: {pullRequests: {edges}}} = await graphql(queries.notable.issues({login, type: "pullRequests", after: cursor ? `after: "${cursor}"` : ""}))
cursor = edges?.[edges?.length - 1]?.cursor cursor = edges?.[edges?.length - 1]?.cursor
edges.map(({node: {repository: {nameWithOwner: repository}}}) => pulls[repository] = (pulls[repositories] ?? 0) + 1) edges.map(({node: {repository: {nameWithOwner: repository}}}) => pulls[repository] = (pulls[repositories] ?? 0) + 1)
pushed = edges.length pushed = edges.length
} while ((pushed) && (cursor)) } while ((pushed) && (cursor))
} }
//Fetch commits //Fetch commits
for (const contribution of contributions) { for (const contribution of contributions) {
//Prepare data //Prepare data
const {handle, stars} = contribution const {handle, stars} = contribution
const [owner, repo] = handle.split("/") const [owner, repo] = handle.split("/")
try { try {
//Count total commits on repository //Count total commits on repository
const {repository: {defaultBranchRef: {target: {history}}}} = await graphql(queries.notable.commits({owner, repo})) const {repository: {defaultBranchRef: {target: {history}}}} = await graphql(queries.notable.commits({owner, repo}))
contribution.history = history.totalCount contribution.history = history.totalCount
//Load maintainers (errors probably means that token is not allowed to list contributors hence not a maintainer of said repo) //Load maintainers (errors probably means that token is not allowed to list contributors hence not a maintainer of said repo)
const {data: collaborators} = await rest.repos.listCollaborators({owner, repo}).catch(() => ({data: []})) const {data: collaborators} = await rest.repos.listCollaborators({owner, repo}).catch(() => ({data: []}))
const maintainers = collaborators.filter(({role_name: role}) => ["admin", "maintain", "write"].includes(role)).map(({login}) => login) const maintainers = collaborators.filter(({role_name: role}) => ["admin", "maintain", "write"].includes(role)).map(({login}) => login)
//Count total commits of user //Count total commits of user
const {data: contributions = []} = await rest.repos.getContributorsStats({owner, repo}) const {data: contributions = []} = await rest.repos.getContributorsStats({owner, repo})
const commits = contributions.filter(({author}) => author.login.toLocaleLowerCase() === login.toLocaleLowerCase()).reduce((a, {total: b}) => a + b, 0) const commits = contributions.filter(({author}) => author.login.toLocaleLowerCase() === login.toLocaleLowerCase()).reduce((a, {total: b}) => a + b, 0)
//Save user data //Save user data
contribution.user = { contribution.user = {
commits, commits,
percentage: commits / contribution.history, percentage: commits / contribution.history,
maintainer: maintainers.includes(login), maintainer: maintainers.includes(login),
issues: issues[handle] ?? 0, issues: issues[handle] ?? 0,
pulls: pulls[handle] ?? 0, pulls: pulls[handle] ?? 0,
get stars() { get stars() {
return Math.round(this.maintainer ? stars : this.percentage * stars) return Math.round(this.maintainer ? stars : this.percentage * stars)
}, },
}
console.debug(`metrics/compute/${login}/plugins > notable > indepth > successfully processed ${owner}/${repo}`)
}
catch (error) {
console.debug(error)
console.debug(`metrics/compute/${login}/plugins > notable > indepth > failed to compute for ${owner}/${repo}`)
} }
console.debug(`metrics/compute/${login}/plugins > notable > indepth > successfully processed ${owner}/${repo}`)
}
catch (error) {
console.debug(error)
console.debug(`metrics/compute/${login}/plugins > notable > indepth > failed to compute for ${owner}/${repo}`)
} }
} }
} }
@@ -142,6 +139,6 @@ export default async function({login, q, imports, rest, graphql, data, account,
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,14 +1,16 @@
//Setup //Setup
export default async function({login, imports, data, q, account}, {enabled = false, token = null} = {}) { export default async function({login, imports, data, q, account}, {enabled = false, token = null, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.pagespeed) || ((!data.user.websiteUrl) && (!q["pagespeed.url"]))) if ((!enabled) || (!q.pagespeed) || (!imports.metadata.plugins.pagespeed.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {detailed, screenshot, url, pwa} = imports.metadata.plugins.pagespeed.inputs({data, account, q}) let {detailed, screenshot, url, pwa} = imports.metadata.plugins.pagespeed.inputs({data, account, q})
//Format url if needed //Format url if needed
if (!url)
throw {error: {message: "Website URL is not set"}}
if (!/^https?:[/][/]/.test(url)) if (!/^https?:[/][/]/.test(url))
url = `https://${url}` url = `https://${url}`
const {protocol, host} = imports.url.parse(url) const {protocol, host} = imports.url.parse(url)
@@ -45,15 +47,13 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error, {descriptions:{"429":'(consider using "plugin_pagespeed_token")', custom(error) {
if (error.isAxiosError) { const description = error.response?.data?.error?.message?.match(/Lighthouse returned error: (?<description>[A-Z_]+)/)?.groups?.description ?? null
const status = error.response?.status if (description) {
let description = error.response?.data?.error?.message?.match(/Lighthouse returned error: (?<description>[A-Z_]+)/)?.groups?.description ?? null const status = error.response?.status
if ((status === 429) && (!description)) return `API error: ${status} (${description})`
description = 'consider using "plugin_pagespeed_token"' }
message = `API returned ${status}${description ? ` (${description})` : ""}` return null
error = error.response?.data ?? null }}})
}
throw {error: {message, instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, graphql, rest, q, queries, imports, account}, {enabled = false} = {}) { export default async function({login, data, graphql, rest, q, queries, imports, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.people)) if ((!enabled) || (!q.people) || (!imports.metadata.plugins.people.extras("enabled", {extras})))
return null return null
//Context //Context
@@ -102,6 +102,6 @@ export default async function({login, data, graphql, rest, q, queries, imports,
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, imports, q, queries, account}, {enabled = false} = {}) { export default async function({login, data, imports, q, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.posts)) if ((!enabled) || (!q.posts) || (!imports.metadata.plugins.posts.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -51,12 +51,10 @@ export default async function({login, data, imports, q, queries, account}, {enab
} }
//Unhandled error //Unhandled error
throw {error: {message: "An error occured (could not retrieve posts)"}} throw {error: {message: "Failed to retrieve posts"}}
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, imports, graphql, q, queries, account}, {enabled = false} = {}) { export default async function({login, data, imports, graphql, q, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.projects)) if ((!enabled) || (!q.projects) || (!imports.metadata.plugins.projects.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -66,9 +66,10 @@ export default async function({login, data, imports, graphql, q, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error, {descriptions:{custom(error) {
if (error.errors?.map(({type}) => type)?.includes("INSUFFICIENT_SCOPES")) if (error.errors?.map(({type}) => type)?.includes("INSUFFICIENT_SCOPES"))
message = "Insufficient token rights" return "Insufficient token scopes"
throw {error: {message, instance: error}} return null
}}})
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.reactions)) if ((!enabled) || (!q.reactions) || (!imports.metadata.plugins.reactions.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -60,6 +60,6 @@ export default async function({login, q, imports, data, graphql, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, graphql, queries, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, graphql, queries, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.repositories)) if ((!enabled) || (!q.repositories) || (!imports.metadata.plugins.repositories.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -30,7 +30,7 @@ export default async function({login, q, imports, graphql, queries, data, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,15 +1,15 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.rss)) if ((!enabled) || (!q.rss) || (!imports.metadata.plugins.rss.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {source, limit} = imports.metadata.plugins.rss.inputs({data, account, q}) let {source, limit} = imports.metadata.plugins.rss.inputs({data, account, q})
if (!source) if (!source)
throw {error: {message: "A RSS feed is required"}} throw {error: {message: "RSS feed URL is not set"}}
//Load rss feed //Load rss feed
const {title, description, link, items} = await (new imports.rss()).parseURL(source) //eslint-disable-line new-cap const {title, description, link, items} = await (new imports.rss()).parseURL(source) //eslint-disable-line new-cap
@@ -26,8 +26,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.skyline)) if ((!enabled) || (!q.skyline) || (!imports.metadata.plugins.skyline.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -42,6 +42,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -18,7 +18,9 @@ inputs:
type: boolean type: boolean
default: no default: no
extras: extras:
- metrics.cpu.overuse
- metrics.npm.optional.gifencoder - metrics.npm.optional.gifencoder
- metrics.run.puppeteer.scrapping
plugin_skyline_year: plugin_skyline_year:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, graphql, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.sponsors)) if ((!enabled) || (!q.sponsors) || (!imports.metadata.plugins.sponsors.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -85,6 +85,6 @@ export default async function({login, q, imports, data, graphql, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,15 +1,15 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.stackoverflow)) if ((!enabled) || (!q.stackoverflow) || (!imports.metadata.plugins.stackoverflow.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
let {sections, user, limit, lines, "lines.snippet": codelines} = imports.metadata.plugins.stackoverflow.inputs({data, account, q}) let {sections, user, limit, lines, "lines.snippet": codelines} = imports.metadata.plugins.stackoverflow.inputs({data, account, q})
if (!user) if (!user)
throw {error: {message: "You must provide a stackoverflow user id"}} throw {error: {message: "Stack Overflow user id is not set"}}
//Initialization //Initialization
//See https://api.stackexchange.com/docs //See https://api.stackexchange.com/docs
@@ -64,9 +64,7 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, graphql, data, imports, q, queries, account}, {enabled = false} = {}) { export default async function({login, graphql, data, imports, q, queries, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.stargazers)) if ((!enabled) || (!q.stargazers) || (!imports.metadata.plugins.stargazers.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -59,7 +59,7 @@ export default async function({login, graphql, data, imports, q, queries, accoun
//Generating charts //Generating charts
let charts = null let charts = null
if (_charts === "chartist") { if ((_charts === "chartist")&&(imports.metadata.plugins.stargazers.extras("charts.type", {extras}))) {
console.debug(`metrics/compute/${login}/plugins > stargazers > generating charts`) console.debug(`metrics/compute/${login}/plugins > stargazers > generating charts`)
charts = await Promise.all([{data: total, low: total.min, high: total.max}, {data: increments, ref: 0, low: increments.min, high: increments.max, sign: true}].map(({data: {dates: set}, high, low, ref, sign = false}) => charts = await Promise.all([{data: total, low: total.min, high: total.max}, {data: increments, ref: 0, low: increments.min, high: increments.max, sign: true}].map(({data: {dates: set}, high, low, ref, sign = false}) =>
imports.chartist("line", { imports.chartist("line", {
@@ -100,6 +100,6 @@ export default async function({login, graphql, data, imports, q, queries, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.starlists)) if ((!enabled) || (!q.starlists) || (!imports.metadata.plugins.starlists.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -101,6 +101,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
throw {error: {message: "An error occured", instance: error}} throw imports.format.error(error)
} }
} }

View File

@@ -16,6 +16,8 @@ inputs:
Enable starlists plugin Enable starlists plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_starlists_limit: plugin_starlists_limit:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, graphql, q, queries, imports, account}, {enabled = false} = {}) { export default async function({login, data, graphql, q, queries, imports, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.stars)) if ((!enabled) || (!q.stars) || (!imports.metadata.plugins.stars.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -30,8 +30,6 @@ export default async function({login, data, graphql, q, queries, imports, accoun
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.support)) if ((!enabled) || (!q.support) || (!imports.metadata.plugins.support.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -24,7 +24,7 @@ export default async function({login, q, imports, data, account}, {enabled = fal
await frame.waitForSelector(".user-profile-names", {timeout: 5000}) await frame.waitForSelector(".user-profile-names", {timeout: 5000})
} }
catch { catch {
throw {error: {message: "Could not find matching account on github.community"}} throw {error: {message: "Account does not exists on github.community"}}
} }
} }
@@ -81,8 +81,6 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -14,4 +14,6 @@ inputs:
description: | description: |
Enable support plugin Enable support plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, data, imports, q, account}, {enabled = false} = {}) { export default async function({login, data, imports, q, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.topics)) if ((!enabled) || (!q.topics) || (!imports.metadata.plugins.topics.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -88,8 +88,6 @@ export default async function({login, data, imports, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
if (error.error?.message) throw imports.format.error(error)
throw error
throw {error: {message: "An error occured", instance: error}}
} }
} }

View File

@@ -18,6 +18,8 @@ inputs:
Enable topics plugin Enable topics plugin
type: boolean type: boolean
default: no default: no
extras:
- metrics.run.puppeteer.scrapping
plugin_topics_mode: plugin_topics_mode:
description: | description: |

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, imports, data, rest, q, account}, {enabled = false} = {}) { export default async function({login, imports, data, rest, q, account}, {enabled = false, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.traffic)) if ((!enabled) || (!q.traffic) || (!imports.metadata.plugins.traffic.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -27,9 +27,6 @@ export default async function({login, imports, data, rest, q, account}, {enabled
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error, {descriptions:{"403":"Insufficient token scopes"}})
if (error.status === 403)
message = "Insufficient token rights"
throw {error: {message, instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, imports, data, q, account}, {enabled = false, token = ""} = {}) { export default async function({login, imports, data, q, account}, {enabled = false, token = "", extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled) || (!q.tweets)) if ((!enabled) || (!q.tweets) || (!imports.metadata.plugins.tweets.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -93,13 +93,6 @@ export default async function({login, imports, data, q, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error)
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response?.data?.errors?.[0]?.message ?? null
message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
}
throw {error: {message, instance: error}}
} }
} }

View File

@@ -1,9 +1,9 @@
//Setup //Setup
export default async function({login, q, imports, data, account}, {enabled = false, token} = {}) { export default async function({login, q, imports, data, account}, {enabled = false, token, extras = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if (!enabled || !q.wakatime) if ((!enabled) || (!q.wakatime) || (!imports.metadata.plugins.wakatime.extras("enabled", {extras})))
return null return null
//Load inputs //Load inputs
@@ -46,13 +46,7 @@ export default async function({login, q, imports, data, account}, {enabled = fal
} }
//Handle errors //Handle errors
catch (error) { catch (error) {
let message = "An error occured" throw imports.format.error(error)
if (error.isAxiosError) {
const status = error.response?.status
message = `API returned ${status}`
error = error.response?.data ?? null
}
throw {error: {message, instance: error}}
} }
} }

View File

@@ -6,6 +6,8 @@
{"source": "/:login([-\\w]+)/:repository([-\\w]+)", "destination": "https://metrics.lecoq.io/:login/:repository"}, {"source": "/:login([-\\w]+)/:repository([-\\w]+)", "destination": "https://metrics.lecoq.io/:login/:repository"},
{"source": "/about/query/:login", "destination": "https://metrics.lecoq.io/about/query/:login"}, {"source": "/about/query/:login", "destination": "https://metrics.lecoq.io/about/query/:login"},
{"source": "/about/query/:login/:plugin", "destination": "https://metrics.lecoq.io/about/query/:login/:plugin"}, {"source": "/about/query/:login/:plugin", "destination": "https://metrics.lecoq.io/about/query/:login/:plugin"},
{"source": "/insights/query/:login", "destination": "https://metrics.lecoq.io/insights/query/:login"},
{"source": "/insights/query/:login/:plugin", "destination": "https://metrics.lecoq.io/insights/query/:login/:plugin"},
{"source": "/.uncache", "destination": "https://metrics.lecoq.io/.uncache"}, {"source": "/.uncache", "destination": "https://metrics.lecoq.io/.uncache"},
{"source": "/.requests", "destination": "https://metrics.lecoq.io/.requests"} {"source": "/.requests", "destination": "https://metrics.lecoq.io/.requests"}
], ],