From 130c74b2665ec406ba6da3273f49d35958b4ed55 Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Wed, 6 Jul 2022 04:37:39 +0200 Subject: [PATCH] refactor(app/web): new features (#1124) [skip ci] --- .github/config/label.yml | 2 +- .../readme/partials/templated/introduction.md | 4 +- .../partials/templated/plugins.community.md | 5 +- .github/scripts/preview.mjs | 21 +- .github/scripts/quickstart/plugin/index.mjs | 6 +- source/app/metrics/index.mjs | 10 +- source/app/metrics/metadata.mjs | 80 ++-- source/app/metrics/setup.mjs | 117 +++-- source/app/metrics/utils.mjs | 43 ++ source/app/web/instance.mjs | 428 ++++++++++-------- source/app/web/settings.example.json | 63 ++- source/app/web/statics/app.js | 231 +--------- source/app/web/statics/embed/app.js | 328 ++++++++++++++ .../statics/{ => embed}/app.placeholder.js | 0 source/app/web/statics/embed/index.html | 201 ++++++++ .../placeholders/isocalendar.full-year.svg | 0 .../placeholders/isocalendar.half-year.svg | 0 .../{ => embed}/placeholders/screenshot.png | Bin .../{ => embed}/placeholders/skyline.png | Bin .../{ => embed}/placeholders/stock.svg | 0 source/app/web/statics/index.html | 173 +------ .../statics/{about => insights}/index.html | 4 +- .../web/statics/{about => insights}/script.js | 8 +- .../web/statics/{about => insights}/style.css | 0 source/app/web/statics/style.css | 2 +- source/plugins/achievements/index.mjs | 6 +- source/plugins/achievements/metadata.yml | 2 + source/plugins/activity/index.mjs | 6 +- source/plugins/anilist/index.mjs | 13 +- source/plugins/anilist/metadata.yml | 2 + source/plugins/base/index.mjs | 3 +- source/plugins/base/metadata.yml | 1 - source/plugins/calendar/index.mjs | 6 +- source/plugins/code/index.mjs | 6 +- source/plugins/community/README.md | 3 +- source/plugins/community/fortune/index.mjs | 6 +- source/plugins/community/nightscout/index.mjs | 10 +- source/plugins/community/poopmap/index.mjs | 6 +- source/plugins/community/screenshot/index.mjs | 10 +- .../plugins/community/screenshot/metadata.yml | 2 + source/plugins/community/stock/index.mjs | 17 +- source/plugins/contributors/index.mjs | 6 +- source/plugins/core/metadata.yml | 10 +- source/plugins/discussions/index.mjs | 6 +- source/plugins/followup/index.mjs | 57 ++- source/plugins/gists/index.mjs | 8 +- source/plugins/habits/index.mjs | 10 +- source/plugins/introduction/index.mjs | 6 +- source/plugins/isocalendar/index.mjs | 8 +- source/plugins/languages/index.mjs | 79 ++-- source/plugins/languages/metadata.yml | 22 +- source/plugins/licenses/index.mjs | 4 +- source/plugins/licenses/metadata.yml | 1 + source/plugins/lines/index.mjs | 6 +- source/plugins/music/index.mjs | 49 +- source/plugins/music/metadata.yml | 2 + source/plugins/notable/index.mjs | 125 +++-- source/plugins/pagespeed/index.mjs | 24 +- source/plugins/people/index.mjs | 6 +- source/plugins/posts/index.mjs | 10 +- source/plugins/projects/index.mjs | 13 +- source/plugins/reactions/index.mjs | 6 +- source/plugins/repositories/index.mjs | 6 +- source/plugins/rss/index.mjs | 10 +- source/plugins/skyline/index.mjs | 6 +- source/plugins/skyline/metadata.yml | 2 + source/plugins/sponsors/index.mjs | 6 +- source/plugins/stackoverflow/index.mjs | 10 +- source/plugins/stargazers/index.mjs | 8 +- source/plugins/starlists/index.mjs | 6 +- source/plugins/starlists/metadata.yml | 2 + source/plugins/stars/index.mjs | 8 +- source/plugins/support/index.mjs | 10 +- source/plugins/support/metadata.yml | 4 +- source/plugins/topics/index.mjs | 8 +- source/plugins/topics/metadata.yml | 2 + source/plugins/traffic/index.mjs | 9 +- source/plugins/tweets/index.mjs | 13 +- source/plugins/wakatime/index.mjs | 12 +- vercel.json | 2 + 80 files changed, 1304 insertions(+), 1103 deletions(-) create mode 100644 source/app/web/statics/embed/app.js rename source/app/web/statics/{ => embed}/app.placeholder.js (100%) create mode 100644 source/app/web/statics/embed/index.html rename source/app/web/statics/{ => embed}/placeholders/isocalendar.full-year.svg (100%) rename source/app/web/statics/{ => embed}/placeholders/isocalendar.half-year.svg (100%) rename source/app/web/statics/{ => embed}/placeholders/screenshot.png (100%) rename source/app/web/statics/{ => embed}/placeholders/skyline.png (100%) rename source/app/web/statics/{ => embed}/placeholders/stock.svg (100%) rename source/app/web/statics/{about => insights}/index.html (99%) rename source/app/web/statics/{about => insights}/script.js (97%) rename source/app/web/statics/{about => insights}/style.css (100%) diff --git a/.github/config/label.yml b/.github/config/label.yml index bbba6ab1..f4f16be4 100644 --- a/.github/config/label.yml +++ b/.github/config/label.yml @@ -2,7 +2,7 @@ - source/app/action/** - source/app/web/** ✨ metrics insights: - - source/app/web/statics/about/** + - source/app/web/statics/insights/** 🧩 plugins: - source/plugins/** diff --git a/.github/readme/partials/templated/introduction.md b/.github/readme/partials/templated/introduction.md index 89473dbe..bbe2c3ef 100644 --- a/.github/readme/partials/templated/introduction.md +++ b/.github/readme/partials/templated/introduction.md @@ -79,8 +79,8 @@ Generate metrics that can be embedded everywhere, including your GitHub profile

🦑 Try it now!

- 📊 Metrics embed - ✨ Metrics insights + 📊 Metrics embed + ✨ Metrics insights diff --git a/.github/readme/partials/templated/plugins.community.md b/.github/readme/partials/templated/plugins.community.md index 45c398c2..24f76243 100644 --- a/.github/readme/partials/templated/plugins.community.md +++ b/.github/readme/partials/templated/plugins.community.md @@ -220,7 +220,8 @@ export default async function( }, //Settings and tokens { - enabled = false + enabled = false, + extras = false, } = {}) { //Plugin execution try { @@ -241,7 +242,7 @@ export default async function( } //Handle errors catch (error) { - throw {error:{message:"An error occured", instance:error}} + throw imports.format.error(error) } } ``` diff --git a/.github/scripts/preview.mjs b/.github/scripts/preview.mjs index e7021115..8763bec7 100644 --- a/.github/scripts/preview.mjs +++ b/.github/scripts/preview.mjs @@ -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 __node_modules = paths.join(paths.join(__metrics, "node_modules")) 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_js = paths.join(__preview, ".js") const __preview_css = paths.join(__preview, ".css") 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 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_templates, {recursive: true}) await fs.mkdir(__preview_templates_, {recursive: true}) -await fs.mkdir(__preview_about, {recursive: true}) //Web 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 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"})) -//About -fs.copyFile(paths.join(__web, "about", "index.html"), paths.join(__preview, "about", "index.html")) -for (const file of await fs.readdir(__web_about)) { - if (file !== ".statics") - fs.copyFile(paths.join(__web_about, file), paths.join(__preview_about, file)) -} +//Insights +for (const insight of ["insights", "about"]) { + const __web_insights = paths.join(paths.join(__web, insight)) + const __preview_insights = paths.join(__preview, `${insight}/.statics`) + 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)) + } +} \ No newline at end of file diff --git a/.github/scripts/quickstart/plugin/index.mjs b/.github/scripts/quickstart/plugin/index.mjs index defb82a8..7f956c21 100644 --- a/.github/scripts/quickstart/plugin/index.mjs +++ b/.github/scripts/quickstart/plugin/index.mjs @@ -1,15 +1,15 @@ //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 try { //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 //Results return {} } //Handle errors catch (error) { - throw {error:{message:"An error occured", instance:error}} + throw imports.format.error(error) } } \ No newline at end of file diff --git a/source/app/metrics/index.mjs b/source/app/metrics/index.mjs index 463191ce..e1f07278 100644 --- a/source/app/metrics/index.mjs +++ b/source/app/metrics/index.mjs @@ -24,7 +24,7 @@ export default async function metrics({login, q}, {graphql, rest, plugins, conf, //Initialization const pending = [] 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 imports = { 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"))) rendered = await imports.svg.optimize.svg(rendered, q, experimental) //Verify svg - if (verify) { + if ((verify)&&(imports.metadata.plugins.core.extras("verify", {...conf.settings, error:false}))) { console.debug(`metrics/compute/${login} > verify SVG`) let libxmljs = null try { @@ -281,9 +281,9 @@ metrics.insights.output = async function({login, imports, conf}, {graphql, rest, console.debug(`metrics/compute/${login} > insights > generating data`) const result = await metrics.insights({login}, {graphql, rest, conf}, {Plugins, Templates}) 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.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}) //Rendering @@ -297,7 +297,7 @@ metrics.insights.output = async function({login, imports, conf}, {graphql, rest, ${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}) => ``).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}) => ``).join("\n")} ` await browser.close() diff --git a/source/app/metrics/metadata.mjs b/source/app/metrics/metadata.mjs index 96f6cbff..f6129352 100644 --- a/source/app/metrics/metadata.mjs +++ b/source/app/metrics/metadata.mjs @@ -111,7 +111,7 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) { if (account !== "bypass") { const context = q.repo ? "repository" : account 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 const replacer = value => { @@ -214,33 +214,59 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) { //Extra features parser { - meta.extras = function(input, {extras = {}}) { - //Required permissions - const required = inputs[metadata.to.yaml(input, {name})]?.extras ?? null - if (!required) + meta.extras = function(input, {extras = {}, error = true}) { + const key = metadata.to.yaml(input, {name}) + try { + //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 - 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)) { - console.debug(`metrics/extras > ${name} > ${input} > extras is not a permission array, skipping`) - return false + catch { + if (!error) { + 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 }, yaml(key, {name = ""} = {}) { - const parts = [key.replaceAll(".", "_")] + const parts = [] + if (key !== "enabled") + parts.unshift(key.replaceAll(".", "_")) if (name) parts.unshift((name === "base") ? name : `plugin_${name}`) return parts.join("_") diff --git a/source/app/metrics/setup.mjs b/source/app/metrics/setup.mjs index e546b935..f0233913 100644 --- a/source/app/metrics/setup.mjs +++ b/source/app/metrics/setup.mjs @@ -78,63 +78,68 @@ export default async function({log = true, sandbox = false, community = {}} = {} logger("metrics/setup > load package.json > success") //Load community templates - if ((typeof conf.settings.community.templates === "string") && (conf.settings.community.templates.length)) { - 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 - logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`) - await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) - //Download community templates - for (const template of conf.settings.community.templates) { - try { - //Parse community template - logger(`metrics/setup > load community template ${template}`) - const {repo, branch, name, trust = false} = template.match(/^(?[\s\S]+?)@(?[\s\S]+?):(?[\s\S]+?)(?[+]trust)?$/)?.groups ?? null - const command = `git clone --single-branch --branch ${branch} https://github.com/${repo}.git ${path.join(__templates, ".community")}` - logger(`metrics/setup > run ${command}`) - //Clone remote repository - processes.execSync(command, {stdio: "ignore"}) - //Extract template - logger(`metrics/setup > extract ${name} from ${repo}@${branch}`) - await fs.promises.rm(path.join(__templates, `@${name}`), {recursive: true, force: true}) - await fs.promises.rename(path.join(__templates, ".community/source/templates", name), path.join(__templates, `@${name}`)) - //JavaScript file - if (trust) - logger(`metrics/setup > keeping @${name}/template.mjs (unsafe mode is enabled)`) - else if (fs.existsSync(path.join(__templates, `@${name}`, "template.mjs"))) { - logger(`metrics/setup > removing @${name}/template.mjs`) - await fs.promises.unlink(path.join(__templates, `@${name}`, "template.mjs")) - const inherit = yaml.load(`${fs.promises.readFile(path.join(__templates, `@${name}`, "metadata.yml"))}`).extends ?? null - if (inherit) { - logger(`metrics/setup > @${name} extends from ${inherit}`) - if (fs.existsSync(path.join(__templates, inherit, "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`) + if ((conf.settings.extras?.features?.includes("metrics.setup.community.templates"))||(conf.settings.extras?.features === true)||(conf.settings.extras?.default)) { + if ((typeof conf.settings.community.templates === "string") && (conf.settings.community.templates.length)) { + 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 + logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`) + await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) + //Download community templates + for (const template of conf.settings.community.templates) { + try { + //Parse community template + logger(`metrics/setup > load community template ${template}`) + const {repo, branch, name, trust = false} = template.match(/^(?[\s\S]+?)@(?[\s\S]+?):(?[\s\S]+?)(?[+]trust)?$/)?.groups ?? null + const command = `git clone --single-branch --branch ${branch} https://github.com/${repo}.git ${path.join(__templates, ".community")}` + logger(`metrics/setup > run ${command}`) + //Clone remote repository + processes.execSync(command, {stdio: "ignore"}) + //Extract template + logger(`metrics/setup > extract ${name} from ${repo}@${branch}`) + await fs.promises.rm(path.join(__templates, `@${name}`), {recursive: true, force: true}) + await fs.promises.rename(path.join(__templates, ".community/source/templates", name), path.join(__templates, `@${name}`)) + //JavaScript file + if (trust) + logger(`metrics/setup > keeping @${name}/template.mjs (unsafe mode is enabled)`) + else if (fs.existsSync(path.join(__templates, `@${name}`, "template.mjs"))) { + logger(`metrics/setup > removing @${name}/template.mjs`) + await fs.promises.unlink(path.join(__templates, `@${name}`, "template.mjs")) + const inherit = yaml.load(`${fs.promises.readFile(path.join(__templates, `@${name}`, "metadata.yml"))}`).extends ?? null + if (inherit) { + logger(`metrics/setup > @${name} extends from ${inherit}`) + if (fs.existsSync(path.join(__templates, inherit, "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}/template.mjs does not exist`) - } + else { + logger(`metrics/setup > @${name}/template.mjs does not exist`) + } - //Clean remote repository - logger(`metrics/setup > clean ${repo}@${branch}`) - await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) - logger(`metrics/setup > loaded community template ${name}`) - } - catch (error) { - logger(`metrics/setup > failed to load community template ${template}`) - logger(error) + //Clean remote repository + logger(`metrics/setup > clean ${repo}@${branch}`) + await fs.promises.rm(path.join(__templates, ".community"), {recursive: true, force: true}) + logger(`metrics/setup > loaded community template ${name}`) + } + catch (error) { + logger(`metrics/setup > failed to load community template ${template}`) + logger(error) + } } } + else { + logger("metrics/setup > no community templates to install") + } } else { - logger("metrics/setup > no community templates to install") + logger("metrics/setup > community templates are disabled") } //Load templates @@ -188,6 +193,18 @@ export default async function({log = true, sandbox = false, community = {}} = {} //Load metadata 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 if (conf.settings.token) { try { diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 5077225f..f64ca644 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -125,6 +125,49 @@ export function formatters({timeZone} = {}) { 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} } diff --git a/source/app/web/instance.mjs b/source/app/web/instance.mjs index 69221bf0..edd83e13 100644 --- a/source/app/web/instance.mjs +++ b/source/app/web/instance.mjs @@ -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)) for (const template in conf.templates) app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`)) - //Placeholders - app.use("/.placeholders", express.static(`${conf.paths.statics}/placeholders`)) //Styles 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.prism.css", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/themes/prism-tomorrow.css`)) //Scripts 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/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`)) @@ -176,217 +173,258 @@ export default async function({sandbox = false} = {}) { //Pending requests const pending = new Map() - //About routes - app.use("/about/.statics/", express.static(`${conf.paths.statics}/about`)) - app.get("/about/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) - app.get("/about/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) - app.get("/about/:login", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) - app.get("/about/query/:login/:plugin/", 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") - } - //Check plugin - 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") - } - 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}`) + //Metrics insights + if (conf.settings.modes.includes("insights")) { + console.debug("metrics/app > setup insights mode") + //Legacy routes + app.get("/about/*", (req, res) => res.redirect(req.path.replace("/about/", "/insights/"))) + //Static routes + app.get("/insights/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/insights/index.html`)) + app.get("/insights/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/insights/index.html`)) + app.use("/insights/.statics/", express.static(`${conf.paths.statics}/insights`)) + //App routes + 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 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") } - else { - pending.set(`about.${login}`, new Promise(_solve => solve = _solve)) + //Check plugin + 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 ((!debug) && (cached) && (cache.get(`about.${login}`))) { - console.debug(`metrics/app/${login}/insights > using cached results`) - return res.send(cache.get(`about.${login}`)) + if (cache.get(`insights.${login}.${plugin}`)) + return res.send(cache.get(`insights.${login}.${plugin}`)) + return res.status(204).send("No content: no data fetched yet") + }) + 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 - 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(`about.${login}.${plugin}`, result) - }, - } - ;(async () => { - try { - const json = await metrics.insights({login}, {graphql, rest, conf, callbacks}, {Plugins, Templates}) - //Cache - cache.put(`about.${login}`, json) - if ((!debug) && (cached)) { - const maxage = Math.round(Number(req.query.cache)) - cache.put(`about.${login}`, json, maxage > 0 ? maxage : cached) + let solve = null + try { + //Prevent multiples requests + if ((!debug) && (!mock) && (pending.has(`insights.${login}`))) { + console.debug(`metrics/app/${login}/insights > awaiting pending request`) + await pending.get(`insights.${login}`) + } + else { + pending.set(`insights.${login}`, new Promise(_solve => solve = _solve)) + } + //Read cached data if possible + if ((!debug) && (cached) && (cache.get(`insights.${login}`))) { + console.debug(`metrics/app/${login}/insights > using cached results`) + return res.send(cache.get(`insights.${login}`)) + } + //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) { - console.error(`metrics/app/${login}/insights > error > ${error}`) + //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(/`(?[\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)`) } - })() - 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") + //General error + console.error(error) + return res.status(500).send("Internal Server Error: failed to process metrics correctly") } - //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(/`(?[\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)`) + finally { + solve?.() + _requests_refresh = true } - //General error - console.error(error) - return res.status(500).send("Internal Server Error: failed to process metrics correctly") - } - finally { - solve?.() - _requests_refresh = true - } - }) + }) + } + else { + app.get("/about/*", (req, res) => res.redirect(req.path.replace("/about/", "/insights/"))) + app.get("/insights/*", (req, res) => res.status(405).send("Method not allowed: this endpoint is not available")) + } - //Metrics - app.get("/:login/:repository?", ...middlewares, async (req, res) => { - //Request params - const login = req.params.login?.replace(/[\n\r]/g, "") - const repository = req.params.repository?.replace(/[\n\r]/g, "") - let solve = null - //Check username - if (!/^[-\w]+$/i.test(login)) { - console.debug(`metrics/app/${login} > 400 (invalid username)`) - return res.status(400).send("Bad request: username seems invalid") - } - //Allowed list check - if ((restricted.length) && (!restricted.includes(login))) { - 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)) - } + //Metrics embed + if (conf.settings.modes.includes("embed")) { + console.debug("metrics/app > setup embed mode") + //Static routes + app.get("/embed/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/index.html`)) + app.get("/embed/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/index.html`)) + app.use("/.placeholders", express.static(`${conf.paths.statics}/embed/placeholders`)) + app.get("/.js/embed/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.js`)) + app.get("/.js/embed/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.placeholder.js`)) + //App routes + app.get("/:login/:repository?", ...middlewares, async (req, res) => { + //Request params + const login = req.params.login?.replace(/[\n\r]/g, "") + const repository = req.params.repository?.replace(/[\n\r]/g, "") + let solve = null + //Check username + if (!/^[-\w]+$/i.test(login)) { + console.debug(`metrics/app/${login} > 400 (invalid username)`) + return res.status(400).send("Bad request: username seems invalid") + } + //Allowed list check + if ((restricted.length) && (!restricted.includes(login))) { + 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 - if ((!debug) && (cached) && (cache.get(login))) { - console.debug(`metrics/app/${login} > using cached image`) - const {rendered, mime} = cache.get(login) - res.header("Content-Type", mime) - return res.send(rendered) - } - //Maximum simultaneous users - if ((maxusers) && (cache.size() + 1 > maxusers)) { - 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") - } - //Repository alias - if (repository) { - console.debug(`metrics/app/${login} > compute repository metrics`) - if (!req.query.template) - req.query.template = "repository" - req.query.repo = repository - } + //Read cached data if possible + if ((!debug) && (cached) && (cache.get(login))) { + console.debug(`metrics/app/${login} > using cached image`) + const {rendered, mime} = cache.get(login) + res.header("Content-Type", mime) + return res.send(rendered) + } + //Maximum simultaneous users + if ((maxusers) && (cache.size() + 1 > maxusers)) { + 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") + } + //Repository alias + if (repository) { + console.debug(`metrics/app/${login} > compute repository metrics`) + if (!req.query.template) + req.query.template = "repository" + req.query.repo = repository + } - //Compute rendering - try { - //Render - const q = req.query - 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)) { - console.debug(`metrics/app/${login} > presets have been specified, loading them`) - Object.assign(q, await presets(q["config.presets"])) + //Compute rendering + try { + //Render + const q = req.query + console.debug(`metrics/app/${login} > ${util.inspect(q, {depth: Infinity, maxStringLength: 256})}`) + 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`) + 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}, { - graphql, - rest, - plugins, - conf, - die: q["plugins.errors.fatal"] ?? false, - verify: q.verify ?? false, - convert: ["svg", "jpeg", "png", "json", "markdown", "markdown-pdf", "insights"].includes(q["config.output"]) ? q["config.output"] : null, - }, {Plugins, Templates}) - //Cache - if ((!debug) && (cached)) { - const maxage = Math.round(Number(req.query.cache)) - cache.put(login, {rendered, mime}, maxage > 0 ? maxage : cached) + //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)`) + return res.status(400).send("Bad request: unsupported template") + } + //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(/`(?[\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 - res.header("Content-Type", mime) - return res.send(rendered) - } - //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") + finally { + //After rendering + solve?.() + _requests_refresh = true } - //Invalid template - if ((error instanceof Error) && (/^unsupported template$/.test(error.message))) { - console.debug(`metrics/app/${login} > 400 (bad request)`) - return res.status(400).send("Bad request: unsupported template") - } - //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(/`(?[\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 - } - }) + }) + } + else { + app.get("/embed/*", (req, res) => res.status(405).send("Method not allowed: this endpoint is not available")) + } //Listen app.listen(port, () => console.log([ - `Listening on port │ ${port}`, - `Debug mode │ ${debug}`, - `Mocked data │ ${conf.settings.mocked ?? false}`, - `Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`, - `Cached time │ ${cached} seconds`, - `Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth: Infinity, maxStringLength: 256}) : "(enabled)"}`, - `Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`, - `Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`, - `SVG optimization │ ${conf.settings.optimize ?? false}`, + "───────────────────────────────────────────────────────────────────", + "── Server configuration ───────────────────────────────────────────", + `Listening on port │ ${port}`, + `Modes │ ${conf.settings.modes}`, + "── Server capacity ───────────────────────────────────────────────", + `Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`, + `Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`, + `Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth: Infinity, maxStringLength: 256}) : "(enabled)"}`, + `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 !", ].join("\n"))) } diff --git a/source/app/web/settings.example.json b/source/app/web/settings.example.json index f278a8f2..3ac35afb 100644 --- a/source/app/web/settings.example.json +++ b/source/app/web/settings.example.json @@ -2,38 +2,53 @@ "//": "Example of configuration for metrics web instance", "//": "====================================================================", - "token": "MY GITHUB API TOKEN", "//": "GitHub Personal Token (required)", - "restricted": [], "//": "Authorized users (empty to disable)", - "maxusers": 0, "//": "Maximum users, (0 to disable)", - "cached": 3600000, "//": "Cache time rendered metrics (0 to disable)", - "ratelimiter": null, "//": "Rate limiter (see express-rate-limit documentation)", - "port": 3000, "//": "Listening port", - "optimize": true, "//": "SVG optimization", - "debug": false, "//": "Debug logs", - "debug.headless": false, "//": "Debug puppeteer process", - "mocked": false, "//": "Use mocked data instead of live APIs (use 'force' to use mocked token even if real token are defined)", - "repositories": 100, "//": "Number of repositories to use", - "padding": ["0", "8 + 11%"], "//": "Image padding (default)", + "token": "GITHUB API TOKEN", "//": "GitHub Personal Token (required)", + "modes": ["embed", "insights"], "//": "Web instance enabled modes", + "restricted": [], "//": "Authorized users (empty to disable)", + "maxusers": 0, "//": "Maximum users, (0 to disable)", + "cached": 3600000, "//": "Cache time rendered metrics (0 to disable)", + "ratelimiter": null, "//": "Rate limiter (see express-rate-limit documentation)", + "port": 3000, "//": "Listening port", + "optimize": true, "//": "SVG optimization", + "debug": false, "//": "Debug logs", + "debug.headless": false, "//": "Debug puppeteer process", + "mocked": false, "//": "Use mocked data instead of live APIs (use 'force' to use mocked token even if real token are defined)", + "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": { - "by": "", "//": "Web instance host (displayed in footer)", - "link": "", "//": "Web instance host link (displayed in footer)" + "by": "", "//": "Web instance host (displayed in footer)", + "link": "", "//": "Web instance host link (displayed in footer)" }, "community": { - "templates": [], "//": "Additional community templates to setup" + "templates": [], "//": "Additional community templates to setup" }, "templates": { - "default": "classic", "//": "Default template", - "enabled": [], "//": "Enabled templates (empty to enable all)" + "default": "classic", "//": "Default template", + "enabled": [], "//": "Enabled templates (empty to enable all)" }, "extras": { - "default": false, "//": "Default extras state (advised to let 'false' unless in debug mode)", - "presets": false, "//": "Allow use of 'config.presets' option", - "css": false, "//": "Allow use of 'extras.css' option", - "js": false, "//": "Allow use of 'extras.js' option", - "features": false, "//": "Enable extra features (advised to let 'false' on web instances)" + "default": false, "//": "Default extras state (advised to let 'false' unless in debug mode)", + "presets": false, "//": "Allow use of 'config.presets' option", + "features": false, "//": "Enable extra features (advised to let 'false' on web instances), see below for supported features", + "//": "________________________________________________________________________", + "//": "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": { "//": "Global plugin configuration", + "plugins.default": false, "//": "Default plugin state (advised to let 'false' unless in debug mode)", + "plugins": { "//": "Global plugin configuration", <% for (const name of Object.keys(plugins).filter(v => !["base", "core"].includes(v))) { -%> "<%= 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() %>" diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js index 54dfaa1a..ff820280 100644 --- a/source/app/web/statics/app.js +++ b/source/app/web/statics/app.js @@ -1,8 +1,4 @@ ;(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 @@ -62,7 +58,6 @@ } }, 100) }, - components: {Prism: PrismComponent}, //Watchers watch: { tab: { @@ -86,244 +81,20 @@ data: { version: "", user: "", - mode: "metrics", 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/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 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 {} - } - }, }, }) })() diff --git a/source/app/web/statics/embed/app.js b/source/app/web/statics/embed/app.js new file mode 100644 index 00000000..26b94dd4 --- /dev/null +++ b/source/app/web/statics/embed/app.js @@ -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 {} + } + }, + }, + }) +})() diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/embed/app.placeholder.js similarity index 100% rename from source/app/web/statics/app.placeholder.js rename to source/app/web/statics/embed/app.placeholder.js diff --git a/source/app/web/statics/embed/index.html b/source/app/web/statics/embed/index.html new file mode 100644 index 00000000..8649b937 --- /dev/null +++ b/source/app/web/statics/embed/index.html @@ -0,0 +1,201 @@ + + + + + Metrics + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/source/app/web/statics/placeholders/isocalendar.full-year.svg b/source/app/web/statics/embed/placeholders/isocalendar.full-year.svg similarity index 100% rename from source/app/web/statics/placeholders/isocalendar.full-year.svg rename to source/app/web/statics/embed/placeholders/isocalendar.full-year.svg diff --git a/source/app/web/statics/placeholders/isocalendar.half-year.svg b/source/app/web/statics/embed/placeholders/isocalendar.half-year.svg similarity index 100% rename from source/app/web/statics/placeholders/isocalendar.half-year.svg rename to source/app/web/statics/embed/placeholders/isocalendar.half-year.svg diff --git a/source/app/web/statics/placeholders/screenshot.png b/source/app/web/statics/embed/placeholders/screenshot.png similarity index 100% rename from source/app/web/statics/placeholders/screenshot.png rename to source/app/web/statics/embed/placeholders/screenshot.png diff --git a/source/app/web/statics/placeholders/skyline.png b/source/app/web/statics/embed/placeholders/skyline.png similarity index 100% rename from source/app/web/statics/placeholders/skyline.png rename to source/app/web/statics/embed/placeholders/skyline.png diff --git a/source/app/web/statics/placeholders/stock.svg b/source/app/web/statics/embed/placeholders/stock.svg similarity index 100% rename from source/app/web/statics/placeholders/stock.svg rename to source/app/web/statics/embed/placeholders/stock.svg diff --git a/source/app/web/statics/index.html b/source/app/web/statics/index.html index 32beeaa2..292a30b4 100644 --- a/source/app/web/statics/index.html +++ b/source/app/web/statics/index.html @@ -10,7 +10,6 @@ - @@ -22,165 +21,11 @@ Metrics {{ version }} -
- - -
- -
- - - -
- -
-
- - {{ user }}/README.md -
- - -
- -
-
- An error occurred while generating your metrics :(
- {{ generated.error.message }} -
-
-
-
- Add the markdown below to your README.md at {{ user }}/{{ user }} -
- -
-
-
-
- -
- Create a new workflow with the following content at {{ user }}/{{ user }} -
- -
-
-
- -
- - +
+
+ Hi +
+