From f84f59e0df5cb859beb57f1839fca8d3c618aa03 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 22:54:33 +0200 Subject: [PATCH] feat: build out centralcloud_ops staff portal with Grafana OnCall backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CentralcloudCore.OnCall API client (alert groups, schedules, users, escalation chains) β€” talks to Grafana OnCall HTTP API v1 - Add centralcloud_ops application, endpoint, router, layouts - Add RequireStaff auth plug, SessionController, HealthController - Add DashboardLive: firing/acked alerts with ack/resolve actions, auto-refresh - Add IncidentsLive: filterable incident list by status - Add IncidentLive: incident detail with ack/resolve/silence actions - Add OnCallLive: schedule cards showing who is currently on-call - Add StakeholdersLive: HostBill client search + service view + comms panel - Wire ONCALL_URL / ONCALL_API_TOKEN env vars in config and runtime.exs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/centralcloud_core/oncall.ex | 118 +++++++++++++ .../lib/centralcloud_ops/application.ex | 13 ++ .../controllers/health_controller.ex | 7 + .../controllers/session_controller.ex | 90 ++++++++++ .../lib/centralcloud_ops/endpoint.ex | 29 ++++ .../lib/centralcloud_ops/layouts.ex | 112 +++++++++++++ .../centralcloud_ops/live/dashboard_live.ex | 149 +++++++++++++++++ .../centralcloud_ops/live/incident_live.ex | 155 ++++++++++++++++++ .../centralcloud_ops/live/incidents_live.ex | 74 +++++++++ .../lib/centralcloud_ops/live/oncall_live.ex | 78 +++++++++ .../live/stakeholders_live.ex | 154 +++++++++++++++++ .../centralcloud_ops/plugs/require_staff.ex | 18 ++ .../lib/centralcloud_ops/router.ex | 48 ++++++ config/config.exs | 4 + config/runtime.exs | 4 + 15 files changed, 1053 insertions(+) create mode 100644 apps/centralcloud_core/lib/centralcloud_core/oncall.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/application.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/controllers/health_controller.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/controllers/session_controller.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/live/dashboard_live.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/live/incidents_live.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/live/oncall_live.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/live/stakeholders_live.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/plugs/require_staff.ex create mode 100644 apps/centralcloud_ops/lib/centralcloud_ops/router.ex diff --git a/apps/centralcloud_core/lib/centralcloud_core/oncall.ex b/apps/centralcloud_core/lib/centralcloud_core/oncall.ex new file mode 100644 index 0000000..15060d3 --- /dev/null +++ b/apps/centralcloud_core/lib/centralcloud_core/oncall.ex @@ -0,0 +1,118 @@ +defmodule CentralcloudCore.OnCall do + @moduledoc """ + Grafana OnCall API client. + + Talks to the Grafana OnCall HTTP API (v1). + Auth: Authorization: Token + """ + + # --------------------------------------------------------------------------- + # Alert groups (incidents) + + @spec list_alert_groups(keyword()) :: {:ok, map()} | {:error, term()} + def list_alert_groups(opts \\ []) do + params = %{} + params = if s = opts[:status], do: Map.put(params, :status, s), else: params + get("/alert_groups/", params) + end + + @spec get_alert_group(String.t()) :: {:ok, map()} | {:error, term()} + def get_alert_group(id), do: get("/alert_groups/#{id}/") + + @spec acknowledge_alert_group(String.t()) :: {:ok, map()} | {:error, term()} + def acknowledge_alert_group(id), do: post("/alert_groups/#{id}/acknowledge/", %{}) + + @spec resolve_alert_group(String.t()) :: {:ok, map()} | {:error, term()} + def resolve_alert_group(id), do: post("/alert_groups/#{id}/resolve/", %{}) + + @spec silence_alert_group(String.t(), pos_integer()) :: {:ok, map()} | {:error, term()} + def silence_alert_group(id, duration_seconds), + do: post("/alert_groups/#{id}/silence/", %{delay: duration_seconds}) + + # --------------------------------------------------------------------------- + # Schedules + + @spec list_schedules() :: {:ok, map()} | {:error, term()} + def list_schedules, do: get("/schedules/") + + @spec get_schedule(String.t()) :: {:ok, map()} | {:error, term()} + def get_schedule(id), do: get("/schedules/#{id}/") + + @spec get_schedule_shifts(String.t()) :: {:ok, map()} | {:error, term()} + def get_schedule_shifts(id), do: get("/schedules/#{id}/final_shifts/") + + # --------------------------------------------------------------------------- + # On-call users + + @spec current_oncall(String.t()) :: {:ok, list(map())} | {:error, term()} + def current_oncall(schedule_id) do + case get_schedule_shifts(schedule_id) do + {:ok, %{"results" => shifts}} -> + now = DateTime.utc_now() + + on_call = + shifts + |> Enum.filter(fn s -> + {:ok, from, _} = DateTime.from_iso8601(s["shift_start"]) + {:ok, to, _} = DateTime.from_iso8601(s["shift_end"]) + DateTime.compare(from, now) != :gt and DateTime.compare(to, now) == :gt + end) + |> Enum.map(& &1["user"]) + + {:ok, on_call} + + err -> + err + end + end + + # --------------------------------------------------------------------------- + # Users + + @spec list_users() :: {:ok, map()} | {:error, term()} + def list_users, do: get("/users/") + + @spec get_user(String.t()) :: {:ok, map()} | {:error, term()} + def get_user(id), do: get("/users/#{id}/") + + # --------------------------------------------------------------------------- + # Escalation chains + + @spec list_escalation_chains() :: {:ok, map()} | {:error, term()} + def list_escalation_chains, do: get("/escalation_chains/") + + # --------------------------------------------------------------------------- + # Private + + defp get(path, params \\ %{}) do + url = base_url() <> path + headers = auth_headers() + + case Req.get(url, params: params, headers: headers) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + {:ok, %{status: status, body: body}} -> {:error, {:http, status, body}} + {:error, reason} -> {:error, reason} + end + end + + defp post(path, body) do + url = base_url() <> path + headers = auth_headers() + + case Req.post(url, json: body, headers: headers) do + {:ok, %{status: s, body: b}} when s in 200..299 -> {:ok, b} + {:ok, %{status: status, body: body}} -> {:error, {:http, status, body}} + {:error, reason} -> {:error, reason} + end + end + + defp base_url do + cfg = Application.fetch_env!(:centralcloud_core, :oncall) + cfg[:base_url] <> "/api/v1" + end + + defp auth_headers do + cfg = Application.fetch_env!(:centralcloud_core, :oncall) + [{"Authorization", "Token #{cfg[:token]}"}] + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/application.ex b/apps/centralcloud_ops/lib/centralcloud_ops/application.ex new file mode 100644 index 0000000..c6ec3fe --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/application.ex @@ -0,0 +1,13 @@ +defmodule CentralcloudOps.Application do + use Application + + @impl true + def start(_type, _args) do + children = [ + CentralcloudOps.Endpoint + ] + + opts = [strategy: :one_for_one, name: CentralcloudOps.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/controllers/health_controller.ex b/apps/centralcloud_ops/lib/centralcloud_ops/controllers/health_controller.ex new file mode 100644 index 0000000..78a175b --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/controllers/health_controller.ex @@ -0,0 +1,7 @@ +defmodule CentralcloudOps.HealthController do + use Phoenix.Controller, formats: [:json] + + def check(conn, _params) do + json(conn, %{status: "ok"}) + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/controllers/session_controller.ex b/apps/centralcloud_ops/lib/centralcloud_ops/controllers/session_controller.ex new file mode 100644 index 0000000..74dce5e --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/controllers/session_controller.ex @@ -0,0 +1,90 @@ +defmodule CentralcloudOps.SessionController do + use Phoenix.Controller, formats: [:html] + import Plug.Conn + + def new(conn, _params) do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, login_page(conn)) + end + + def create(conn, %{"username" => _username, "password" => _password}) do + # TODO: validate against Authentik / staff directory + conn + |> put_flash(:error, "Invalid credentials") + |> redirect(to: "/login") + end + + def delete(conn, _params) do + conn + |> clear_session() + |> redirect(to: "/login") + end + + def oidc_callback(conn, params) do + # TODO: exchange code with Authentik, verify staff group membership, set session + _ = params + conn + |> put_flash(:info, "SSO login coming soon") + |> redirect(to: "/login") + end + + defp login_page(conn) do + csrf = Phoenix.Controller.get_csrf_token() + flash_error = Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) + + """ + + + + + + + Sign in β€” CentralCloud Ops + + + +
+

