From 1dca747b160af65ea76e5f15cbd469b4be9cb7ea Mon Sep 17 00:00:00 2001 From: Love98 <77888749+love98ooo@users.noreply.github.com> Date: Tue, 28 May 2024 19:36:38 +0800 Subject: [PATCH] feat: add dashboard page (#28) * feat: add dashboard and its list page * fix: signed in required * fix: rollback local config * fix: rollback local config * fix: package resolved in yarn * chore: define a JS file for each card, put them into a folder * chore: avoid global CSS * chore: replace npmmirror with yarnpkg * chore: sort imports and replace strconv.Atoi with util.ParseInt * chore: sort imports --- controllers/metric.go | 124 +++++++++++ object/record.go | 48 +++++ routers/router.go | 2 + web/package.json | 2 + web/src/App.js | 10 + web/src/DashboardPage.js | 310 ++++++++++++++++++++++++++++ web/src/backend/DashboardBackend.js | 29 +++ web/src/components/BarChartCard.js | 47 +++++ web/src/components/PieChartCard.js | 53 +++++ web/src/components/StatisticCard.js | 46 +++++ web/src/index.css | 4 + web/yarn.lock | 33 +++ 12 files changed, 708 insertions(+) create mode 100644 controllers/metric.go create mode 100644 web/src/DashboardPage.js create mode 100644 web/src/backend/DashboardBackend.js create mode 100644 web/src/components/BarChartCard.js create mode 100644 web/src/components/PieChartCard.js create mode 100644 web/src/components/StatisticCard.js diff --git a/controllers/metric.go b/controllers/metric.go new file mode 100644 index 0000000..ee56c86 --- /dev/null +++ b/controllers/metric.go @@ -0,0 +1,124 @@ +// Copyright 2024 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "errors" + "time" + + "github.com/casbin/caswaf/object" + "github.com/casbin/caswaf/util" +) + +func (c *ApiController) GetMetricsOverTime() { + if c.RequireSignedIn() { + return + } + rangeType := c.Input().Get("rangeType") + count := util.ParseInt(c.Input().Get("count")) + granularity := c.Input().Get("granularity") + timeType := granularity2TimeType(granularity) + startTime := time.Now().Add(time.Duration(-count) * rangeType2Duration(rangeType)) + metrics, err := object.GetMetricsOverTime(startTime, timeType) + if err != nil { + c.ResponseError(err.Error()) + return + } + var total int64 + for _, metric := range *metrics { + total += metric.Count + } + c.ResponseOk(metrics, total) +} + +func granularity2TimeType(rangeType string) string { + switch rangeType { + case "hour": + return "hour" + case "day": + return "day" + case "week": + return "day" + case "month": + return "month" + case "year": + return "month" + default: + return "month" + } +} + +func (c *ApiController) GetMetrics() { + if c.RequireSignedIn() { + return + } + + dtoType := c.Input().Get("type") + dataType, err := type2DataType(dtoType) + if err != nil { + c.ResponseError(err.Error()) + return + } + rangeType := c.Input().Get("rangeType") + count := util.ParseInt(c.Input().Get("count")) + top, err := util.ParseIntWithError(c.Input().Get("top")) + // if top is not set or invalid, set it to the maximum value + if err != nil || top <= 0 { + top = int(^uint(0) >> 1) + } + startTime := time.Now().Add(time.Duration(-count) * rangeType2Duration(rangeType)) + metrics, err := object.GetMetrics(dataType, startTime, top) + if err != nil { + c.ResponseError(err.Error()) + return + } + var total int64 + for _, metric := range *metrics { + total += metric.Count + } + c.ResponseOk(metrics, total) +} + +func rangeType2Duration(rangeType string) time.Duration { + switch rangeType { + case "hour": + return time.Hour + case "day": + return 24 * time.Hour + case "week": + return 7 * 24 * time.Hour + case "month": + return 30 * 24 * time.Hour + case "year": + return 365 * 24 * time.Hour + default: + return time.Hour + } +} + +func type2DataType(dataType string) (string, error) { + switch dataType { + case "site": + return "host", nil + case "path": + return "path", nil + case "ip": + return "client_ip", nil + case "userAgent": + return "user_agent", nil + default: + return "", errors.New("invalid data type") + } +} diff --git a/object/record.go b/object/record.go index a833ab4..6013b96 100644 --- a/object/record.go +++ b/object/record.go @@ -16,6 +16,7 @@ package object import ( "strconv" + "time" "github.com/xorm-io/core" ) @@ -95,3 +96,50 @@ func getRecord(owner string, id int64) (*Record, error) { } return nil, nil } + +type DataCount struct { + Data string `json:"data"` + Count int64 `json:"count"` +} + +func GetMetrics(dataType string, startAt time.Time, top int) (*[]DataCount, error) { + var dataCounts []DataCount + err := ormer.Engine.Table("record"). + Where("UNIX_TIMESTAMP(created_time) > ?", startAt.Unix()). + Select(dataType + " as data, COUNT(*) as count"). + GroupBy("data"). + Desc("count"). + Limit(top). + Find(&dataCounts) + if err != nil { + return nil, err + } + return &dataCounts, nil +} + +func GetMetricsOverTime(startAt time.Time, timeType string) (*[]DataCount, error) { + var dataCounts []DataCount + createdTime := "DATE_FORMAT(created_time, '" + timeType2Format(timeType) + "')" + err := ormer.Engine.Table("record"). + Where("UNIX_TIMESTAMP(created_time) > ?", startAt.Unix()). + GroupBy(createdTime). + Select(createdTime + " as data, COUNT(*) as count"). + Asc("data"). + Find(&dataCounts) + if err != nil { + return nil, err + } + return &dataCounts, nil +} + +func timeType2Format(timeType string) string { + switch timeType { + case "hour": + return "%Y-%m-%d %H" + case "day": + return "%Y-%m-%d" + case "month": + return "%Y-%m" + } + return "%Y-%m-%d %H" +} diff --git a/routers/router.go b/routers/router.go index 89de865..e5200f6 100644 --- a/routers/router.go +++ b/routers/router.go @@ -59,4 +59,6 @@ func initAPI() { beego.Router("/api/update-record", &controllers.ApiController{}, "POST:UpdateRecord") beego.Router("/api/add-record", &controllers.ApiController{}, "POST:AddRecord") + beego.Router("/api/get-metrics", &controllers.ApiController{}, "GET:GetMetrics") + beego.Router("/api/get-metrics-over-time", &controllers.ApiController{}, "GET:GetMetricsOverTime") } diff --git a/web/package.json b/web/package.json index 4b3cf0c..21b8854 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "casdoor-js-sdk": "^0.2.7", "copy-to-clipboard": "^3.3.3", "craco-less": "2.0.0", + "echarts": "^5.5.0", + "echarts-for-react": "^3.0.2", "eslint-plugin-unused-imports": "^2.0.0", "file-saver": "^2.0.5", "i18next": "^19.8.9", diff --git a/web/src/App.js b/web/src/App.js index 40d0b05..120db40 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -30,6 +30,8 @@ import SigninPage from "./SigninPage"; import RecordListPage from "./RecordListPage"; import RecordEditPage from "./RecordEditPage"; import i18next from "i18next"; +import DashboardPage from "./DashboardPage"; +// import SelectLanguageBox from "./SelectLanguageBox"; const {Header, Footer} = Layout; @@ -241,6 +243,13 @@ class App extends Component { ); + res.push( + + + {i18next.t("general:Dashboard")} + + + ); res.push( @@ -323,6 +332,7 @@ class App extends Component { this.renderSigninIfNotSignedIn()} /> this.renderSigninIfNotSignedIn()} /> + this.renderSigninIfNotSignedIn()} /> ); diff --git a/web/src/DashboardPage.js b/web/src/DashboardPage.js new file mode 100644 index 0000000..3f810c2 --- /dev/null +++ b/web/src/DashboardPage.js @@ -0,0 +1,310 @@ +// Copyright 2023 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from "react"; +import {Card, Col, Radio, Row, Table} from "antd"; +import * as Setting from "./Setting"; +import BarChartCard from "./components/BarChartCard"; +import StatisticCard from "./components/StatisticCard"; +import * as DashboardBackend from "./backend/DashboardBackend"; +import i18next from "i18next"; +import PieChartCard from "./components/PieChartCard"; + +class DashboardDetailPage extends React.Component { + constructor(props) { + super(props); + this.state = { + classes: props, + site: null, + userAgents: [{}], + sites: [{}], + uniqueIPCount: 0, + totleRequestCount: 0, + ipAddresses: [{}], + requestCountOverTime: [{}], + rangeType: "All", + }; + } + + UNSAFE_componentWillMount() { + this.getAllData(this.state.rangeType); + } + + async getMetric(type, rangeType, top) { + rangeType = rangeType === "All" ? "month" : rangeType.toLowerCase(); + const count = this.getRangeValue(rangeType); + if (type === "UserAgent" || type === "IPAddress") { + top = 10; + } + return DashboardBackend.getMetric(type, rangeType, count, top).then((res) => { + if (res.status === "ok") { + return res; + } else { + Setting.showMessage("error", res.msg); + } + }); + } + + async getMetricOverTime(rangeType) { + rangeType = rangeType === "All" ? "week" : rangeType.toLowerCase(); + const count = this.getRangeValue(rangeType); + const timeType = this.getGranularity(rangeType); + return DashboardBackend.getMetricOverTime(rangeType, count, timeType).then((res) => { + if (res.status === "ok") { + return res; + } else { + Setting.showMessage("error", res.msg); + } + }); + } + + getAllData(rangeType) { + this.getUserAgents(rangeType); + this.getIPAddresses(rangeType); + this.getSites(rangeType); + this.getRequestCount(rangeType); + } + + getRangeValue(rangeType) { + switch (rangeType) { + case "hour": + return 72; + case "day": + return 7; + case "week": + return 12; + case "month": + return 12; + default: + return 7; + } + } + + getGranularity(rangeType) { + switch (rangeType) { + case "hour": + return "hour"; + case "day": + return "hour"; + case "week": + return "day"; + case "month": + return "month"; + default: + return "day"; + } + } + + getUserAgents(rangeType) { + this.getMetric("userAgent", rangeType, 10).then(res => { + this.setState({ + userAgents: res.data, + }); + }); + } + + getIPAddresses(rangeType) { + this.getMetric("ip", rangeType).then((res) => { + this.setState({ + ipAddresses: res.data.slice(0, 10), + uniqueIPCount: res.data.length, + }); + }); + } + + getRequestCount(rangeType) { + this.getMetricOverTime(rangeType).then((res) => { + this.setState({ + requestCountOverTime: res.data, + totleRequestCount: res.data2, + }); + }); + } + + getSites(rangeType) { + this.getMetric("site", rangeType).then((res) => { + this.setState({ + sites: res.data, + }); + }); + } + + renderUserAgentsTable() { + const columns = [ + { + title: i18next.t("general:User-Agent"), + dataIndex: "data", + key: "data", + width: "440px", + }, + { + title: i18next.t("general:Count"), + dataIndex: "count", + key: "count", + width: "40px", + sorter: (a, b) => a.count - b.count, + }, + ]; + + return ( + + + + ); + + } + + renderIPAddressTable() { + const columns = [ + { + title: i18next.t("general:IP Address"), + dataIndex: "data", + key: "data", + width: "140px", + }, + { + title: i18next.t("general:Count"), + dataIndex: "count", + key: "count", + width: "20px", + sorter: (a, b) => a.count - b.count, + }, + ]; + + return ( + +
+ + ); + + } + + renderSitesPieChart() { + return this.renderPieChart("Sites", this.state.sites); + } + + renderPieChart(title, data) { + const d = data.map((item) => { + return {value: item.count, name: item.data}; + }); + return ( +
+ +
+ ); + } + + renderTotalRequestCountStatistic() { + return this.renderStatistic(i18next.t("general:Total Request Count"), this.state.totleRequestCount); + } + + renderUniqueIPCountStatistic() { + return this.renderStatistic(i18next.t("general:Unique IP Count"), this.state.uniqueIPCount); + } + + renderStatistic(title, value) { + return ( +
+ +
+ ); + } + + renderBarChart(title, data) { + return ( +
+ +
+ ); + } + + renderRadio() { + return ( +
+ { + const rangeType = e.target.value; + this.getAllData(rangeType); + this.setState({ + rangeType: rangeType, + }); + }}> + {i18next.t("usage:All")} + {i18next.t("usage:Hour")} + {i18next.t("usage:Day")} + {i18next.t("usage:Week")} + {i18next.t("usage:Month")} + +
+ ); + } + + render() { + return ( +
+ {this.renderRadio()} + +
+ { + this.renderTotalRequestCountStatistic() + } + + + { + this.renderBarChart("Request Count Over Time", this.state.requestCountOverTime) + } + + + + + {this.renderSitesPieChart()} + + {/* + {this.renderHTTPVersionPieChart()} + */} + + { + this.renderUniqueIPCountStatistic() + } + + + + + { + this.renderIPAddressTable() + } + + + { + this.renderUserAgentsTable() + } + + + + ); + } +} + +export default DashboardDetailPage; diff --git a/web/src/backend/DashboardBackend.js b/web/src/backend/DashboardBackend.js new file mode 100644 index 0000000..4b541d6 --- /dev/null +++ b/web/src/backend/DashboardBackend.js @@ -0,0 +1,29 @@ +// Copyright 2023 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as Setting from "../Setting"; + +export function getMetric(type, rangeType, count, top) { + return fetch(`${Setting.ServerUrl}/api/get-metrics?type=${type}&rangeType=${rangeType}&count=${count}&top=${top}`, { + method: "GET", + credentials: "include", + }).then(res => res.json()); +} + +export function getMetricOverTime(rangeType, count, granularity) { + return fetch(`${Setting.ServerUrl}/api/get-metrics-over-time?rangeType=${rangeType}&count=${count}&granularity=${granularity}`, { + method: "GET", + credentials: "include", + }).then(res => res.json()); +} diff --git a/web/src/components/BarChartCard.js b/web/src/components/BarChartCard.js new file mode 100644 index 0000000..b38e5cd --- /dev/null +++ b/web/src/components/BarChartCard.js @@ -0,0 +1,47 @@ +import React from "react"; +import {Card} from "antd"; +import ReactECharts from "echarts-for-react"; +import i18next from "i18next"; + +const BarChartCard = ({title, data}) => { + const option = { + tooltip: { + trigger: "axis", + axisPointer: { + type: "shadow", + }, + }, + grid: { + left: "3%", + right: "4%", + bottom: "3%", + containLabel: true, + }, + xAxis: { + type: "category", + data: data.map((item) => item.data), + axisTick: { + alignWithLabel: true, + }, + }, + yAxis: { + type: "value", + }, + series: [ + { + name: title, + type: "bar", + barWidth: "60%", + data: data.map((item) => item.count), + }, + ], + }; + + return ( + + + + ); +}; + +export default BarChartCard; diff --git a/web/src/components/PieChartCard.js b/web/src/components/PieChartCard.js new file mode 100644 index 0000000..d5b5de4 --- /dev/null +++ b/web/src/components/PieChartCard.js @@ -0,0 +1,53 @@ +import React from "react"; +import {Card} from "antd"; +import ReactECharts from "echarts-for-react"; +import i18next from "i18next"; + +const PieChartCard = ({title, data}) => { + const option = { + tooltip: { + trigger: "item", + }, + legend: { + top: "5%", + left: "right", + orient: "vertical", + }, + series: [ + { + name: title, + type: "pie", + radius: ["40%", "70%"], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: "#fff", + borderWidth: 2, + }, + label: { + show: false, + position: "center", + }, + emphasis: { + label: { + show: true, + fontSize: 30, + fontWeight: "bold", + }, + }, + labelLine: { + show: false, + }, + data: data, + }, + ], + }; + + return ( + + + + ); +}; + +export default PieChartCard; diff --git a/web/src/components/StatisticCard.js b/web/src/components/StatisticCard.js new file mode 100644 index 0000000..78e1837 --- /dev/null +++ b/web/src/components/StatisticCard.js @@ -0,0 +1,46 @@ +import React from "react"; +import {Card} from "antd"; +import ReactECharts from "echarts-for-react"; +import i18next from "i18next"; + +const StatisticCard = ({title, value}) => { + const option = { + series: [ + { + type: "scatter", + data: [[0, 0]], + symbolSize: 1, + label: { + show: true, + formatter: [value].join("\n"), + color: "#000", + fontSize: 64, + }, + }, + ], + xAxis: { + axisLabel: {show: false}, + axisLine: {show: false}, + splitLine: {show: false}, + axisTick: {show: false}, + min: -1, + max: 1, + }, + yAxis: { + axisLabel: {show: false}, + axisLine: {show: false}, + splitLine: {show: false}, + axisTick: {show: false}, + min: -1, + max: 1, + }, + }; + + return ( + + + + ); +}; + +export default StatisticCard; diff --git a/web/src/index.css b/web/src/index.css index ae77fda..70ae6ce 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -78,3 +78,7 @@ code { .conferenceMenu { background-color: rgb(242,242,242) !important; } + +.dashboard-card { + margin: 7px !important; +} diff --git a/web/yarn.lock b/web/yarn.lock index dd32923..d9a0f95 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4110,6 +4110,22 @@ duplexer@^0.1.2: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== +echarts-for-react@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/echarts-for-react/-/echarts-for-react-3.0.2.tgz#ac5859157048a1066d4553e34b328abb24f2b7c1" + integrity sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA== + dependencies: + fast-deep-equal "^3.1.3" + size-sensor "^1.0.1" + +echarts@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.5.0.tgz#c13945a7f3acdd67c134d8a9ac67e917830113ac" + integrity sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw== + dependencies: + tslib "2.3.0" + zrender "5.5.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -8944,6 +8960,11 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +size-sensor@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/size-sensor/-/size-sensor-1.0.2.tgz#b8f8da029683cf2b4e22f12bf8b8f0a1145e8471" + integrity sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -9528,6 +9549,11 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -10287,3 +10313,10 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zrender@5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e" + integrity sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w== + dependencies: + tslib "2.3.0"