portal: Status indicator badge (#1703)

Did some research on status page providers to manage incidents.
statuspage.io seems to be easy to use and cost-effective, fairly popular
and provides a good amount of flexibility to customize emails,
notifications, etc.

Super easy to set up and use but am not married to it if anyone feels
strongly about using another incident management service.

https://firezone.statuspage.io

## Demo:

<img width="235" alt="Screenshot 2023-06-27 at 8 07 29 AM"
src="https://github.com/firezone/firezone/assets/167144/8ad12b9b-7345-4a5d-bf43-c8af798d85f9">
This commit is contained in:
Jamil
2023-06-27 14:19:31 -07:00
committed by GitHub
parent 242d5d6975
commit b50f6559d3
7 changed files with 250 additions and 14 deletions

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": false
}

View File

@@ -1,7 +1,7 @@
import "@fontsource/source-sans-pro";
import "@fontsource/source-sans-pro"
// Import CSS generated by Tailwind compiler
import "../tmp/tailwind/app.css";
import "../tmp/tailwind/app.css"
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
@@ -13,15 +13,21 @@ import "flowbite/dist/flowbite.phoenix.js"
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Hooks from "./hooks"
// Read CSRF token from the meta tag and use it in the LiveSocket params
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } })
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
params: { _csrf_token: csrfToken },
})
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300))
window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()

View File

@@ -0,0 +1,35 @@
import StatusPage from "../vendor/status_page"
let Hooks = {}
// Update status indicator when sidebar is mounted or updated
let statusIndicatorClassNames = {
none: "bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300",
minor:
"bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-yellow-900 dark:text-yellow-300",
major:
"bg-orange-100 text-orange-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-orange-900 dark:text-orange-300",
critical:
"bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300",
}
const statusUpdater = function () {
const self = this
const sp = new StatusPage.page({ page: "firezone" })
sp.summary({
success: function (data) {
self.el.innerHTML = `<span class="${
statusIndicatorClassNames[data.status.indicator]
}">${data.status.description}</span>`
},
error: function (data) {
console.error("An error occured while fetching status page data")
self.el.innerHTML = `<span class="${statusIndicatorClassNames.minor}">Unable to fetch status</span>`
},
})
}
Hooks.StatusPage = {
mounted: statusUpdater,
updated: statusUpdater,
}
export default Hooks

View File

