diff --git a/admin.go b/admin.go index 4f42570..9923c73 100644 --- a/admin.go +++ b/admin.go @@ -3,8 +3,10 @@ package main import ( "bytes" "encoding/json" + "fmt" "net/http" + "github.com/jmoiron/sqlx/types" "github.com/labstack/echo" ) @@ -15,10 +17,8 @@ type configScript struct { Messengers []string `json:"messengers"` } -// handleGetStats returns a collection of general statistics. -func handleGetStats(c echo.Context) error { - app := c.Get("app").(*App) - return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()}) +type dashboardStats struct { + Stats types.JSONText `db:"stats"` } // handleGetConfigScript returns general configuration as a Javascript @@ -41,3 +41,18 @@ func handleGetConfigScript(c echo.Context) error { j.Encode(out) return c.Blob(http.StatusOK, "application/javascript", b.Bytes()) } + +// handleGetDashboardStats returns general states for the dashboard. +func handleGetDashboardStats(c echo.Context) error { + var ( + app = c.Get("app").(*App) + out dashboardStats + ) + + if err := app.Queries.GetDashboardStats.Get(&out); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err))) + } + + return c.JSON(http.StatusOK, okResp{out.Stats}) +} diff --git a/frontend/my/package.json b/frontend/my/package.json index d51b916..7c90fe1 100644 --- a/frontend/my/package.json +++ b/frontend/my/package.json @@ -5,6 +5,7 @@ "dependencies": { "antd": "^3.6.5", "axios": "^0.18.0", + "bizcharts": "^3.2.5-beta.4", "dayjs": "^1.7.5", "react": "^16.4.1", "react-app-rewire-less": "^2.1.3", diff --git a/frontend/my/src/Dashboard.js b/frontend/my/src/Dashboard.js index 63aa962..7a3da08 100644 --- a/frontend/my/src/Dashboard.js +++ b/frontend/my/src/Dashboard.js @@ -1,14 +1,123 @@ -import React from 'react'; +import { Col, Row, notification, Card, Tooltip, Icon } from "antd" +import React from "react"; +import { Chart, Axis, Geom, Tooltip as BizTooltip } from 'bizcharts'; + +import * as cs from "./constants" class Dashboard extends React.PureComponent { - componentDidMount = () => { - this.props.pageTitle("Dashboard") - } - render() { - return ( -

Welcome

