diff --git a/api/v1/analytics.go b/api/v1/analytics.go new file mode 100644 index 0000000..20a8dc7 --- /dev/null +++ b/api/v1/analytics.go @@ -0,0 +1,128 @@ +package v1 + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/boojack/shortify/store" + "github.com/labstack/echo/v4" + "github.com/mssola/useragent" + "golang.org/x/exp/slices" +) + +type ReferenceInfo struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type DeviceInfo struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type BrowserInfo struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type AnalysisData struct { + ReferenceData []ReferenceInfo `json:"referenceData"` + DeviceData []DeviceInfo `json:"deviceData"` + BrowserData []BrowserInfo `json:"browserData"` +} + +func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) { + g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error { + ctx := c.Request().Context() + shortcutID, err := strconv.Atoi(c.Param("shortcutId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err) + } + activities, err := s.Store.ListActivities(ctx, &store.FindActivity{ + Type: store.ActivityShortcutView, + Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)}, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err) + } + + referenceMap := make(map[string]int) + deviceMap := make(map[string]int) + browserMap := make(map[string]int) + for _, activity := range activities { + payload := &ActivityShorcutViewPayload{} + if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err) + } + + if _, ok := referenceMap[payload.Referer]; !ok { + referenceMap[payload.Referer] = 0 + } + referenceMap[payload.Referer]++ + + ua := useragent.New(payload.UserAgent) + deviceName := ua.OSInfo().Name + browserName, _ := ua.Browser() + + if _, ok := deviceMap[deviceName]; !ok { + deviceMap[deviceName] = 0 + } + deviceMap[deviceName]++ + + if _, ok := browserMap[browserName]; !ok { + browserMap[browserName] = 0 + } + browserMap[browserName]++ + } + + return c.JSON(http.StatusOK, &AnalysisData{ + ReferenceData: mapToReferenceInfoSlice(referenceMap), + DeviceData: mapToDeviceInfoSlice(deviceMap), + BrowserData: mapToBrowserInfoSlice(browserMap), + }) + }) +} + +func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo { + referenceInfoSlice := make([]ReferenceInfo, 0) + for key, value := range m { + referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{ + Name: key, + Count: value, + }) + } + slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool { + return i.Count > j.Count + }) + return referenceInfoSlice +} + +func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo { + deviceInfoSlice := make([]DeviceInfo, 0) + for key, value := range m { + deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{ + Name: key, + Count: value, + }) + } + slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool { + return i.Count > j.Count + }) + return deviceInfoSlice +} + +func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo { + browserInfoSlice := make([]BrowserInfo, 0) + for key, value := range m { + browserInfoSlice = append(browserInfoSlice, BrowserInfo{ + Name: key, + Count: value, + }) + } + slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool { + return i.Count > j.Count + }) + return browserInfoSlice +} diff --git a/api/v1/v1.go b/api/v1/v1.go index b1d42b6..07b0783 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -29,6 +29,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) { s.registerAuthRoutes(apiV1Group, secret) s.registerUserRoutes(apiV1Group) s.registerShortcutRoutes(apiV1Group) + s.registerAnalyticsRoutes(apiV1Group) redirectorGroup := apiGroup.Group("/s") redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/go.mod b/go.mod index 1f1c090..849eb28 100644 --- a/go.mod +++ b/go.mod @@ -66,8 +66,10 @@ require ( require ( github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/mssola/useragent v1.0.0 github.com/pkg/errors v0.9.1 go.deanishe.net/favicon v0.1.0 - golang.org/x/mod v0.8.0 + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df + golang.org/x/mod v0.11.0 modernc.org/sqlite v1.23.1 ) diff --git a/go.sum b/go.sum index 89c08f4..974988d 100644 --- a/go.sum +++ b/go.sum @@ -217,6 +217,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o= +github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= @@ -316,6 +318,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -339,8 +343,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/server/server.go b/server/server.go index a34bc77..f940bda 100644 --- a/server/server.go +++ b/server/server.go @@ -61,10 +61,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store } } - apiGroup := e.Group("") + rootGroup := e.Group("") // Register API v1 routes. apiV1Service := apiv1.NewAPIV1Service(profile, store) - apiV1Service.Start(apiGroup, secret) + apiV1Service.Start(rootGroup, secret) return s, nil } diff --git a/web/src/components/AnalyticsDialog.tsx b/web/src/components/AnalyticsDialog.tsx new file mode 100644 index 0000000..98364f3 --- /dev/null +++ b/web/src/components/AnalyticsDialog.tsx @@ -0,0 +1,135 @@ +import { Button, Modal, ModalDialog } from "@mui/joy"; +import { useEffect, useState } from "react"; +import * as api from "../helpers/api"; +import Icon from "./Icon"; + +interface Props { + shortcutId: ShortcutId; + onClose: () => void; +} + +const AnalyticsDialog: React.FC = (props: Props) => { + const { shortcutId, onClose } = props; + const [analytics, setAnalytics] = useState(null); + const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os"); + + useEffect(() => { + api.getShortcutAnalytics(shortcutId).then(({ data }) => { + setAnalytics(data); + }); + }, []); + + return ( + + +
+ Analytics + +
+
+ {analytics ? ( + <> +

Top Sources

+
+ + + + + + + + + {analytics.referenceData.map((reference) => ( + + + + + ))} + +
+ Source + + Visitors +
+ {reference.name ? ( + + {reference.name} + + ) : ( + "Direct" + )} + {reference.count}
+
+ +
+ Devices +
+ + / + +
+
+ +
+ {selectedDeviceTab === "os" ? ( + + + + + + + + + {analytics.deviceData.map((reference) => ( + + + + + ))} + +
+ Devices + + Visitors +
{reference.name || "Unknown"}{reference.count}
+ ) : ( + + + + + + + + + {analytics.browserData.map((reference) => ( + + + + + ))} + +
+ Browsers + + Visitors +
{reference.name || "Unknown"}{reference.count}
+ )} +
+ + ) : null} +
+
+
+ ); +}; + +export default AnalyticsDialog; diff --git a/web/src/components/ShortcutView.tsx b/web/src/components/ShortcutView.tsx index e8fb11d..910218c 100644 --- a/web/src/components/ShortcutView.tsx +++ b/web/src/components/ShortcutView.tsx @@ -13,6 +13,7 @@ import Icon from "./Icon"; import Dropdown from "./common/Dropdown"; import VisibilityIcon from "./VisibilityIcon"; import GenerateQRCodeDialog from "./GenerateQRCodeDialog"; +import AnalyticsDialog from "./AnalyticsDialog"; interface Props { shortcut: Shortcut; @@ -27,6 +28,7 @@ const ShortcutView = (props: Props) => { const faviconStore = useFaviconStore(); const [favicon, setFavicon] = useState(undefined); const [showQRCodeDialog, setShowQRCodeDialog] = useState(false); + const [showAnalyticsDialog, setShowAnalyticsDialog] = useState(false); const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id; const shortifyLink = absolutifyLink(`/s/${shortcut.name}`); @@ -46,7 +48,7 @@ const ShortcutView = (props: Props) => { const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { showCommonDialog({ title: "Delete Shortcut", - content: `Are you sure to delete shortcut \`${shortcut.name}\`? You can not undo this action.`, + content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`, style: "danger", onConfirm: async () => { await shortcutService.deleteShortcutById(shortcut.id); @@ -147,7 +149,10 @@ const ShortcutView = (props: Props) => { -
+
setShowAnalyticsDialog(true)} + > {shortcut.view} visits
@@ -156,6 +161,8 @@ const ShortcutView = (props: Props) => {
{showQRCodeDialog && setShowQRCodeDialog(false)} />} + + {showAnalyticsDialog && setShowAnalyticsDialog(false)} />} ); }; diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 1885229..11ed9d7 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -59,6 +59,10 @@ export function createShortcut(shortcutCreate: ShortcutCreate) { return axios.post("/api/v1/shortcut", shortcutCreate); } +export function getShortcutAnalytics(shortcutId: ShortcutId) { + return axios.get(`/api/v1/shortcut/${shortcutId}/analytics`); +} + export function patchShortcut(shortcutPatch: ShortcutPatch) { return axios.patch(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch); } diff --git a/web/src/types/analytics.d.ts b/web/src/types/analytics.d.ts new file mode 100644 index 0000000..3b1b0f7 --- /dev/null +++ b/web/src/types/analytics.d.ts @@ -0,0 +1,20 @@ +interface ReferenceInfo { + name: string; + count: number; +} + +interface DeviceInfo { + name: string; + count: number; +} + +interface BrowserInfo { + name: string; + count: number; +} + +interface AnalysisData { + referenceData: ReferenceInfo[]; + deviceData: DeviceInfo[]; + browserData: BrowserInfo[]; +}