1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Display board statistics (#4025)

* initial commit for displaying board statistics

* lint fixes

* i18n-extract, remove log entries, cleanup

* more lint fixes

* add check for standalone mode

* update tests due to change to NotImplemented

* lint fix

* revert removing empty comment lines

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
Scott Bishel 2022-10-25 14:28:00 -06:00 committed by GitHub
parent e9d4aeba0e
commit e3ae682eea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 313 additions and 4 deletions

View File

@ -27,7 +27,7 @@ func normalizeAppErr(appErr *mm_model.AppError) error {
// serviceAPIAdapter is an adapter that flattens the APIs provided by suite services so they can
// be used as per the Plugin API.
// Note: when supporting a plugin build is no longer needed this adapter may be removed as the Boards app
// can be modified to use the services in modular fashion.
// can be modified to use the services in modular fashion.
type serviceAPIAdapter struct {
api *boardsProduct
ctx *request.Context
@ -123,6 +123,10 @@ func (a *serviceAPIAdapter) CreateMember(teamID string, userID string) (*mm_mode
// Permissions service.
//
func (a *serviceAPIAdapter) HasPermissionTo(userID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionTo(userID, permission)
}
func (a *serviceAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionToTeam(userID, teamID, permission)
}
@ -134,6 +138,7 @@ func (a *serviceAPIAdapter) HasPermissionToChannel(askingUserID string, channelI
//
// Bot service.
//
func (a *serviceAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
return a.api.botService.EnsureBot(a.ctx, boardsProductID, bot)
}
@ -141,6 +146,7 @@ func (a *serviceAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
//
// License service.
//
func (a *serviceAPIAdapter) GetLicense() *mm_model.License {
return a.api.licenseService.GetLicense()
}
@ -148,6 +154,7 @@ func (a *serviceAPIAdapter) GetLicense() *mm_model.License {
//
// FileInfoStore service.
//
func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
fi, appErr := a.api.fileInfoStoreService.GetFileInfo(fileID)
return fi, normalizeAppErr(appErr)
@ -156,6 +163,7 @@ func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, erro
//
// Cluster store.
//
func (a *serviceAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
a.api.clusterService.PublishWebSocketEvent(boardsProductID, event, payload, broadcast)
}
@ -167,6 +175,7 @@ func (a *serviceAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterE
//
// Cloud service.
//
func (a *serviceAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
return a.api.cloudService.GetCloudLimits()
}
@ -174,6 +183,7 @@ func (a *serviceAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
//
// Config service.
//
func (a *serviceAPIAdapter) GetConfig() *mm_model.Config {
return a.api.configService.Config()
}
@ -181,6 +191,7 @@ func (a *serviceAPIAdapter) GetConfig() *mm_model.Config {
//
// Logger service.
//
func (a *serviceAPIAdapter) GetLogger() mlog.LoggerIFace {
return a.api.logger
}
@ -188,6 +199,7 @@ func (a *serviceAPIAdapter) GetLogger() mlog.LoggerIFace {
//
// KVStore service.
//
func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(boardsProductID, key, value, options)
return b, normalizeAppErr(appErr)
@ -196,6 +208,7 @@ func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options m
//
// Store service.
//
func (a *serviceAPIAdapter) GetMasterDB() (*sql.DB, error) {
return a.api.storeService.GetMasterDB(), nil
}
@ -203,6 +216,7 @@ func (a *serviceAPIAdapter) GetMasterDB() (*sql.DB, error) {
//
// System service.
//
func (a *serviceAPIAdapter) GetDiagnosticID() string {
return a.api.systemService.GetDiagnosticId()
}
@ -210,6 +224,7 @@ func (a *serviceAPIAdapter) GetDiagnosticID() string {
//
// Router service.
//
func (a *serviceAPIAdapter) RegisterRouter(sub *mux.Router) {
a.api.routerService.RegisterRouter(boardsProductName, sub)
}
@ -217,6 +232,7 @@ func (a *serviceAPIAdapter) RegisterRouter(sub *mux.Router) {
//
// Preferences service.
//
func (a *serviceAPIAdapter) GetPreferencesForUser(userID string) (mm_model.Preferences, error) {
p, appErr := a.api.preferencesService.GetPreferencesForUser(userID)
return p, normalizeAppErr(appErr)

View File

@ -125,6 +125,10 @@ func (a *pluginAPIAdapter) CreateMember(teamID string, userID string) (*mm_model
// Permissions service.
//
func (a *pluginAPIAdapter) HasPermissionTo(userID string, permission *mm_model.Permission) bool {
return a.api.HasPermissionTo(userID, permission)
}
func (a *pluginAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
return a.api.HasPermissionToTeam(userID, teamID, permission)
}
@ -136,6 +140,7 @@ func (a *pluginAPIAdapter) HasPermissionToChannel(askingUserID string, channelID
//
// Bot service.
//
func (a *pluginAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
return a.api.EnsureBotUser(bot)
}
@ -143,6 +148,7 @@ func (a *pluginAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
//
// License service.
//
func (a *pluginAPIAdapter) GetLicense() *mm_model.License {
return a.api.GetLicense()
}
@ -150,6 +156,7 @@ func (a *pluginAPIAdapter) GetLicense() *mm_model.License {
//
// FileInfoStore service.
//
func (a *pluginAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
fi, appErr := a.api.GetFileInfo(fileID)
return fi, normalizeAppErr(appErr)
@ -158,6 +165,7 @@ func (a *pluginAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error
//
// Cluster store.
//
func (a *pluginAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
a.api.PublishWebSocketEvent(event, payload, broadcast)
}
@ -169,6 +177,7 @@ func (a *pluginAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEv
//
// Cloud service.
//
func (a *pluginAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
return a.api.GetCloudLimits()
}
@ -176,6 +185,7 @@ func (a *pluginAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
//
// Config service.
//
func (a *pluginAPIAdapter) GetConfig() *mm_model.Config {
return a.api.GetUnsanitizedConfig()
}
@ -183,6 +193,7 @@ func (a *pluginAPIAdapter) GetConfig() *mm_model.Config {
//
// Logger service.
//
func (a *pluginAPIAdapter) GetLogger() mlog.LoggerIFace {
return a.logger
}
@ -190,6 +201,7 @@ func (a *pluginAPIAdapter) GetLogger() mlog.LoggerIFace {
//
// KVStore service.
//
func (a *pluginAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
b, appErr := a.api.KVSetWithOptions(key, value, options)
return b, normalizeAppErr(appErr)
@ -198,6 +210,7 @@ func (a *pluginAPIAdapter) KVSetWithOptions(key string, value []byte, options mm
//
// Store service.
//
func (a *pluginAPIAdapter) GetMasterDB() (*sql.DB, error) {
return a.storeService.GetMasterDB()
}
@ -205,6 +218,7 @@ func (a *pluginAPIAdapter) GetMasterDB() (*sql.DB, error) {
//
// System service.
//
func (a *pluginAPIAdapter) GetDiagnosticID() string {
return a.api.GetDiagnosticId()
}
@ -212,6 +226,7 @@ func (a *pluginAPIAdapter) GetDiagnosticID() string {
//
// Router service.
//
func (a *pluginAPIAdapter) RegisterRouter(sub *mux.Router) {
// NOOP for plugin
}
@ -219,6 +234,7 @@ func (a *pluginAPIAdapter) RegisterRouter(sub *mux.Router) {
//
// Preferences service.
//
func (a *pluginAPIAdapter) GetPreferencesForUser(userID string) (mm_model.Preferences, error) {
preferences, appErr := a.api.GetPreferencesForUser(userID)
if appErr != nil {

View File

@ -352,6 +352,30 @@ export default class Plugin {
return data
})
}
// Site statistics handler
if (registry.registerSiteStatisticsHandler) {
registry.registerSiteStatisticsHandler(async () => {
const siteStats = await octoClient.getSiteStatistics()
if(siteStats){
return {
boards_count: {
name: intl.formatMessage({id: 'SiteStats.total_boards', defaultMessage: 'Total Boards'}),
id: 'total_boards',
icon: 'icon-product-boards',
value: siteStats.board_count,
},
cards_count: {
name: intl.formatMessage({id: 'SiteStats.total_cards', defaultMessage: 'Total Cards'}),
id: 'total_cards',
icon: 'icon-products',
value: siteStats.card_count,
},
}
}
return {}
})
}
}
this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => (

View File

@ -18,6 +18,7 @@ export interface PluginRegistry {
registerRightHandSidebarComponent(component: React.ElementType, title: React.Element)
registerRootComponent(component: React.ElementType)
registerInsightsHandler(handler: (timeRange: string, page: number, perPage: number, teamId: string, insightType: string) => void)
registerSiteStatisticsHandler(handler: () => void)
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}

View File

@ -95,6 +95,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
a.registerTemplatesRoutes(apiv2)
a.registerBoardsRoutes(apiv2)
a.registerBlocksRoutes(apiv2)
a.registerStatisticsRoutes(apiv2)
// V3 routes
a.registerCardsRoutes(apiv2)

70
server/api/statistics.go Normal file
View File

@ -0,0 +1,70 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/mattermost/focalboard/server/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
)
func (a *API) registerStatisticsRoutes(r *mux.Router) {
// statistics
r.HandleFunc("/statistics", a.sessionRequired(a.handleStatistics)).Methods("GET")
}
func (a *API) handleStatistics(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /statistics handleStatistics
//
// Fetches the statistic of the server.
//
// ---
// produces:
// - application/json
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/BoardStatistics"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
if !a.MattermostAuth {
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
return
}
// user must have right to access analytics
userID := getUserID(r)
if !a.permissions.HasPermissionTo(userID, mmModel.PermissionGetAnalytics) {
a.errorResponse(w, r, model.NewErrPermission("access denied System Statistics"))
return
}
boardCount, err := a.app.GetBoardCount()
if err != nil {
a.errorResponse(w, r, err)
return
}
cardCount, err := a.app.GetUsedCardsCount()
if err != nil {
a.errorResponse(w, r, err)
return
}
stats := model.BoardsStatistics{
Boards: int(boardCount),
Cards: cardCount,
}
data, err := json.Marshal(stats)
if err != nil {
a.errorResponse(w, r, err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
}

View File

@ -55,6 +55,10 @@ func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
return boardsCloudLimits, nil
}
func (a *App) GetUsedCardsCount() (int, error) {
return a.store.GetUsedCardsCount()
}
// IsCloud returns true if the server is running as a plugin in a
// cloud licensed server.
func (a *App) IsCloud() bool {

View File

@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"strings"
@ -56,7 +55,7 @@ func BuildErrorResponse(r *http.Response, err error) *Response {
func closeBody(r *http.Response) {
if r.Body != nil {
_, _ = io.Copy(ioutil.Discard, r.Body)
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
}
@ -903,3 +902,19 @@ func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) {
return limits, BuildResponse(r)
}
func (c *Client) GetStatistics() (*model.BoardsStatistics, *Response) {
r, err := c.DoAPIGet("/statistics", "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
var stats *model.BoardsStatistics
err = json.NewDecoder(r.Body).Decode(&stats)
if err != nil {
return nil, BuildErrorResponse(r, err)
}
return stats, BuildResponse(r)
}

View File

@ -72,6 +72,10 @@ type TestHelper struct {
type FakePermissionPluginAPI struct{}
func (*FakePermissionPluginAPI) HasPermissionTo(userID string, permission *mmModel.Permission) bool {
return userID == userAdmin
}
func (*FakePermissionPluginAPI) HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool {
if userID == userNoTeamMember {
return false

View File

@ -3773,3 +3773,40 @@ func TestPermissionsChannel(t *testing.T) {
runTestCases(t, ttCases, testData, clients)
})
}
func TestPermissionsGetStatistics(t *testing.T) {
t.Run("plugin", func(t *testing.T) {
th := SetupTestHelperPluginMode(t)
defer th.TearDown()
clients := setupClients(th)
testData := setupData(t, th)
ttCases := []TestCase{
{"/statistics", methodGet, "", userAnon, http.StatusUnauthorized, 0},
{"/statistics", methodGet, "", userNoTeamMember, http.StatusForbidden, 0},
{"/statistics", methodGet, "", userTeamMember, http.StatusForbidden, 0},
{"/statistics", methodGet, "", userViewer, http.StatusForbidden, 0},
{"/statistics", methodGet, "", userCommenter, http.StatusForbidden, 0},
{"/statistics", methodGet, "", userEditor, http.StatusForbidden, 0},
{"/statistics", methodGet, "", userAdmin, http.StatusOK, 1},
{"/statistics", methodGet, "", userGuest, http.StatusForbidden, 0},
}
runTestCases(t, ttCases, testData, clients)
})
t.Run("local", func(t *testing.T) {
th := SetupTestHelperLocalMode(t)
defer th.TearDown()
clients := setupLocalClients(th)
testData := setupData(t, th)
ttCases := []TestCase{
{"/statistics", methodGet, "", userAnon, http.StatusUnauthorized, 0},
{"/statistics", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0},
{"/statistics", methodGet, "", userTeamMember, http.StatusNotImplemented, 0},
{"/statistics", methodGet, "", userViewer, http.StatusNotImplemented, 0},
{"/statistics", methodGet, "", userCommenter, http.StatusNotImplemented, 0},
{"/statistics", methodGet, "", userEditor, http.StatusNotImplemented, 0},
{"/statistics", methodGet, "", userAdmin, http.StatusNotImplemented, 1},
{"/statistics", methodGet, "", userGuest, http.StatusForbidden, 0},
}
runTestCases(t, ttCases, testData, clients)
})
}

View File

@ -0,0 +1,55 @@
package integrationtests
import (
"testing"
"github.com/mattermost/focalboard/server/client"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
)
func TestStatisticsLocalMode(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
t.Run("an unauthenticated client should not be able to get statistics", func(t *testing.T) {
th.Logout(th.Client)
stats, resp := th.Client.GetStatistics()
th.CheckUnauthorized(resp)
require.Nil(t, stats)
})
t.Run("Check authenticated user, not admin", func(t *testing.T) {
th.Login1()
stats, resp := th.Client.GetStatistics()
th.CheckNotImplemented(resp)
require.Nil(t, stats)
})
}
func TestStatisticsPluginMode(t *testing.T) {
th := SetupTestHelperPluginMode(t)
defer th.TearDown()
// Permissions are tested in permissions_test.go
// This tests the functionality.
t.Run("Check authenticated user, admin", func(t *testing.T) {
th.Client = client.NewClient(th.Server.Config().ServerRoot, "")
th.Client.HTTPHeader["Mattermost-User-Id"] = userAdmin
stats, resp := th.Client.GetStatistics()
th.CheckOK(resp)
require.NotNil(t, stats)
numberCards := 2
th.CreateBoardAndCards("testTeam", model.BoardTypeOpen, numberCards)
stats, resp = th.Client.GetStatistics()
th.CheckOK(resp)
require.NotNil(t, stats)
require.Equal(t, 1, stats.Boards)
require.Equal(t, numberCards, stats.Cards)
})
}

View File

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// BoardsStatistics is the representation of the statistics for the Boards server
// swagger:model
type BoardsStatistics struct {
// The maximum number of cards on the server
// required: true
Boards int `json:"board_count"`
// The maximum number of cards on the server
// required: true
Cards int `json:"card_count"`
}

View File

@ -347,6 +347,20 @@ func (mr *MockServicesAPIMockRecorder) GetUsersFromProfiles(arg0 interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersFromProfiles", reflect.TypeOf((*MockServicesAPI)(nil).GetUsersFromProfiles), arg0)
}
// HasPermissionTo mocks base method.
func (m *MockServicesAPI) HasPermissionTo(arg0 string, arg1 *model.Permission) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasPermissionTo", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// HasPermissionTo indicates an expected call of HasPermissionTo.
func (mr *MockServicesAPIMockRecorder) HasPermissionTo(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionTo", reflect.TypeOf((*MockServicesAPI)(nil).HasPermissionTo), arg0, arg1)
}
// HasPermissionToChannel mocks base method.
func (m *MockServicesAPI) HasPermissionToChannel(arg0, arg1 string, arg2 *model.Permission) bool {
m.ctrl.T.Helper()

View File

@ -49,6 +49,7 @@ type ServicesAPI interface {
CreateMember(teamID string, userID string) (*mm_model.TeamMember, error)
// Permissions service
HasPermissionTo(userID string, permission *mm_model.Permission) bool
HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool
HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool

View File

@ -23,6 +23,10 @@ func New(store permissions.Store, logger mlog.LoggerIFace) *Service {
}
}
func (s *Service) HasPermissionTo(userID string, permission *mmModel.Permission) bool {
return false
}
func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool {
if userID == "" || teamID == "" || permission == nil {
return false

View File

@ -12,6 +12,7 @@ import (
)
type APIInterface interface {
HasPermissionTo(userID string, permission *mmModel.Permission) bool
HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool
HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool
}
@ -30,6 +31,13 @@ func New(store permissions.Store, api APIInterface, logger mlog.LoggerIFace) *Se
}
}
func (s *Service) HasPermissionTo(userID string, permission *mmModel.Permission) bool {
if userID == "" || permission == nil {
return false
}
return s.api.HasPermissionTo(userID, permission)
}
func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool {
if userID == "" || teamID == "" || permission == nil {
return false

View File

@ -11,6 +11,7 @@ import (
)
type PermissionsService interface {
HasPermissionTo(userID string, permission *mmModel.Permission) bool
HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool
HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool
HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool

View File

@ -521,7 +521,8 @@ func (s *SQLStore) getBoardCount(db sq.BaseRunner) (int64, error) {
query := s.getQueryBuilder(db).
Select("COUNT(*) AS count").
From(s.tablePrefix + "boards").
Where(sq.Eq{"delete_at": 0})
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"is_template": false})
row := query.QueryRow()

View File

@ -257,6 +257,8 @@
"SidebarTour.SidebarCategories.Body": "All your boards are now organized under your new sidebar. No more switching between workspaces. One-time custom categories based on your prior workspaces may have automatically been created for you as part of your v7.2 upgrade. These can be removed or edited to your preference.",
"SidebarTour.SidebarCategories.Link": "Learn more",
"SidebarTour.SidebarCategories.Title": "Sidebar categories",
"SiteStats.total_boards": "Total Boards",
"SiteStats.total_cards": "Total Cards",
"TableComponent.add-icon": "Add icon",
"TableComponent.name": "Name",
"TableComponent.plus-new": "+ New",

View File

@ -17,6 +17,7 @@ import {Constants} from './constants'
import {BoardsCloudLimits} from './boardsCloudLimits'
import {TopBoardResponse} from './insights'
import {BoardSiteStatistics} from './statistics'
//
// OctoClient is the client interface to the server APIs
@ -921,6 +922,18 @@ class OctoClient {
return limits
}
async getSiteStatistics(): Promise<BoardSiteStatistics | undefined> {
const path = '/api/v2/statistics'
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
if (response.status !== 200) {
return undefined
}
const stats = (await this.getJson(response, {})) as BoardSiteStatistics
Utils.log(`Site Statistics: cards=${stats.card_count} boards=${stats.board_count}`)
return stats
}
// insights
async getMyTopBoards(timeRange: string, page: number, perPage: number, teamId: string): Promise<TopBoardResponse | undefined> {
const path = `/api/v2/users/me/boards/insights?time_range=${timeRange}&page=${page}&per_page=${perPage}&team_id=${teamId}`

View File

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface BoardSiteStatistics {
board_count: number
card_count: number
}