From 3f2202de0ed83d52e64df25e064d1aad2cce0208 Mon Sep 17 00:00:00 2001 From: Nick Grato Date: Mon, 27 Nov 2023 14:34:23 -0800 Subject: [PATCH] analytics page (#416) * new pages * staching code * api * formatting: * formatting * removing debugs * formatting * formatting * formatting * formatting * date fix * adding analytics. * linting errors * fixing build: --------- Co-authored-by: Nick Grato --- client/package-lock.json | 14 +- client/package.json | 2 +- client/pages/analytics/analytics.module.scss | 15 + client/pages/analytics/index.tsx | 323 ++++++++++++++++++ client/services/analytics.service.ts | 22 ++ client/styles/tools/flex.scss | 5 + client/styles/tools/utility.scss | 28 ++ lib/dash.ex | 10 + lib/dash/hub.ex | 6 +- lib/dash/hub_stat.ex | 2 +- .../api/v1/analytics_controller.ex | 12 + lib/dash_web/router.ex | 1 + package-lock.json | 6 + 13 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 client/pages/analytics/analytics.module.scss create mode 100644 client/pages/analytics/index.tsx create mode 100644 client/services/analytics.service.ts create mode 100644 lib/dash_web/controllers/api/v1/analytics_controller.ex create mode 100644 package-lock.json diff --git a/client/package-lock.json b/client/package-lock.json index 45c87906..91aa573b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,7 +8,7 @@ "name": "client", "version": "0.1.0", "dependencies": { - "@mozilla/lilypad-ui": "2.0.0", + "@mozilla/lilypad-ui": "2.0.1", "@reduxjs/toolkit": "^1.8.1", "axios": "^0.27.1", "cookies-next": "^2.0.4", @@ -514,9 +514,9 @@ } }, "node_modules/@mozilla/lilypad-ui": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@mozilla/lilypad-ui/-/lilypad-ui-2.0.0.tgz", - "integrity": "sha512-o+pnH+9p1WhzhiYA+p/k+GFfkr2DS9BDnEWNOPclEXtZP3M0WJWSg0yg0HiXii1nkxuVtBr5NR/wrzlwbQI3nQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mozilla/lilypad-ui/-/lilypad-ui-2.0.1.tgz", + "integrity": "sha512-Rou5aPx6bN015kDMyrJ4KxZT59kPAQMiBITejDm/zBM6cqj/tp3FUMGTMeBmcrImsJTPWrXRVRnytmQ5RbEYbw==", "peerDependencies": { "react": ">=16.13.1" } @@ -6154,9 +6154,9 @@ } }, "@mozilla/lilypad-ui": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@mozilla/lilypad-ui/-/lilypad-ui-2.0.0.tgz", - "integrity": "sha512-o+pnH+9p1WhzhiYA+p/k+GFfkr2DS9BDnEWNOPclEXtZP3M0WJWSg0yg0HiXii1nkxuVtBr5NR/wrzlwbQI3nQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mozilla/lilypad-ui/-/lilypad-ui-2.0.1.tgz", + "integrity": "sha512-Rou5aPx6bN015kDMyrJ4KxZT59kPAQMiBITejDm/zBM6cqj/tp3FUMGTMeBmcrImsJTPWrXRVRnytmQ5RbEYbw==", "requires": {} }, "@next/env": { diff --git a/client/package.json b/client/package.json index d1c0a0cb..27e1994d 100644 --- a/client/package.json +++ b/client/package.json @@ -9,7 +9,7 @@ "lint": "next lint && prettier -c ." }, "dependencies": { - "@mozilla/lilypad-ui": "2.0.0", + "@mozilla/lilypad-ui": "2.0.1", "@reduxjs/toolkit": "^1.8.1", "axios": "^0.27.1", "cookies-next": "^2.0.4", diff --git a/client/pages/analytics/analytics.module.scss b/client/pages/analytics/analytics.module.scss new file mode 100644 index 00000000..362abf8e --- /dev/null +++ b/client/pages/analytics/analytics.module.scss @@ -0,0 +1,15 @@ +@use '../../styles/core/boilerplate' as *; + +.data_table { + border-collapse: collapse; + width: 100%; + td, + th { + padding: 12px 12px 12px 0px; + border-bottom: 1px solid rgb(126, 126, 126); + } + + th { + text-align: left; + } +} diff --git a/client/pages/analytics/index.tsx b/client/pages/analytics/index.tsx new file mode 100644 index 00000000..9a297958 --- /dev/null +++ b/client/pages/analytics/index.tsx @@ -0,0 +1,323 @@ +import Head from 'next/head'; +import styles from './analytics.module.scss'; +import Card from '@Shared/Card/Card'; +import { getAnalytics, HubStat } from 'services/analytics.service'; +import { Button, Input, Pill } from '@mozilla/lilypad-ui'; +import { useState, ChangeEvent } from 'react'; + +type SandboxPropsT = { + analytics: {}; +}; + +/** + * This modal is used to sandbox code. feel free to play, this will + * not show up on prod + */ +const Sandbox = ({ analytics }: SandboxPropsT) => { + const getFormattedDate = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = (today.getMonth() + 1).toString().padStart(2, '0'); + const date = today.getDate().toString().padStart(2, '0'); + return `${year}-${month}-${date}`; + }; + + const [firstStartDate, setFirstStartDate] = useState(getFormattedDate()); + const [firstEndDate, setFirstEndDate] = useState(getFormattedDate()); + + const [secondStartDate, setSecondStartDate] = useState(getFormattedDate()); + const [secondEndDate, setSecondEndDate] = useState(getFormattedDate()); + + type TierStatsT = { + p0: number[]; + p1: number[]; + b0: number[]; + }; + + const initTiers = { + p0: [], + p1: [], + b0: [], + }; + + const [tiers, setTiers] = useState(initTiers); + + const [compareTiers, setCompareTiers] = useState(initTiers); + + const [analyzedData, setAnalyzedData] = useState({ + p0: { + persistent: 0, + dropped: 0, + gained: 0, + }, + p1: { + persistent: 0, + dropped: 0, + gained: 0, + }, + b0: { + persistent: 0, + dropped: 0, + gained: 0, + }, + }); + + const filterHubs = (hubs: HubStat[]): TierStatsT => { + const data: TierStatsT = { + p0: [], + p1: [], + b0: [], + }; + + hubs.forEach((hub) => { + data[hub.tier].push(hub.hub_id); + }); + + return data; + }; + + const analyzeData = (hubs: TierStatsT, compareHubs: TierStatsT) => { + const hubsP0Set = new Set(hubs.p0); + const hubsP1Set = new Set(hubs.p1); + const hubsB0Set = new Set(hubs.b0); + + const compareHubsP0Set = new Set(compareHubs.p0); + const compareHubsP1Set = new Set(compareHubs.p1); + const compareHubsB0Set = new Set(compareHubs.b0); + + const p0Persistant = hubs.p0.filter((id) => compareHubsP0Set.has(id)); + const p0Gained = compareHubs.p0.filter((id) => !hubsP0Set.has(id)); + + const p1Persistant = hubs.p1.filter((id) => compareHubsP1Set.has(id)); + const p1Gained = compareHubs.p1.filter((id) => !hubsP1Set.has(id)); + + const b0Persistant = hubs.b0.filter((id) => compareHubsB0Set.has(id)); + const b0Gained = compareHubs.b0.filter((id) => !hubsB0Set.has(id)); + + const data = { + p0: { + persistent: p0Persistant.length, + dropped: hubs.p0.length - p0Persistant.length, + gained: p0Gained.length, + }, + p1: { + persistent: p1Persistant.length, + dropped: hubs.p1.length - p0Persistant.length, + gained: p1Gained.length, + }, + b0: { + persistent: b0Persistant.length, + dropped: hubs.b0.length - p0Persistant.length, + gained: b0Gained.length, + }, + }; + + setAnalyzedData(data); + }; + + const onCompare = async () => { + try { + const hubs = await getAnalytics(firstStartDate, firstEndDate); + const compareHubs = await getAnalytics(secondStartDate, secondEndDate); + const filteredHub = filterHubs(hubs); + const compareFilteredHub = filterHubs(compareHubs); + + analyzeData(filteredHub, compareFilteredHub); + setTiers(filteredHub); + setCompareTiers(compareFilteredHub); + } catch (error) { + console.log(error); + } + }; + + return ( +
+ + Sandbox + +
+
+ +

Analytics

+ +
+

+ Selected a start and end date to see how many active hubs. +

+
+ ) => + setFirstStartDate(e.target.value) + } + label="Start Date" + placeholder="date" + name="start_date" + /> + + ) => + setFirstEndDate(e.target.value) + } + label="End Date" + placeholder="date" + name="end_date" + /> +
+
+ +
+

