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"