- ); - } + state = { + stats: null + } + + campaignTypes = ["running", "finished", "paused", "draft", "scheduled", "cancelled"] + + componentDidMount = () => { + this.props.pageTitle("Dashboard") + + this.props.request(cs.Routes.GetDashboarcStats, cs.MethodGet).then((resp) => { + this.setState({ stats: resp.data.data }) + }).catch(e => { + notification["error"]({ message: "Error", description: e.message }) + }) + } + + orZero(v) { + return v ? v : 0 + } + + render() { + return ( +
+

Welcome

+
+ { this.state.stats && +
+ + + + + +

{ this.orZero(this.state.stats.subscribers.enabled) }

+
+ + + +

{ this.orZero(this.state.stats.subscribers.blacklisted) }

+
+ + + +

{ this.orZero(this.state.stats.orphan_subscribers) }

+
+ +
+ + + + + +

{ this.orZero(this.state.stats.lists.public) }

+
+ + + +

{ this.orZero(this.state.stats.lists.private) }

+
+ +
+ +
+
+ + + + + +

+ { this.state.stats.campaign_views.reduce((total, v) => total + v.count, 0) } + {' '} + views +

+ + + + + +
+ + + +

+ { this.state.stats.link_clicks.reduce((total, v) => total + v.count, 0) } + {' '} + clicks +

+ + + + + +
+ +
+ + + + + { this.campaignTypes.map((key, count) => + +

{ key }

+

{ count }

+
+ )} +
+ +
+
+ } +
+ ); + } } export default Dashboard; diff --git a/frontend/my/src/constants.js b/frontend/my/src/constants.js index 11a42a6..1430593 100644 --- a/frontend/my/src/constants.js +++ b/frontend/my/src/constants.js @@ -52,6 +52,7 @@ export const SubscriptionStatusUnsubscribed = "unsubscribed" // API routes. export const Routes = { + GetDashboarcStats: "/api/dashboard/stats", GetUsers: "/api/users", GetLists: "/api/lists", diff --git a/frontend/my/src/index.css b/frontend/my/src/index.css index 91bfd67..d36f6fc 100644 --- a/frontend/my/src/index.css +++ b/frontend/my/src/index.css @@ -48,11 +48,14 @@ body { } .content-body { - background: #fff; - padding: 24px; min-height: 90vh; } +section.content { + padding: 24px; + background: #fff; +} + .logo { padding: 30px; } @@ -69,10 +72,19 @@ body { width: 20px; } +.ant-card-head-title { + font-size: .85em !important; + color: #999 !important; +} + .broken { margin: 100px; } +.hidden { + display: none; +} + /* Form */ @@ -90,6 +102,14 @@ td .ant-tag { margin-top: 5px; } +/* Dashboard */ +.dashboard { + margin: 24px; +} + .dashboard .campaign-counts .name { + text-transform: capitalize; + } + /* Templates */ .wysiwyg { padding: 30px; diff --git a/main.go b/main.go index 4071a89..21e5f23 100644 --- a/main.go +++ b/main.go @@ -80,6 +80,7 @@ func init() { func registerHandlers(e *echo.Echo) { e.GET("/", handleIndexPage) e.GET("/api/config.js", handleGetConfigScript) + e.GET("/api/dashboard/stats", handleGetDashboardStats) e.GET("/api/users", handleGetUsers) e.POST("/api/users", handleCreateUser) e.DELETE("/api/users/:id", handleDeleteUser) diff --git a/queries.go b/queries.go index e60f157..e83be4d 100644 --- a/queries.go +++ b/queries.go @@ -8,6 +8,8 @@ import ( // Queries contains all prepared SQL queries. type Queries struct { + GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"` + InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"` UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"` BlacklistSubscriber *sqlx.Stmt `query:"blacklist-subscriber"` diff --git a/queries.sql b/queries.sql index 2dd704f..c6335ca 100644 --- a/queries.sql +++ b/queries.sql @@ -416,22 +416,37 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id) RETURNING (SELECT url FROM link); --- -- name: get-stats --- WITH lists AS ( --- SELECT type, COUNT(id) AS num FROM lists GROUP BY type --- ), --- subs AS ( --- SELECT status, COUNT(id) AS num FROM subscribers GROUP by status --- ), --- orphans AS ( --- SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id) --- WHERE subscriber_lists.subscriber_id IS NULL --- ), --- camps AS ( --- SELECT status, COUNT(id) AS num FROM campaigns GROUP by status --- ) --- SELECT JSON_BUILD_OBJECT('lists', lists); --- row_to_json(t) --- from ( --- select type, num from lists --- ) t, \ No newline at end of file +-- name: get-dashboard-stats +WITH lists AS ( + SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row +), +subs AS ( + SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row +), +orphans AS ( + SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id) + WHERE subscriber_lists.subscriber_id IS NULL +), +camps AS ( + SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row +), +clicks AS ( + -- Clicks by day for the last 3 months + SELECT JSON_AGG(ROW_TO_JSON(row)) + FROM (SELECT COUNT(*) AS count, created_at::DATE as date + FROM link_clicks GROUP by date ORDER BY date DESC LIMIT 100 + ) row +), +views AS ( + -- Views by day for the last 3 months + SELECT JSON_AGG(ROW_TO_JSON(row)) + FROM (SELECT COUNT(*) AS count, created_at::DATE as date + FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100 + ) row +) +SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'), + 'subscribers', COALESCE((SELECT * FROM subs), '[]'), + 'orphan_subscribers', (SELECT * FROM orphans), + 'campaigns', COALESCE((SELECT * FROM camps), '[]'), + 'link_clicks', COALESCE((SELECT * FROM clicks), '[]'), + 'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;