Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve web instance #149

Merged
merged 3 commits into from
Feb 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 0 additions & 16 deletions .github/readme/partials/setup/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,6 @@ systemctl status github_metrics

</details>

<details>
<summary>⚠️ HTTP errors code</summary>

Following error codes may be encountered on web instance:

| Error code | Description |
| ------------------------- | -------------------------------------------------------------------------- |
| `400 Bad request` | Invalid query (e.g. unsupported template) |
| `403 Forbidden` | User not allowed in `restricted` users list |
| `404 Not found` | GitHub API did not found the requested user |
| `429 Too many requests` | Thrown when rate limiter is trigerred |
| `500 Internal error` | Server error while generating metrics images (check logs for more details) |
| `503 Service unavailable` | Maximum user capacity reached, only cached images can be accessed for now |

</details>

<details>
<summary>🔗 HTTP parameters</summary>

Expand Down
47 changes: 30 additions & 17 deletions source/app/web/instance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
skip(req, _res) {
return !!cache.get(req.params.login)
},
message:"Too many requests",
message:"Too many requests: retry later",
headers:true,
...ratelimiter,
}))
}
Expand All @@ -74,11 +75,11 @@
})

//Base routes
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000})
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000, headers:false})
const metadata = Object.fromEntries(Object.entries(conf.metadata.plugins)
.filter(([key]) => !["base", "core"].includes(key))
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "categorie", "web", "supports"].includes(key)))]))
const enabled = Object.entries(metadata).map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false}))
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "categorie", "web", "supports"].includes(key)))])
.map(([key, value]) => [key, key === "core" ? {...value, web:Object.fromEntries(Object.entries(value.web).filter(([key]) => /^config[.]/.test(key)).map(([key, value]) => [key.replace(/^config[.]/, ""), value]))} : value]))
const enabled = Object.entries(metadata).filter(([_name, {categorie}]) => categorie !== "core").map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false}))
const templates = Object.entries(Templates).map(([name]) => ({name, enabled:(conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false}))
const actions = {flush:new Map()}
let requests = {limit:0, used:0, remaining:0, reset:NaN}
Expand Down Expand Up @@ -119,17 +120,18 @@
app.get("/.js/prism.markdown.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/components/prism-markdown.min.js`))
//Meta
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
app.get("/.requests", limiter, async(req, res) => res.status(200).json(requests))
app.get("/.requests", limiter, (req, res) => res.status(200).json(requests))
app.get("/.hosted", limiter, (req, res) => res.status(200).json(conf.settings.hosted || null))
//Cache
app.get("/.uncache", limiter, async(req, res) => {
app.get("/.uncache", limiter, (req, res) => {
const {token, user} = req.query
if (token) {
if (actions.flush.has(token)) {
console.debug(`metrics/app/${actions.flush.get(token)} > flushed cache`)
cache.del(actions.flush.get(token))
return res.sendStatus(200)
}
return res.sendStatus(404)
return res.sendStatus(400)
}
{
const token = `${Math.random().toString(16).replace("0.", "")}${Math.random().toString(16).replace("0.", "")}`
Expand All @@ -139,25 +141,32 @@
})

//Metrics
const pending = new Set()
app.get("/:login/:repository", ...middlewares, (req, res) => res.redirect(`/${req.params.login}?template=repository&repo=${req.params.repository}&${Object.entries(req.query).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&")}`))
app.get("/:login", ...middlewares, async(req, res) => {
//Request params
const login = req.params.login?.replace(/[\n\r]/g, "")
if ((restricted.length)&&(!restricted.includes(login))) {
console.debug(`metrics/app/${login} > 403 (not in whitelisted users)`)
return res.sendStatus(403)
console.debug(`metrics/app/${login} > 403 (not in allowed users)`)
return res.status(403).send(`Forbidden: "${login}" not in allowed users`)
}
//Read cached data if possible
if ((!debug)&&(cached)&&(cache.get(login))) {
const {rendered, mime} = cache.get(login)
res.header("Content-Type", mime)
res.send(rendered)
return
return res.send(rendered)
}
//Maximum simultaneous users
if ((maxusers)&&(cache.size()+1 > maxusers)) {
console.debug(`metrics/app/${login} > 503 (maximum users reached)`)
return res.sendStatus(503)
return res.status(503).send("Service Unavailable: maximum users reached, only cached metrics are available")
}
//Prevent multiples requests
if (pending.has(login)) {
console.debug(`metrics/app/${login} > 409 (multiple requests)`)
return res.status(409).send(`Conflict: a request for "${login}" is being process, retry later`)
}
pending.add(login)

//Compute rendering
try {
Expand All @@ -175,23 +184,27 @@
cache.put(login, {rendered, mime}, cached)
//Send response
res.header("Content-Type", mime)
res.send(rendered)
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.sendStatus(404)
return res.status(404).send(`Not found: unknown user or organization "${login}"`)
}
//Invalid template
if ((error instanceof Error)&&(/^unsupported template$/.test(error.message))) {
console.debug(`metrics/app/${login} > 400 (bad request)`)
return res.sendStatus(400)
return res.status(400).send(`Bad request: unsupported template "${req.query.template}"`)
}
//General error
console.error(error)
res.sendStatus(500)
return res.status(500).send("Internal Server Error: failed to process metrics correctly")
}
//After rendering
finally {
pending.delete(login)
}
})

Expand Down
4 changes: 4 additions & 0 deletions source/app/web/settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"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": ["6%", "15%"], "//": "Image padding (default)",
"hosted": {
"by": "", "//": "Web instance host (displayed in footer)",
"link": "", "//": "Web instance host link (displayed in footer)"
},
"community": {
"templates": [], "//": "Additional community templates to setup"
},
Expand Down
28 changes: 17 additions & 11 deletions source/app/web/statics/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
const {data:metadata} = await axios.get("/.plugins.metadata")
const {data:base} = await axios.get("/.plugins.base")
const {data:version} = await axios.get("/.version")
const {data:hosted} = await axios.get("/.hosted")
templates.sort((a, b) => (a.name.startsWith("@") ^ b.name.startsWith("@")) ? (a.name.startsWith("@") ? 1 : -1) : a.name.localeCompare(b.name))
//Disable unsupported options
delete metadata.core.web.output
delete metadata.core.web.twemojis
//App
return new Vue({
//Initialization
Expand Down Expand Up @@ -52,10 +56,9 @@
palette:"light",
requests:{limit:0, used:0, remaining:0, reset:0},
cached:new Map(),
config:{
timezone:"",
animated:true,
},
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,
plugins:{
base,
list:plugins,
Expand Down Expand Up @@ -118,12 +121,14 @@
.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).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`)
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 !== templates[0]) ? [`template=${this.templates.selected}`] : []
//Generated url
const params = [...template, ...plugins, ...options, ...config].join("&")
const params = [...template, ...base, ...plugins, ...options, ...config].join("&")
return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}`
},
//Embedded generated code
Expand Down Expand Up @@ -157,9 +162,10 @@
` 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(/[.]/, "_")}: ${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).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
...Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => ` config_${key.replace(/[.]/, "_")}: ${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(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
].sort(),
].join("\n")
},
Expand All @@ -185,7 +191,7 @@
this.templates.placeholder.timeout = setTimeout(async () => {
this.templates.placeholder.image = await placeholder(this)
this.generated.content = ""
this.generated.error = false
this.generated.error = null
}, timeout)
},
//Resize mock image
Expand All @@ -207,9 +213,9 @@
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 = false
} catch {
this.generated.error = true
this.generated.error = null
} catch (error) {
this.generated.error = {code:error.response.status, message:error.response.data}
}
finally {
this.generated.pending = false
Expand Down
2 changes: 1 addition & 1 deletion source/app/web/statics/app.placeholder.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
const data = {
//Template elements
style, fonts, errors:[],
partials:new Set(partials),
partials:new Set([...(set.config.order||"").split(",").map(x => x.trim()).filter(x => partials.includes(x)), ...partials]),
//Plural helper
s(value, end = "") {
return value !== 1 ? {y:"ies", "":"s"}[end] : end
Expand Down
35 changes: 31 additions & 4 deletions source/app/web/statics/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<main :class="[palette]">
<template>

<header>
<header v-once>
<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 v{{ version }}</a>
</header>
Expand Down Expand Up @@ -45,7 +45,7 @@

<div class="ui-avatar" :style="{backgroundImage:avatar ? `url(${avatar})` : 'none'}"></div>

<input type="text" v-model="user" placeholder="Your GitHub username" @keyup="mock">
<input type="text" v-model="user" placeholder="Your GitHub username">
<button @click="generate" :disabled="(!user)||(generated.pending)">
{{ generated.pending ? 'Working on it :)' : 'Generate your metrics!' }}
</button>
Expand Down Expand Up @@ -77,7 +77,6 @@

<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">
Expand All @@ -90,7 +89,25 @@
<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 }}</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>
Expand All @@ -103,7 +120,10 @@
</div>

<div v-if="tab == 'overview'">
<div class="error" v-if="generated.error">An error occurred while generating your metrics :( Please try again later.</div>
<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'">
Expand All @@ -122,6 +142,13 @@

</div>

<footer v-once>
<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/github-metrics-as-svg-image">GitHub Action</a>
<span v-if="hosted">Hosted with ❤️ by <a :href="hosted.link">{{ hosted.by }}</a></span>
</footer>

</template>
</main>
<!-- Scripts -->
Expand Down
40 changes: 40 additions & 0 deletions source/app/web/statics/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,46 @@ body {
text-align: center;
}

/* Details */
details summary:hover {
background-color: var(--color-input-contrast-bg);
border-radius: 6px;
cursor: pointer;
}

details summary:focus {
outline: none;
}

/* Error */
.error {
padding: 1.25rem 1rem;
background-image: linear-gradient(var(--color-alert-error-bg),var(--color-alert-error-bg));
color: var(--color-alert-error-text);
border: 1px solid var(--color-alert-error-border);
border-radius: 6px;
}

/* Footer */
main > footer {
margin: 1rem;
margin-top: 2rem;
padding-top: 1rem;
font-size: .8rem;
color: var(--color-text-secondary);
border-top: 1px solid var(--color-border-secondary);
font-style: normal;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
}

main > footer > * {
margin: 0 1rem;
}

/* Media screen */
@media only screen and (min-width: 740px) {
.ui {
Expand Down
1 change: 1 addition & 0 deletions source/plugins/core/metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ inputs:
type: array
format: comma-separated
default: ""
example: base.header, base.repositories

# Use twemojis instead of emojis
# May increase filesize but emojis will be rendered the same across all platforms
Expand Down