πŸ”₯ CentralCloud Ops

+

Staff portal β€” sign in to continue

+ #{if flash_error, do: "
#{flash_error}
", else: ""} +
+ + + + + + +
+
or
+ πŸ” Sign in with CentralCloud SSO +
+ + + """ + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex b/apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex new file mode 100644 index 0000000..fffc14b --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/endpoint.ex @@ -0,0 +1,29 @@ +defmodule CentralcloudOps.Endpoint do + use Phoenix.Endpoint, otp_app: :centralcloud_ops, adapter: Bandit.PhoenixAdapter + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: {CentralcloudOps.Router, :session_opts, []}]] + + plug Plug.Static, + at: "/", + from: {:centralcloud_ops, "priv/static"}, + gzip: false + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, + store: :cookie, + key: "_centralcloud_ops_session", + signing_salt: "ops_salt_2026", + same_site: "Lax" + + plug CentralcloudOps.Router +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex b/apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex new file mode 100644 index 0000000..edf0459 --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/layouts.ex @@ -0,0 +1,112 @@ +defmodule CentralcloudOps.Layouts do + use Phoenix.Component + + def render("root.html", assigns) do + ~H""" + + + + + + + CentralCloud Ops + + <.live_title suffix=" β€” CentralCloud Ops"> + {assigns[:page_title] || "Ops"} + + + + +
+