+ Selected a start and end date to compare to previously selected + point in time. +

+
+ ) => + setSecondStartDate(e.target.value) + } + label="Start Date" + placeholder="date" + name="start_date" + /> + + ) => + setSecondEndDate(e.target.value) + } + label="End Date" + placeholder="date" + name="end_date" + /> +
+
+
+
+ +
+
+

Active Hubs

+
+

+ From: {firstStartDate} to {firstEndDate} +

+
+ + + +
+
+
+

+ From: {secondStartDate} to {secondEndDate} +

+
+ + + +
+
+

Hub Behaviour

+

+ The follow data comes form the HubStat table and the Hubs table. + If a hub is active it is logged to the HubStat table. This query + leverages the HubStat table to see what hubs are active on + specific dates. Persistent is how many hubs remained in + the two date ranges, Dropped is how many hub id's + where in the first date range but not in the second. + Gained are active Hubs in the second date range but not + in the first. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TierPersistentDroppedGained
+ + {analyzedData.p0.persistent}{analyzedData.p0.dropped}{analyzedData.p0.gained}
+ + {analyzedData.p1.persistent}{analyzedData.p1.dropped}{analyzedData.p1.gained}
+ + {analyzedData.b0.persistent}{analyzedData.b0.dropped}{analyzedData.b0.gained}
+
+
+
+
+
+ ); +}; + +export default Sandbox; + +export async function getStaticProps() { + if (process.env.ENV === 'prod') { + return { notFound: true }; + } + + return { + props: {}, + }; +} diff --git a/client/services/analytics.service.ts b/client/services/analytics.service.ts new file mode 100644 index 00000000..14c3186f --- /dev/null +++ b/client/services/analytics.service.ts @@ -0,0 +1,22 @@ +import axios, { AxiosResponse } from 'axios'; +import { PUBLIC_API_SERVER } from 'config'; +import { TierT } from 'types/General'; + +const API_PATH = '/api/v1/analytics'; + +export type HubStat = { + hub_id: number; + tier: TierT; +}; + +export const getAnalytics = async (startDate: string, endDate: string) => { + const path = `/?start_date=${startDate}T00:00:00Z&end_date=${endDate}T00:00:00Z`; + + return axios + .get(`${PUBLIC_API_SERVER}${API_PATH}${path}`, { + withCredentials: true, + }) + .then((response: AxiosResponse) => { + return response.data.hubs as HubStat[]; + }); +}; diff --git a/client/styles/tools/flex.scss b/client/styles/tools/flex.scss index 39f0f557..6e22c2cc 100644 --- a/client/styles/tools/flex.scss +++ b/client/styles/tools/flex.scss @@ -41,3 +41,8 @@ .flex-shrink-0 { flex-shrink: 0; } + +.flex-column { + display: flex; + flex-direction: column; +} diff --git a/client/styles/tools/utility.scss b/client/styles/tools/utility.scss index af36928a..5e44c747 100644 --- a/client/styles/tools/utility.scss +++ b/client/styles/tools/utility.scss @@ -249,3 +249,31 @@ $fontSizes: 12, 14, 16, 18, 20; height: 100%; } } + +// Create Size Value Array +$start: 0; +$end: 50; +$array: ''; +$unit: 'px'; +$array: set-nth($array, 1, $start); + +@for $i from $start + 1 through $end { + $array: append($array, $i, comma); +} + +@each $size in $array { + .gap-#{$size} { + display: flex; + gap: #{$size}#{$unit}; + &-dt { + @include mobile-up { + gap: #{$size}#{$unit}; + } + } + &-mb { + @include mobile-down { + gap: #{$size}#{$unit}; + } + } + } +} diff --git a/lib/dash.ex b/lib/dash.ex index fa7d033f..3591543a 100644 --- a/lib/dash.ex +++ b/lib/dash.ex @@ -314,4 +314,14 @@ defmodule Dash do def subdomain_wait(), do: Application.get_env(:dash, __MODULE__)[:subdomain_wait_time] + + def get_hubs_by_date(start_date, end_date) do + query = + from(hub in Hub, + join: stat in assoc(hub, :hub_stats), + where: stat.measured_at >= ^start_date and stat.measured_at <= ^end_date + ) + + Repo.all(query) + end end diff --git a/lib/dash/hub.ex b/lib/dash/hub.ex index 10b8c447..c101eef9 100644 --- a/lib/dash/hub.ex +++ b/lib/dash/hub.ex @@ -1,7 +1,7 @@ defmodule Dash.Hub do use Ecto.Schema - alias Dash.{HubDeployment, Repo, RetClient, SubdomainDenial, TaskSupervisor} + alias Dash.{HubDeployment, Repo, RetClient, SubdomainDenial, TaskSupervisor, HubStat} import Dash.Utils, only: [rand_string: 1] import Ecto.Changeset import Ecto.Query @@ -9,7 +9,7 @@ defmodule Dash.Hub do @type id :: pos_integer @type t :: %__MODULE__{account_id: id} - + @derive {Jason.Encoder, only: [:tier, :hub_id]} @personal_ccu_limit 20 @personal_storage_limit_mb 2_000 @@ -24,7 +24,7 @@ defmodule Dash.Hub do belongs_to :account, Dash.Account, references: :account_id has_one :deployment, HubDeployment, foreign_key: :hub_id - + has_many :hub_stats, HubStat, foreign_key: :hub_id timestamps() end diff --git a/lib/dash/hub_stat.ex b/lib/dash/hub_stat.ex index f5f1156e..6d039b26 100644 --- a/lib/dash/hub_stat.ex +++ b/lib/dash/hub_stat.ex @@ -6,7 +6,7 @@ defmodule Dash.HubStat do schema "hub_stats" do field :measured_at, :utc_datetime field :storage_mb, :integer - field :hub_id, :integer + belongs_to :hub, Hub, references: :hub_id end defp hub_stat_for_hub_id(hub_id, measured_at) do diff --git a/lib/dash_web/controllers/api/v1/analytics_controller.ex b/lib/dash_web/controllers/api/v1/analytics_controller.ex new file mode 100644 index 00000000..0907772e --- /dev/null +++ b/lib/dash_web/controllers/api/v1/analytics_controller.ex @@ -0,0 +1,12 @@ +defmodule DashWeb.Api.V1.AnalyticsController do + use DashWeb, :controller + + require Logger + + def show(conn, %{"start_date" => start_date, "end_date" => end_date} = _params) do + hubs = Dash.get_hubs_by_date(start_date, end_date) + + conn + |> json(%{hubs: hubs}) + end +end diff --git a/lib/dash_web/router.ex b/lib/dash_web/router.ex index 1a68dc05..7e03a843 100644 --- a/lib/dash_web/router.ex +++ b/lib/dash_web/router.ex @@ -37,6 +37,7 @@ defmodule DashWeb.Router do resources "/account", Api.V1.AccountController, only: [:show], singleton: true resources "/plans", Api.V1.PlanController, only: [:create] resources "/subscription", Api.V1.SubscriptionController, only: [:show], singleton: true + resources "/analytics", Api.V1.AnalyticsController, only: [:show], singleton: true end scope "/api/v1", DashWeb do diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..f991e334 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "turkey-portal", + "lockfileVersion": 2, + "requires": true, + "packages": {} +}