From b50f6559d330d0996784149f42f44539ba8acedb Mon Sep 17 00:00:00 2001 From: Jamil Date: Tue, 27 Jun 2023 14:19:31 -0700 Subject: [PATCH] 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: Screenshot 2023-06-27 at 8 07 29 AM --- .prettierrc.json | 6 + elixir/apps/web/assets/js/app.js | 18 +- elixir/apps/web/assets/js/hooks.js | 35 ++++ elixir/apps/web/assets/vendor/status_page.js | 184 ++++++++++++++++++ .../web/lib/web/components/core_components.ex | 10 + .../lib/web/components/layouts/app.html.heex | 8 +- elixir/config/dev.exs | 3 +- 7 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 .prettierrc.json create mode 100644 elixir/apps/web/assets/js/hooks.js create mode 100644 elixir/apps/web/assets/vendor/status_page.js diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..f6ca42860 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": false +} diff --git a/elixir/apps/web/assets/js/app.js b/elixir/apps/web/assets/js/app.js index 6592fd18f..3f861b754 100644 --- a/elixir/apps/web/assets/js/app.js +++ b/elixir/apps/web/assets/js/app.js @@ -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() diff --git a/elixir/apps/web/assets/js/hooks.js b/elixir/apps/web/assets/js/hooks.js new file mode 100644 index 000000000..529ca06ea --- /dev/null +++ b/elixir/apps/web/assets/js/hooks.js @@ -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 = `${data.status.description}` + }, + error: function (data) { + console.error("An error occured while fetching status page data") + self.el.innerHTML = `Unable to fetch status` + }, + }) +} +Hooks.StatusPage = { + mounted: statusUpdater, + updated: statusUpdater, +} + +export default Hooks diff --git a/elixir/apps/web/assets/vendor/status_page.js b/elixir/apps/web/assets/vendor/status_page.js new file mode 100644 index 000000000..f32088a94 --- /dev/null +++ b/elixir/apps/web/assets/vendor/status_page.js @@ -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 diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 3b4fed24c..3cfa32584 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -1037,6 +1037,16 @@ defmodule Web.CoreComponents do """ end + def status_page_widget(assigns) do + ~H""" +
+ <.link href="https://firezone.statuspage.io" class="text-xs hover:underline"> + + +
+ """ + end + ## JS Commands def show(js \\ %JS{}, selector) do diff --git a/elixir/apps/web/lib/web/components/layouts/app.html.heex b/elixir/apps/web/lib/web/components/layouts/app.html.heex index 1a531b8b1..d90cfdeea 100644 --- a/elixir/apps/web/lib/web/components/layouts/app.html.heex +++ b/elixir/apps/web/lib/web/components/layouts/app.html.heex @@ -276,13 +276,7 @@ -
- - - All systems online - - -
+ <.status_page_widget />
diff --git a/elixir/config/dev.exs b/elixir/config/dev.exs index b67bdc184..fd0474434 100644 --- a/elixir/config/dev.exs +++ b/elixir/config/dev.exs @@ -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" ] ###############################