{msg}

+

{msg}

+ {@inner_content} +
+ + + """ + end + + def render("app.html", assigns) do + ~H""" + {@inner_content} + """ + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/live/dashboard_live.ex b/apps/centralcloud_ops/lib/centralcloud_ops/live/dashboard_live.ex new file mode 100644 index 0000000..a27879c --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/live/dashboard_live.ex @@ -0,0 +1,149 @@ +defmodule CentralcloudOps.DashboardLive do + use Phoenix.LiveView + alias CentralcloudCore.OnCall + + @refresh_ms 30_000 + + def mount(_params, _session, socket) do + if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms) + {:ok, load(socket)} + end + + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, load(socket)} + end + + def handle_event("ack", %{"id" => id}, socket) do + case OnCall.acknowledge_alert_group(id) do + {:ok, _} -> {:noreply, load(put_flash(socket, :info, "Alert acknowledged"))} + {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to acknowledge")} + end + end + + def handle_event("resolve", %{"id" => id}, socket) do + case OnCall.resolve_alert_group(id) do + {:ok, _} -> {:noreply, load(put_flash(socket, :info, "Alert resolved"))} + {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to resolve")} + end + end + + defp load(socket) do + firing = fetch_groups("firing") + acked = fetch_groups("acknowledged") + resolved = fetch_groups("resolved", limit: 5) + + assign(socket, + page_title: "Dashboard", + firing: firing, + acked: acked, + recent_resolved: resolved, + firing_count: length(firing), + acked_count: length(acked), + last_updated: DateTime.utc_now() + ) + end + + defp fetch_groups(status, _opts \\ []) do + case OnCall.list_alert_groups(status: status) do + {:ok, %{"results" => results}} -> results + _ -> [] + end + end + + def render(assigns) do + ~H""" +
πŸ”₯ Operations Dashboard
+ +
+
+
Firing
+
0, do: "red", else: "green"}"}>{@firing_count}
+
+
+
Acknowledged
+
0, do: "orange", else: "green"}"}>{@acked_count}
+
+
+
Recent Resolved
+
{length(@recent_resolved)}
+
+
+
Last Updated
+
+ {Calendar.strftime(@last_updated, "%H:%M:%S")} UTC +
+
+
+ +
+
🚨 Firing Alerts
+
+ + + + + + + + + + + + + + + + + +
AlertSourceStartedActions
+ + {ag["render_for_web"]["title"] || ag["id"]} + + {ag["alert_receive_channel"]["verbal_name"]}{format_dt(ag["started_at"])} + + +
+
+
+ +
+
⏳ Acknowledged
+
+ + + + + + + + + + + + +
AlertSourceAcked AtActions
+ + {ag["render_for_web"]["title"] || ag["id"]} + + {ag["alert_receive_channel"]["verbal_name"]}{format_dt(ag["acknowledged_at"])} + +
+
+
+ +
+
βœ…
+
All clear β€” no active alerts
+
+ """ + end + + defp format_dt(nil), do: "β€”" + defp format_dt(iso) do + case DateTime.from_iso8601(iso) do + {:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC") + _ -> iso + end + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex b/apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex new file mode 100644 index 0000000..4f6a088 --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/live/incident_live.ex @@ -0,0 +1,155 @@ +defmodule CentralcloudOps.IncidentLive do + use Phoenix.LiveView + alias CentralcloudCore.OnCall + + def mount(%{"id" => id}, _session, socket) do + socket = load(socket, id) + {:ok, socket} + end + + def handle_event("ack", _, socket) do + id = socket.assigns.id + case OnCall.acknowledge_alert_group(id) do + {:ok, _} -> {:noreply, load(put_flash(socket, :info, "Acknowledged"), id)} + _ -> {:noreply, put_flash(socket, :error, "Failed to acknowledge")} + end + end + + def handle_event("resolve", _, socket) do + id = socket.assigns.id + case OnCall.resolve_alert_group(id) do + {:ok, _} -> {:noreply, load(put_flash(socket, :info, "Resolved"), id)} + _ -> {:noreply, put_flash(socket, :error, "Failed to resolve")} + end + end + + def handle_event("silence", %{"duration" => dur}, socket) do + id = socket.assigns.id + seconds = String.to_integer(dur) * 60 + case OnCall.silence_alert_group(id, seconds) do + {:ok, _} -> {:noreply, load(put_flash(socket, :info, "Silenced for #{dur} minutes"), id)} + _ -> {:noreply, put_flash(socket, :error, "Failed to silence")} + end + end + + defp load(socket, id) do + {ag, alerts} = + case OnCall.get_alert_group(id) do + {:ok, ag} -> + alerts = ag["alerts"] || [] + {ag, alerts} + _ -> + {nil, []} + end + + assign(socket, + page_title: "Incident #{id}", + id: id, + alert_group: ag, + alerts: alerts + ) + end + + def render(assigns) do + ~H""" +
+ ← Back to Incidents +
+ +
+ Alert group not found. +
+ +
+
+
+
+ {@alert_group["status"]} +