@@ -0,0 +1,184 @@
// StatusPage API Wrapper. Vendored and reviewed by @jamilbk from https://cdn.statuspage.io/se-v2.js
;(StatusPage = "undefined" == typeof StatusPage ? {} : StatusPage),
(StatusPage.page = function (e) {
if (!(e = e || {}).page)
throw new Error("A pageId is required to initialize.")
;(this.apiKey = e.apiKey || null),
(this.error = e.error || this.error),
(this.format = e.format || "json"),
(this.pageId = e.page),
(this.version = e.version || "v2"),
(this.secure = !("secure" in e) || e.secure),
(this.protocol = this.secure ? "https" : "http"),
(this.host = e.host || "statuspage.io"),
(this.host_with_port_and_protocol = e.test
? ""
: this.protocol + "://" + this.pageId + "." + this.host)
}),
(StatusPage.page.prototype.serialize = function (e, t) {
var s = [],
r = { sms: "email_sms", webhook: "endpoint" }
for (var o in e)
if ("to_sentence" !== o) {
var i = o
o = o in r ? r[o] : o
var a = t ? t + "[" + o + "]" : o,
n = e[i]
s.push(
"object" == typeof n
? this.serialize(n, a)
: encodeURIComponent(a) + "=" + encodeURIComponent(n)
)
}
return s.join("&")
}),
(StatusPage.page.prototype.createStatusPageCORSRequest = function (e, t) {
var s = new XMLHttpRequest()
return (
"withCredentials" in s
? s.open(e, t, !0)
: "undefined" != typeof XDomainRequest
? (s = new XDomainRequest()).open(e, t)
: (s = null),
s
)
}),
(StatusPage.page.prototype.executeRequestAndCallbackWithResponse = function (
e
) {
if (!e.path) throw new Error("A path is required to make a request")
var t = e.path,
s = e.method || "GET",
r = e.success || null,
o = e.error || this.error,
i =
this.host_with_port_and_protocol +
"/api/" +
this.version +
"/" +
t +
"." +
this.format,
a = this.createStatusPageCORSRequest(s, i)
if (a)
if (
(this.apiKey &&
(console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
a.setRequestHeader("Authorization", "OAuth " + this.apiKey)),
(a.onload = function () {
var e = JSON.parse(a.responseText)
r && r(e)
}),
(a.onerror = o),
"POST" === s || "DELETE" === s)
) {
var n = e.data || {}
a.setRequestHeader("Content-type", "application/x-www-form-urlencoded"),
a.send(this.serialize(n))
} else a.send()
}),
(StatusPage.page.prototype.get = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
if (!t.success) throw new Error("Success Callback is required.")
var s = t.success || {},
r = t.error || {}
this.executeRequestAndCallbackWithResponse({
path: e,
success: s,
error: r,
method: "GET",
})
}),
(StatusPage.page.prototype.post = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
var s = {}
if ("subscribers" === e) {
if (!t.subscriber) throw new Error("Subscriber is required to post.")
s.subscriber = t.subscriber
} else {
if (!t.data) throw new Error("Data is required to post.")
s = t.data
}
var r = t.success || {},
o = t.error || {}
this.executeRequestAndCallbackWithResponse({
data: s,
path: e,
success: r,
error: o,
method: "POST",
})
}),
(StatusPage.page.prototype["delete"] = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
if (!t.subscriber) throw new Error("Data is required to delete.")
var s = {}
"subscribers" === e ? (s.subscriber = t.subscriber) : (s = t.data)
var r = t.success || {},
o = t.error || {}
this.executeRequestAndCallbackWithResponse({
data: s,
path: e,
success: r,
error: o,
method: "DELETE",
})
}),
(StatusPage.page.prototype.error = function () {
console.log("There was an error with your request")
}),
(StatusPage.page.prototype.summary = function (e) {
this.get("summary", e)
}),
(StatusPage.page.prototype.status = function (e) {
this.get("status", e)
}),
(StatusPage.page.prototype.components = function (e) {
this.get("components", e)
}),
(StatusPage.page.prototype.incidents = function (e) {
switch (e.filter) {
case "unresolved":
this.get("incidents/unresolved", e)
break
case "resolved":
this.get("incidents/resolved", e)
break
default:
this.get("incidents", e)
}
}),
(StatusPage.page.prototype.scheduled_maintenances = function (e) {
switch (e.filter) {
case "active":
this.get("scheduled-maintenances/active", e)
break
case "upcoming":
this.get("scheduled-maintenances/upcoming", e)
break
default:
this.get("scheduled-maintenances", e)
}
}),
(StatusPage.page.prototype.subscribe = function (e) {
if (!e || !e.subscriber) throw new Error("A subscriber object is required.")
this.post("subscribers", e)
}),
(StatusPage.page.prototype.unsubscribe = function (e) {
if (!e || !e.subscriber) throw new Error("A subscriber object is required.")
if (!e.subscriber.id)
throw new Error(
"You must supply a subscriber.id in order to cancel a subscription."
)
this["delete"]("subscribers", e)
})
export default StatusPage

View File

@@ -1037,6 +1037,16 @@ defmodule Web.CoreComponents do
"""
end
def status_page_widget(assigns) do
~H"""
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white dark:bg-gray-800 z-20">
<.link href="https://firezone.statuspage.io" class="text-xs hover:underline">
<span id="status-page-widget" phx-hook="StatusPage" />
</.link>
</div>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do

View File

@@ -276,13 +276,7 @@
</li>
</ul>
</div>
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white dark:bg-gray-800 z-20">
<a href="#" class="text-xs hover:underline">
<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
All systems online
</span>
</a>
</div>
<.status_page_widget />
</aside>
<main class="md:ml-64 h-auto pt-16">

View File

@@ -53,7 +53,8 @@ config :web, Web.Plugs.SecureHeaders,
"default-src 'self' 'nonce-${nonce}'",
"img-src 'self' data: https://www.gravatar.com",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'"
"script-src 'self' 'unsafe-inline'",
"connect-src 'self' data: https://firezone.statuspage.io"
]
###############################