+ {@alert_group["render_for_web"]["title"] || @alert_group["id"]} +

+
+
+ Source: {@alert_group["alert_receive_channel"]["verbal_name"]}  Β·  + Started: {format_dt(@alert_group["started_at"])} +
+
+ +
+ + + + +
+
+ +
+
+
Details
+ + + + + + + + + + + + + + + + + +
ID{@alert_group["id"]}
Status{@alert_group["status"]}
Acknowledged{format_dt(@alert_group["acknowledged_at"])}
Resolved{format_dt(@alert_group["resolved_at"])}
+
+ +
+
Render Preview
+
+ {@alert_group["render_for_web"]["message"] || "No message"} +
+
+
+ +
+
+ Raw Alerts ({length(@alerts)}) +
+ + + + + + + + + + + +
ReceivedTitleMessage
{format_dt(a["created_at"])}{a["render_for_web"]["title"]} + {a["render_for_web"]["message"]} +
+
+
+ """ + end + + defp format_dt(nil), do: "β€”" + defp format_dt(iso) do + case DateTime.from_iso8601(iso) do + {:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC") + _ -> iso + end + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/live/incidents_live.ex b/apps/centralcloud_ops/lib/centralcloud_ops/live/incidents_live.ex new file mode 100644 index 0000000..2b82a7b --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/live/incidents_live.ex @@ -0,0 +1,74 @@ +defmodule CentralcloudOps.IncidentsLive do + use Phoenix.LiveView + alias CentralcloudCore.OnCall + + def mount(_params, _session, socket) do + {:ok, load(socket, "firing")} + end + + def handle_event("filter", %{"status" => status}, socket) do + {:noreply, load(socket, status)} + end + + defp load(socket, status) do + groups = + case OnCall.list_alert_groups(status: status) do + {:ok, %{"results" => r}} -> r + _ -> [] + end + + assign(socket, page_title: "Incidents", groups: groups, status: status) + end + + def render(assigns) do + ~H""" +
πŸ—‚ Incidents
+ +
+ + + + +
+ +
+ No {@status} incidents. +
+ +
+ + + + + + + + + + + + + + + + + + + +
StatusTitleSourceStarted
{ag["status"]}{ag["render_for_web"]["title"] || ag["id"]}{ag["alert_receive_channel"]["verbal_name"]}{format_dt(ag["started_at"])}View β†’
+
+ """ + end + + defp format_dt(nil), do: "β€”" + defp format_dt(iso) do + case DateTime.from_iso8601(iso) do + {:ok, dt, _} -> Calendar.strftime(dt, "%d %b %H:%M UTC") + _ -> iso + end + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/live/oncall_live.ex b/apps/centralcloud_ops/lib/centralcloud_ops/live/oncall_live.ex new file mode 100644 index 0000000..bc0aeaa --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/live/oncall_live.ex @@ -0,0 +1,78 @@ +defmodule CentralcloudOps.OnCallLive do + use Phoenix.LiveView + alias CentralcloudCore.OnCall + + @refresh_ms 60_000 + + def mount(_params, _session, socket) do + if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms) + {:ok, load(socket)} + end + + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, load(socket)} + end + + defp load(socket) do + schedules = + case OnCall.list_schedules() do + {:ok, %{"results" => results}} -> + Enum.map(results, fn s -> + on_call = + case OnCall.current_oncall(s["id"]) do + {:ok, users} -> users + _ -> [] + end + Map.put(s, "current_oncall", on_call) + end) + + _ -> + [] + end + + assign(socket, page_title: "On-Call", schedules: schedules, last_updated: DateTime.utc_now()) + end + + def render(assigns) do + ~H""" +
πŸ“… On-Call Schedules
+ +

+ Updated at {Calendar.strftime(@last_updated, "%H:%M:%S")} UTC β€” refreshes every 60s +

+ +
+ No schedules found in Grafana OnCall. +
+ +
+
+
+ {s["name"]} +
+ +
+
+ Currently on-call +
+
+ Nobody on-call +
+
+ + {String.first(u["username"] || "?")} + + {u["username"]} + ({u["email"]}) +
+
+ +
+ View schedule β†’ +
+
+
+ """ + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/live/stakeholders_live.ex b/apps/centralcloud_ops/lib/centralcloud_ops/live/stakeholders_live.ex new file mode 100644 index 0000000..2ee56e0 --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/live/stakeholders_live.ex @@ -0,0 +1,154 @@ +defmodule CentralcloudOps.StakeholdersLive do + use Phoenix.LiveView + alias CentralcloudCore.{HostBill, OnCall} + + def mount(_params, _session, socket) do + {:ok, load(socket)} + end + + def handle_event("search", %{"q" => q}, socket) do + filtered = + socket.assigns.clients + |> Enum.filter(fn c -> + name = String.downcase(c["firstname"] <> " " <> c["lastname"] <> " " <> (c["companyname"] || "")) + String.contains?(name, String.downcase(q)) + end) + + {:noreply, assign(socket, filtered_clients: filtered, query: q)} + end + + def handle_event("select_client", %{"id" => id}, socket) do + client_id = String.to_integer(id) + + services = + case HostBill.get_client_services(client_id) do + {:ok, %{"products" => p}} -> p + _ -> [] + end + + client = Enum.find(socket.assigns.clients, &(&1["id"] == id)) + + {:noreply, assign(socket, selected_client: client, client_services: services)} + end + + defp load(socket) do + clients = + case HostBill.get_clients() do + {:ok, %{"clients" => c}} -> c + _ -> [] + end + + firing = + case OnCall.list_alert_groups(status: "firing") do + {:ok, %{"results" => r}} -> r + _ -> [] + end + + assign(socket, + page_title: "Stakeholders", + clients: clients, + filtered_clients: clients, + selected_client: nil, + client_services: [], + active_incidents: firing, + query: "" + ) + end + + def render(assigns) do + ~H""" +
πŸ‘₯ Stakeholder Management
+ +
+ + <%!-- Client list --%> +
+
+
+ +
+
+
+
+
{c["firstname"]} {c["lastname"]}
+
{c["companyname"]}
+
+
+ No clients found +
+
+
+ + <%!-- Client detail / comms panel --%> +
+
+ Select a client to view their services and send a status update. +
+ +
+
+
+ {@selected_client["firstname"]} {@selected_client["lastname"]} +
+
{@selected_client["email"]}
+
{@selected_client["companyname"]}
+
+ +
+
+ Active Services ({length(@client_services)}) +
+
+ No services found +
+ + + + + + + + + +
ServiceStatusNext Due
{svc["name"]} + + {svc["status"]} + + {svc["nextduedate"]}
+
+ +
+
πŸ“’ Send Status Update
+
+
+ Link to active incident +
+
+ + {inc["render_for_web"]["title"] || inc["id"]} + +
+
+ + +
+
+
+
+ """ + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/plugs/require_staff.ex b/apps/centralcloud_ops/lib/centralcloud_ops/plugs/require_staff.ex new file mode 100644 index 0000000..fdf2d5c --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/plugs/require_staff.ex @@ -0,0 +1,18 @@ +defmodule CentralcloudOps.Plugs.RequireStaff do + import Plug.Conn + import Phoenix.Controller, only: [redirect: 2] + + def init(opts), do: opts + + def call(conn, _opts) do + case get_session(conn, :staff_id) do + nil -> + conn + |> redirect(to: "/login") + |> halt() + + _id -> + conn + end + end +end diff --git a/apps/centralcloud_ops/lib/centralcloud_ops/router.ex b/apps/centralcloud_ops/lib/centralcloud_ops/router.ex new file mode 100644 index 0000000..c85e5ef --- /dev/null +++ b/apps/centralcloud_ops/lib/centralcloud_ops/router.ex @@ -0,0 +1,48 @@ +defmodule CentralcloudOps.Router do + use Phoenix.Router + import Phoenix.LiveView.Router + + @session_opts [store: :cookie, key: "_centralcloud_ops_session", signing_salt: "ops_salt_2026"] + def session_opts, do: @session_opts + + pipeline :api do + plug :accepts, ["json"] + end + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {CentralcloudOps.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :auth do + plug CentralcloudOps.Plugs.RequireStaff + end + + scope "/api", CentralcloudOps do + pipe_through :api + get "/health", HealthController, :check + end + + scope "/", CentralcloudOps do + pipe_through :browser + + get "/login", SessionController, :new + post "/login", SessionController, :create + delete "/logout", SessionController, :delete + get "/auth/callback", SessionController, :oidc_callback + end + + scope "/", CentralcloudOps do + pipe_through [:browser, :auth] + + live "/", DashboardLive, :index + live "/oncall", OnCallLive, :index + live "/incidents", IncidentsLive, :index + live "/incidents/:id", IncidentLive, :show + live "/stakeholders", StakeholdersLive, :index + end +end diff --git a/config/config.exs b/config/config.exs index 2723abf..d35ec05 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,6 +18,10 @@ config :centralcloud_core, :hostbill, config :centralcloud_core, :dr_portal, base_url: "https://dr.centralcloud.com" +# Grafana OnCall API (defaults β€” overridden at runtime) +config :centralcloud_core, :oncall, + base_url: "https://oncall.centralcloud.com" + # Authentik OIDC (defaults β€” overridden at runtime) config :centralcloud_core, :oidc, issuer: "https://sso.centralcloud.com/application/o/centralcloud/" diff --git a/config/runtime.exs b/config/runtime.exs index 987df92..c6f9cc2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -20,6 +20,10 @@ config :centralcloud_core, :dr_portal, base_url: System.get_env("DR_PORTAL_URL", "https://dr.centralcloud.com"), api_key: System.get_env("DR_PORTAL_API_KEY") +config :centralcloud_core, :oncall, + base_url: System.get_env("ONCALL_URL", "https://oncall.centralcloud.com"), + token: System.get_env("ONCALL_API_TOKEN") + config :centralcloud_core, :oidc, issuer: System.get_env("AUTHENTIK_ISSUER", "https://sso.centralcloud.com/application/o/centralcloud/"), client_id: System.get_env("OIDC_CLIENT_ID"),