Console buckets page (#1847)

This commit is contained in:
Yaroslav Vorobiov 2019-05-16 13:43:46 +03:00 committed by GitHub
parent ae24778ef7
commit 2d2301d5ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 902 additions and 100 deletions

View File

@ -18,8 +18,17 @@ const (
ProjectInputType = "projectInput"
// ProjectUsageType is a graphql type name for project usage
ProjectUsageType = "projectUsage"
// BucketUsageCursorInputType is a graphql input
// type name for bucket usage cursor
BucketUsageCursorInputType = "bucketUsageCursor"
// BucketUsageType is a graphql type name for bucket usage
BucketUsageType = "bucketUsage"
// BucketUsagePageType is a field name for bucket usage page
BucketUsagePageType = "bucketUsagePage"
// FieldName is a field name for "name"
FieldName = "name"
// FieldBucketName is a field name for "bucket name"
FieldBucketName = "bucketName"
// FieldDescription is a field name for description
FieldDescription = "description"
// FieldMembers is field name for members
@ -28,12 +37,24 @@ const (
FieldAPIKeys = "apiKeys"
// FieldUsage is a field name for usage rollup
FieldUsage = "usage"
// FieldBucketUsages is a field name for bucket usages
FieldBucketUsages = "bucketUsages"
// FieldStorage is a field name for storage total
FieldStorage = "storage"
// FieldEgress is a field name for egress total
FieldEgress = "egress"
// FieldObjectCount is a field name for objects count
FieldObjectCount = "objectCount"
// FieldPageCount is a field name for total page count
FieldPageCount = "pageCount"
// FieldCurrentPage is a field name for current page number
FieldCurrentPage = "currentPage"
// FieldTotalCount is a field name for bucket usage count total
FieldTotalCount = "totalCount"
// CursorArg is an argument name for cursor
CursorArg = "cursor"
// PageArg ia an argument name for page number
PageArg = "page"
// LimitArg is argument name for limit
LimitArg = "limit"
// OffsetArg is argument name for offset
@ -149,6 +170,25 @@ func graphqlProject(service *console.Service, types *TypeCreator) *graphql.Objec
return service.GetProjectUsage(p.Context, project.ID, since, before)
},
},
FieldBucketUsages: &graphql.Field{
Type: types.bucketUsagePage,
Args: graphql.FieldConfigArgument{
BeforeArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.DateTime),
},
CursorArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.bucketUsageCursor),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
project, _ := p.Source.(*console.Project)
before := p.Args[BeforeArg].(time.Time)
cursor := fromMapBucketUsageCursor(p.Args[CursorArg].(map[string]interface{}))
return service.GetBucketTotals(p.Context, project.ID, cursor, before)
},
},
},
})
}
@ -168,6 +208,81 @@ func graphqlProjectInput() *graphql.InputObject {
})
}
// graphqlBucketUsageCursor creates bucket usage cursor graphql input type
func graphqlBucketUsageCursor() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: BucketUsageCursorInputType,
Fields: graphql.InputObjectConfigFieldMap{
SearchArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.String),
},
LimitArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
PageArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
},
})
}
// graphqlBucketUsage creates bucket usage grapqhl type
func graphqlBucketUsage() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: BucketUsageType,
Fields: graphql.Fields{
FieldBucketName: &graphql.Field{
Type: graphql.String,
},
FieldStorage: &graphql.Field{
Type: graphql.Float,
},
FieldEgress: &graphql.Field{
Type: graphql.Float,
},
FieldObjectCount: &graphql.Field{
Type: graphql.Float,
},
SinceArg: &graphql.Field{
Type: graphql.DateTime,
},
BeforeArg: &graphql.Field{
Type: graphql.DateTime,
},
},
})
}
// graphqlBucketUsagePage creates bucket usage page graphql object
func graphqlBucketUsagePage(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: BucketUsagePageType,
Fields: graphql.Fields{
FieldBucketUsages: &graphql.Field{
Type: graphql.NewList(types.bucketUsage),
},
SearchArg: &graphql.Field{
Type: graphql.String,
},
LimitArg: &graphql.Field{
Type: graphql.Int,
},
OffsetArg: &graphql.Field{
Type: graphql.Int,
},
FieldPageCount: &graphql.Field{
Type: graphql.Int,
},
FieldCurrentPage: &graphql.Field{
Type: graphql.Int,
},
FieldTotalCount: &graphql.Field{
Type: graphql.Int,
},
},
})
}
// graphqlProjectUsage creates project usage graphql type
func graphqlProjectUsage() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
@ -192,10 +307,21 @@ func graphqlProjectUsage() *graphql.Object {
})
}
// fromMapProjectInfo creates satellite.ProjectInfo from input args
// fromMapProjectInfo creates console.ProjectInfo from input args
func fromMapProjectInfo(args map[string]interface{}) (project console.ProjectInfo) {
project.Name, _ = args[FieldName].(string)
project.Description, _ = args[FieldDescription].(string)
return
}
// fromMapBucketUsageCursor creates console.BucketUsageCursor from input args
func fromMapBucketUsageCursor(args map[string]interface{}) (cursor console.BucketUsageCursor) {
limit, _ := args[LimitArg].(int)
page, _ := args[PageArg].(int)
cursor.Limit = uint(limit)
cursor.Page = uint(page)
cursor.Search, _ = args[SearchArg].(string)
return
}

View File

@ -18,15 +18,18 @@ type TypeCreator struct {
token *graphql.Object
user *graphql.Object
project *graphql.Object
projectUsage *graphql.Object
projectMember *graphql.Object
apiKeyInfo *graphql.Object
createAPIKey *graphql.Object
user *graphql.Object
project *graphql.Object
projectUsage *graphql.Object
bucketUsage *graphql.Object
bucketUsagePage *graphql.Object
projectMember *graphql.Object
apiKeyInfo *graphql.Object
createAPIKey *graphql.Object
userInput *graphql.InputObject
projectInput *graphql.InputObject
userInput *graphql.InputObject
projectInput *graphql.InputObject
bucketUsageCursor *graphql.InputObject
}
// Create create types and check for error
@ -42,6 +45,11 @@ func (c *TypeCreator) Create(log *zap.Logger, service *console.Service, mailServ
return err
}
c.bucketUsageCursor = graphqlBucketUsageCursor()
if err := c.bucketUsageCursor.Error(); err != nil {
return err
}
// entities
c.user = graphqlUser()
if err := c.user.Error(); err != nil {
@ -53,6 +61,16 @@ func (c *TypeCreator) Create(log *zap.Logger, service *console.Service, mailServ
return err
}
c.bucketUsage = graphqlBucketUsage()
if err := c.bucketUsage.Error(); err != nil {
return err
}
c.bucketUsagePage = graphqlBucketUsagePage(c)
if err := c.bucketUsagePage.Error(); err != nil {
return err
}
c.apiKeyInfo = graphqlAPIKeyInfo()
if err := c.apiKeyInfo.Error(); err != nil {
return err

View File

@ -12,7 +12,7 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
monkit "gopkg.in/spacemonkeygo/monkit.v2"
"gopkg.in/spacemonkeygo/monkit.v2"
"storj.io/storj/pkg/auth"
"storj.io/storj/satellite/console/consoleauth"
@ -806,6 +806,24 @@ func (s *Service) GetProjectUsage(ctx context.Context, projectID uuid.UUID, sinc
return projectUsage, nil
}
// GetBucketTotals retrieves paged bucket total usages since project creation
func (s *Service) GetBucketTotals(ctx context.Context, projectID uuid.UUID, cursor BucketUsageCursor, before time.Time) (*BucketUsagePage, error) {
var err error
defer mon.Task()(&ctx)(&err)
auth, err := GetAuth(ctx)
if err != nil {
return nil, err
}
isMember, err := s.isProjectMember(ctx, auth.User.ID, projectID)
if err != nil {
return nil, err
}
return s.store.UsageRollups().GetBucketTotals(ctx, projectID, cursor, isMember.project.CreatedAt, before)
}
// GetBucketUsageRollups retrieves summed usage rollups for every bucket of particular project for a given period
func (s *Service) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) ([]BucketUsageRollup, error) {
var err error

View File

@ -14,6 +14,7 @@ import (
type UsageRollups interface {
GetProjectTotal(ctx context.Context, projectID uuid.UUID, since, before time.Time) (*ProjectUsage, error)
GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) ([]BucketUsageRollup, error)
GetBucketTotals(ctx context.Context, projectID uuid.UUID, cursor BucketUsageCursor, since, before time.Time) (*BucketUsagePage, error)
}
// ProjectUsage consist of period total storage, egress
@ -27,6 +28,40 @@ type ProjectUsage struct {
Before time.Time
}
// BucketUsage consist of total bucket usage for period
type BucketUsage struct {
ProjectID uuid.UUID
BucketName string
Storage float64
Egress float64
ObjectCount float64
Since time.Time
Before time.Time
}
// BucketUsageCursor holds info for bucket usage
// cursor pagination
type BucketUsageCursor struct {
Search string
Limit uint
Page uint
}
// BucketUsagePage represents bucket usage page result
type BucketUsagePage struct {
BucketUsages []BucketUsage
Search string
Limit uint
Offset uint64
PageCount uint
CurrentPage uint
TotalCount uint64
}
// BucketUsageRollup is total bucket usage info
// for certain period
type BucketUsageRollup struct {

View File

@ -1,17 +1,228 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package console
package console_test
import (
"encoding/binary"
"fmt"
"testing"
"time"
"github.com/skyrings/skyring-common/tools/uuid"
"github.com/stretchr/testify/assert"
"storj.io/storj/internal/testcontext"
"storj.io/storj/pkg/accounting"
"storj.io/storj/pkg/pb"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
)
const (
numBuckets = 5
tallyIntervals = 10
tallyInterval = time.Hour
)
func TestUsageRollups(t *testing.T) {
fmt.Println(time.Now().Format(time.RFC3339))
// 2018-04-02T18:25:11+03:00
// 2020-04-05T18:25:11+03:00
// 2019-04-03 17:16:26.822857431+00:00
satellitedbtest.Run(t, func(t *testing.T, db satellite.DB) {
ctx := testcontext.New(t)
defer ctx.Cleanup()
now := time.Now()
start := now.Add(tallyInterval * time.Duration(-tallyIntervals))
project1, err := uuid.New()
if err != nil {
t.Fatal(err)
}
project2, err := uuid.New()
if err != nil {
t.Fatal(err)
}
p1base := binary.BigEndian.Uint64(project1[:8]) >> 48
p2base := binary.BigEndian.Uint64(project2[:8]) >> 48
getValue := func(i, j int, base uint64) int64 {
a := uint64((i+1)*(j+1)) ^ base
a &^= (1 << 63)
return int64(a)
}
actions := []pb.PieceAction{
pb.PieceAction_GET,
pb.PieceAction_GET_AUDIT,
pb.PieceAction_GET_REPAIR,
}
var buckets []string
for i := 0; i < numBuckets; i++ {
bucketName := fmt.Sprintf("bucket_%d", i)
// project 1
bucketID := []byte(project1.String() + "/" + bucketName)
for _, action := range actions {
value := getValue(0, i, p1base)
err := db.Orders().UpdateBucketBandwidthAllocation(ctx,
bucketID,
action,
value*6,
now,
)
if err != nil {
t.Fatal(err)
}
err = db.Orders().UpdateBucketBandwidthSettle(ctx,
bucketID,
action,
value*3,
now,
)
if err != nil {
t.Fatal(err)
}
err = db.Orders().UpdateBucketBandwidthInline(ctx,
bucketID,
action,
value,
now,
)
if err != nil {
t.Fatal(err)
}
}
// project 2
bucketID = []byte(project2.String() + "/" + bucketName)
for _, action := range actions {
value := getValue(1, i, p2base)
err := db.Orders().UpdateBucketBandwidthAllocation(ctx,
bucketID,
action,
value*6,
now,
)
if err != nil {
t.Fatal(err)
}
err = db.Orders().UpdateBucketBandwidthSettle(ctx,
bucketID,
action,
value*3,
now,
)
if err != nil {
t.Fatal(err)
}
err = db.Orders().UpdateBucketBandwidthInline(ctx,
bucketID,
action,
value,
now,
)
if err != nil {
t.Fatal(err)
}
}
buckets = append(buckets, bucketName)
}
for i := 0; i < tallyIntervals; i++ {
interval := start.Add(tallyInterval * time.Duration(i))
bucketTallies := make(map[string]*accounting.BucketTally)
for j, bucket := range buckets {
bucketID1 := project1.String() + "/" + bucket
bucketID2 := project2.String() + "/" + bucket
value1 := getValue(i, j, p1base) * 10
value2 := getValue(i, j, p2base) * 10
tally1 := &accounting.BucketTally{
Segments: value1,
InlineSegments: value1,
RemoteSegments: value1,
Files: value1,
InlineFiles: value1,
RemoteFiles: value1,
Bytes: value1,
InlineBytes: value1,
RemoteBytes: value1,
MetadataSize: value1,
}
tally2 := &accounting.BucketTally{
Segments: value2,
InlineSegments: value2,
RemoteSegments: value2,
Files: value2,
InlineFiles: value2,
RemoteFiles: value2,
Bytes: value2,
InlineBytes: value2,
RemoteBytes: value2,
MetadataSize: value2,
}
bucketTallies[bucketID1] = tally1
bucketTallies[bucketID2] = tally2
}
tallies, err := db.ProjectAccounting().SaveTallies(ctx, interval, bucketTallies)
if err != nil {
t.Fatal(err)
}
if len(tallies) != len(buckets)*2 {
t.Fatal()
}
}
usageRollups := db.Console().UsageRollups()
t.Run("test project total", func(t *testing.T) {
projTotal1, err := usageRollups.GetProjectTotal(ctx, *project1, start, now)
assert.NoError(t, err)
assert.NotNil(t, projTotal1)
projTotal2, err := usageRollups.GetProjectTotal(ctx, *project2, start, now)
assert.NoError(t, err)
assert.NotNil(t, projTotal2)
})
t.Run("test bucket usage rollups", func(t *testing.T) {
rollups1, err := usageRollups.GetBucketUsageRollups(ctx, *project1, start, now)
assert.NoError(t, err)
assert.NotNil(t, rollups1)
rollups2, err := usageRollups.GetBucketUsageRollups(ctx, *project2, start, now)
assert.NoError(t, err)
assert.NotNil(t, rollups2)
})
t.Run("test bucket totals", func(t *testing.T) {
cursor := console.BucketUsageCursor{
Limit: 20,
Page: 1,
}
totals1, err := usageRollups.GetBucketTotals(ctx, *project1, cursor, start, now)
assert.NoError(t, err)
assert.NotNil(t, totals1)
totals2, err := usageRollups.GetBucketTotals(ctx, *project2, cursor, start, now)
assert.NoError(t, err)
assert.NotNil(t, totals2)
})
})
}

View File

@ -415,6 +415,12 @@ type lockedUsageRollups struct {
db console.UsageRollups
}
func (m *lockedUsageRollups) GetBucketTotals(ctx context.Context, projectID uuid.UUID, cursor console.BucketUsageCursor, since time.Time, before time.Time) (*console.BucketUsagePage, error) {
m.Lock()
defer m.Unlock()
return m.db.GetBucketTotals(ctx, projectID, cursor, since, before)
}
func (m *lockedUsageRollups) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since time.Time, before time.Time) ([]console.BucketUsageRollup, error) {
m.Lock()
defer m.Unlock()

View File

@ -23,6 +23,8 @@ type usagerollups struct {
// GetProjectTotal retrieves project usage for a given period
func (db *usagerollups) GetProjectTotal(ctx context.Context, projectID uuid.UUID, since, before time.Time) (usage *console.ProjectUsage, err error) {
since = timeTruncateDown(since)
storageQuery := db.db.All_BucketStorageTally_By_ProjectId_And_BucketName_And_IntervalStart_GreaterOrEqual_And_IntervalStart_LessOrEqual_OrderBy_Desc_IntervalStart
roullupsQuery := db.db.Rebind(`SELECT SUM(settled), SUM(inline), action
@ -95,6 +97,8 @@ func (db *usagerollups) GetProjectTotal(ctx context.Context, projectID uuid.UUID
// GetBucketUsageRollups retrieves summed usage rollups for every bucket of particular project for a given period
func (db *usagerollups) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) ([]console.BucketUsageRollup, error) {
since = timeTruncateDown(since)
buckets, err := db.getBuckets(ctx, projectID, since, before)
if err != nil {
return nil, err
@ -177,6 +181,154 @@ func (db *usagerollups) GetBucketUsageRollups(ctx context.Context, projectID uui
return bucketUsageRollups, nil
}
// GetBucketTotals retrieves bucket usage totals for period of time
func (db *usagerollups) GetBucketTotals(ctx context.Context, projectID uuid.UUID, cursor console.BucketUsageCursor, since, before time.Time) (*console.BucketUsagePage, error) {
since = timeTruncateDown(since)
search := cursor.Search + "%"
if cursor.Limit > 50 {
cursor.Limit = 50
}
if cursor.Page == 0 {
return nil, errs.New("page can not be 0")
}
page := &console.BucketUsagePage{
Search: cursor.Search,
Limit: cursor.Limit,
Offset: uint64((cursor.Page - 1) * cursor.Limit),
}
countQuery := db.db.Rebind(`SELECT COUNT(DISTINCT bucket_name)
FROM bucket_bandwidth_rollups
WHERE project_id = ? AND interval_start >= ? AND interval_start <= ?
AND CAST(bucket_name as TEXT) LIKE ?`)
countRow := db.db.QueryRowContext(ctx,
countQuery,
[]byte(projectID.String()),
since, before,
search)
err := countRow.Scan(&page.TotalCount)
if err != nil {
return nil, err
}
if page.TotalCount == 0 {
return page, nil
}
if page.Offset > page.TotalCount-1 {
return nil, errs.New("page is out of range")
}
bucketsQuery := db.db.Rebind(`SELECT DISTINCT bucket_name
FROM bucket_bandwidth_rollups
WHERE project_id = ? AND interval_start >= ? AND interval_start <= ?
AND CAST(bucket_name as TEXT) LIKE ?
ORDER BY bucket_name ASC
LIMIT ? OFFSET ?`)
bucketRows, err := db.db.QueryContext(ctx,
bucketsQuery,
[]byte(projectID.String()),
since, before,
search,
page.Limit,
page.Offset)
if err != nil {
return nil, err
}
defer func() { err = errs.Combine(err, bucketRows.Close()) }()
var buckets []string
for bucketRows.Next() {
var bucket string
err = bucketRows.Scan(&bucket)
if err != nil {
return nil, err
}
buckets = append(buckets, bucket)
}
roullupsQuery := db.db.Rebind(`SELECT SUM(settled), SUM(inline), action
FROM bucket_bandwidth_rollups
WHERE project_id = ? AND bucket_name = ? AND interval_start >= ? AND interval_start <= ?
GROUP BY action`)
storageQuery := db.db.All_BucketStorageTally_By_ProjectId_And_BucketName_And_IntervalStart_GreaterOrEqual_And_IntervalStart_LessOrEqual_OrderBy_Desc_IntervalStart
var bucketUsages []console.BucketUsage
for _, bucket := range buckets {
bucketUsage := console.BucketUsage{
ProjectID: projectID,
BucketName: bucket,
Since: since,
Before: before,
}
// get bucket_bandwidth_rollups
rollupsRows, err := db.db.QueryContext(ctx, roullupsQuery, []byte(projectID.String()), []byte(bucket), since, before)
if err != nil {
return nil, err
}
defer func() { err = errs.Combine(err, rollupsRows.Close()) }()
var totalEgress int64
for rollupsRows.Next() {
var action pb.PieceAction
var settled, inline int64
err = rollupsRows.Scan(&settled, &inline, &action)
if err != nil {
return nil, err
}
// add values for egress
if action == pb.PieceAction_GET || action == pb.PieceAction_GET_AUDIT || action == pb.PieceAction_GET_REPAIR {
totalEgress += settled + inline
}
}
bucketUsage.Egress = memory.Size(totalEgress).GB()
bucketStorageTallies, err := storageQuery(ctx,
dbx.BucketStorageTally_ProjectId([]byte(projectID.String())),
dbx.BucketStorageTally_BucketName([]byte(bucket)),
dbx.BucketStorageTally_IntervalStart(since),
dbx.BucketStorageTally_IntervalStart(before))
if err != nil {
return nil, err
}
// fill metadata, objects and stored data
// hours calculated from previous tallies,
// so we skip the most recent one
for i := len(bucketStorageTallies) - 1; i > 0; i-- {
current := bucketStorageTallies[i]
hours := bucketStorageTallies[i-1].IntervalStart.Sub(current.IntervalStart).Hours()
bucketUsage.Storage += memory.Size(current.Remote).GB() * hours
bucketUsage.Storage += memory.Size(current.Inline).GB() * hours
bucketUsage.ObjectCount += float64(current.ObjectCount) * hours
}
bucketUsages = append(bucketUsages, bucketUsage)
}
page.PageCount = uint(page.TotalCount / uint64(cursor.Limit))
if page.TotalCount%uint64(cursor.Limit) != 0 {
page.PageCount++
}
page.BucketUsages = bucketUsages
page.CurrentPage = cursor.Page
return page, nil
}
// getBuckets list all bucket of certain project for given period
func (db *usagerollups) getBuckets(ctx context.Context, projectID uuid.UUID, since, before time.Time) ([]string, error) {
bucketsQuery := db.db.Rebind(`SELECT DISTINCT bucket_name
@ -202,3 +354,8 @@ func (db *usagerollups) getBuckets(ctx context.Context, projectID uuid.UUID, sin
return buckets, nil
}
// timeTruncateDown truncates down to the hour before to be in sync with orders endpoint
func timeTruncateDown(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
}

View File

@ -41,3 +41,57 @@ export async function fetchProjectUsage(projectID: string, since: Date, before:
return result;
}
// fetchBucketUsages retrieves bucket usage totals for a particular project
export async function fetchBucketUsages(projectID: string, before: Date, cursor: BucketUsageCursor): Promise<RequestResponse<BucketUsagePage>> {
let result: RequestResponse<BucketUsagePage> = {
errorMessage: '',
isSuccess: false,
data: {} as BucketUsagePage
};
let response: any = null;
try {
response = await apollo.query(
{
query: gql(`
query {
project(id: "${projectID}") {
bucketUsages(before: "${before.toISOString()}", cursor: {
limit: ${cursor.limit}, search: "${cursor.search}", page: ${cursor.page}
}) {
bucketUsages{
bucketName,
storage,
egress,
objectCount,
since,
before
},
search,
limit,
offset,
pageCount,
currentPage,
totalCount
}
}
}`
),
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
);
} catch (e) {
console.log(e);
}
if (response.errors) {
result.errorMessage = response.errors[0].message;
} else {
result.isSuccess = true;
result.data = response.data.project.bucketUsages;
}
return result;
}

View File

@ -3,21 +3,25 @@
<template>
<div>
<div v-if="buckets > 0" class="buckets-overflow">
<div class="buckets-overflow">
<div class="buckets-header">
<p>Buckets</p>
<SearchArea/>
</div>
<div class="buckets-container">
<div v-if="buckets.length > 0" class="buckets-container">
<table style="width:98.5%; margin-top:20px;">
<SortingHeader />
<BucketItem />
<BucketItem v-for="(bucket, index) in buckets" v-bind:bucket="bucket" v-bind:key="index" />
</table>
<PaginationArea />
</div>
<EmptyState
v-if="pages === 0 && search && search.length > 0"
mainTitle="Nothing found :("
:imageSource="emptyImage" />
</div>
<EmptyState
v-if="buckets === 0"
v-if="pages === 0 && !search"
mainTitle="You have no Buckets yet"
:imageSource="emptyImage" />
</div>
@ -35,8 +39,7 @@
@Component({
data: function () {
return {
emptyImage: EMPTY_STATE_IMAGES.API_KEY,
buckets: 1,
emptyImage: EMPTY_STATE_IMAGES.API_KEY
};
},
components: {
@ -45,7 +48,18 @@
SortingHeader,
BucketItem,
PaginationArea,
}
},
computed: {
buckets: function (): BucketUsage[] {
return this.$store.state.bucketUsageModule.page.bucketUsages;
},
pages: function (): number {
return this.$store.state.bucketUsageModule.page.pageCount;
},
search: function (): string {
return this.$store.state.bucketUsageModule.cursor.search;
}
}
})
export default class BucketArea extends Vue {}
</script>

View File

@ -3,18 +3,32 @@
<template>
<tr class="container">
<td class="container__item">test</td>
<td class="container__item">test</td>
<td class="container__item">test</td>
<td class="container__item">test</td>
<td class="container__item">test</td>
<td class="container__item">{{ bucket.bucketName }}</td>
<td class="container__item">{{ storage }}</td>
<td class="container__item">{{ egress }}</td>
<td class="container__item">{{ objectCount }}</td>
</tr>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component({})
@Component({
props: {
bucket: Object
},
computed: {
storage: function (): string {
return (this as any).bucket.storage.toFixed(4);
},
egress: function (): string {
return (this as any).bucket.egress.toFixed(4);
},
objectCount: function (): string {
return (this as any).bucket.objectCount.toFixed(4);
}
}
})
export default class BucketItem extends Vue {}
</script>

View File

@ -5,18 +5,14 @@
<div>
<div class="pagination-container">
<div class="pagination-container__pages">
<div v-html="arrowLeft" class="pagination-container__button"></div>
<div v-html="arrowLeft" v-on:click="prevPage" class="pagination-container__button"></div>
<div class="pagination-container__items">
<span class="selected">1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span v-for="(value, index) in pages" v-bind:class="isSelected(index+1)" v-on:click="onPageClick($event, index+1)">{{index+1}}</span>
</div>
<div v-html="arrowRight" class="pagination-container__button"></div>
<div v-html="arrowRight" v-on:click="nextPage" class="pagination-container__button"></div>
</div>
<div class="pagination-container__counter">
<p>Showing <span>1</span> to <span>6</span> of <span>90</span> entries.</p>
<p>Showing <span>{{firstEdge}}</span> to <span>{{lastEdge}}</span> of <span>{{totalCount}}</span> entries.</p>
</div>
</div>
</div>
@ -25,6 +21,7 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { EMPTY_STATE_IMAGES } from '@/utils/constants/emptyStatesImages';
import { BUCKET_USAGE_ACTIONS, NOTIFICATION_ACTIONS } from '@/utils/constants/actionNames';
@Component({
data: function() {
@ -33,6 +30,66 @@
arrowRight: EMPTY_STATE_IMAGES.ARROW_RIGHT,
};
},
methods: {
onPageClick: async function (event: any, page: number) {
const response = await this.$store.dispatch(BUCKET_USAGE_ACTIONS.FETCH, page);
if (!response.isSuccess) {
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch buckets: ' + response.errorMessage);
}
},
isSelected: function (page: number): string {
return page === (this as any).currentPage ? "selected" : "";
},
nextPage: async function() {
if ((this as any).isLastPage) {
return
}
const response = await this.$store.dispatch(BUCKET_USAGE_ACTIONS.FETCH, (this as any).currentPage + 1);
if (!response.isSuccess) {
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch buckets: ' + response.errorMessage);
}
},
prevPage: async function() {
if ((this as any).isFirstPage) {
return
}
const response = await this.$store.dispatch(BUCKET_USAGE_ACTIONS.FETCH, (this as any).currentPage - 1);
if (!response.isSuccess) {
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch buckets: ' + response.errorMessage);
}
}
},
computed: {
pages: function(): number[] {
return new Array(this.$store.state.bucketUsageModule.page.pageCount);
},
currentPage: function (): number {
return this.$store.state.bucketUsageModule.page.currentPage;
},
firstEdge: function (): number {
return this.$store.state.bucketUsageModule.page.offset + 1;
},
lastEdge: function (): number {
let offset = this.$store.state.bucketUsageModule.page.offset;
let bucketsLength = this.$store.state.bucketUsageModule.page.bucketUsages.length;
return offset + bucketsLength;
},
totalCount: function (): number {
return this.$store.state.bucketUsageModule.page.totalCount;
},
isFirstPage: function() {
return this.$store.state.bucketUsageModule.page.currentPage === 1;
},
isLastPage: function (): boolean {
let currentPage = this.$store.state.bucketUsageModule.page.currentPage;
let pageCount = this.$store.state.bucketUsageModule.page.pageCount;
return currentPage === pageCount;
}
}
})
export default class PaginationArea extends Vue {}

View File

@ -5,7 +5,7 @@
<div class="search-container">
<div class="search-container__wrap">
<label class="search-container__wrap__input">
<input placeholder="Search Buckets" type="text">
<input v-model="search" v-on:input="fetch" placeholder="Search Buckets" type="text">
</label>
</div>
</div>
@ -13,8 +13,28 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { BUCKET_USAGE_ACTIONS, NOTIFICATION_ACTIONS } from '@/utils/constants/actionNames';
@Component({})
@Component({
methods: {
fetch: async function() {
const bucketsResponse = await this.$store.dispatch(BUCKET_USAGE_ACTIONS.FETCH, 1);
if (!bucketsResponse.isSuccess) {
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch buckets: ' + bucketsResponse.errorMessage);
}
}
},
computed: {
search: {
get: function (): string {
return this.$store.state.bucketUsageModule.cursor.search;
},
set: function (search: string) {
this.$store.dispatch(BUCKET_USAGE_ACTIONS.SET_SEARCH, search)
}
}
}
})
export default class SearchArea extends Vue {}
</script>

View File

@ -6,46 +6,21 @@
<th class="sort-header-container__item">
<div class="row">
<p>Bucket Name</p>
<div class="sort-header-container__item__arrows">
<span v-html="arrowUp"></span>
<span class="selected" v-html="arrowDown"></span>
</div>
</div>
</th>
<th class="sort-header-container__item">
<div class="row">
<p>Creation Date</p>
<div class="sort-header-container__item__arrows">
<span v-html="arrowUp"></span>
<span v-html="arrowDown"></span>
</div>
</div>
</th>
<th class="sort-header-container__item">
<div class="row">
<p>Storage Used</p>
<div class="sort-header-container__item__arrows">
<span v-html="arrowUp"></span>
<span v-html="arrowDown"></span>
</div>
</div>
</th>
<th class="sort-header-container__item">
<div class="row">
<p>Egress Used</p>
<div class="sort-header-container__item__arrows">
<span v-html="arrowUp"></span>
<span v-html="arrowDown"></span>
</div>
</div>
</th>
<th class="sort-header-container__item">
<div class="row">
<p>Objects Stored</p>
<div class="sort-header-container__item__arrows">
<span v-html="arrowUp"></span>
<span v-html="arrowDown"></span>
</div>
</div>
</th>
</tr>
@ -53,15 +28,9 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { EMPTY_STATE_IMAGES } from '@/utils/constants/emptyStatesImages';
@Component({
data: function() {
return {
arrowUp: EMPTY_STATE_IMAGES.ARROW_UP,
arrowDown: EMPTY_STATE_IMAGES.ARROW_DOWN,
};
}
})
@Component({})
export default class SortBucketsHeader extends Vue {
}
</script>

View File

@ -188,13 +188,13 @@ import { toUnixTimestamp } from '@/utils/time';
},
},
computed: {
storage: function () {
storage: function (): string {
return this.$store.state.usageModule.projectUsage.storage.toPrecision(5);
},
egress: function () {
egress: function (): string {
return this.$store.state.usageModule.projectUsage.egress.toPrecision(5);
},
objectsCount: function () {
objectsCount: function (): string {
return this.$store.state.usageModule.projectUsage.objectCount.toPrecision(5);
}
}

View File

@ -101,6 +101,11 @@ let router = new Router({
name: ROUTES.API_KEYS.name,
component: ApiKeysArea
},
{
path: ROUTES.BUCKETS.path,
name: ROUTES.BUCKETS.name,
component: BucketArea
},
// {
// path: ROUTES.BUCKETS.path,
// name: ROUTES.BUCKETS.name,
@ -125,7 +130,7 @@ let router = new Router({
// and if we are able to navigate to page without existing project
router.beforeEach((to, from, next) => {
if (isUnavailablePageWithoutProject(to.name as string)) {
next(ROUTES.PROJECT_OVERVIEW);
next(ROUTES.PROJECT_OVERVIEW + '/' + ROUTES.PROJECT_DETAILS);
return;
}

View File

@ -11,7 +11,7 @@ import { projectMembersModule } from '@/store/modules/projectMembers';
import { notificationsModule } from '@/store/modules/notifications';
import { appStateModule } from '@/store/modules/appState';
import { apiKeysModule } from '@/store/modules/apiKeys';
import { usageModule } from '@/store/modules/usage';
import { bucketUsageModule, usageModule } from '@/store/modules/usage';
Vue.use(Vuex);
@ -24,7 +24,8 @@ const store = new Vuex.Store({
notificationsModule,
appStateModule,
apiKeysModule,
usageModule
usageModule,
bucketUsageModule
}
});

View File

@ -1,13 +1,13 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { PROJECT_USAGE_MUTATIONS } from '@/store/mutationConstants';
import { PROJECT_USAGE_ACTIONS } from '@/utils/constants/actionNames';
import { fetchProjectUsage } from '@/api/usage';
import {BUCKET_USAGE_MUTATIONS, PROJECT_USAGE_MUTATIONS} from '@/store/mutationConstants';
import {BUCKET_USAGE_ACTIONS, PROJECT_USAGE_ACTIONS} from '@/utils/constants/actionNames';
import { fetchBucketUsages, fetchProjectUsage } from '@/api/usage';
export const usageModule = {
state: {
projectUsage: {storage: 0, egress: 0, objectCount: 0} as ProjectUsage
projectUsage: {storage: 0, egress: 0, objectCount: 0} as ProjectUsage,
},
mutations: {
[PROJECT_USAGE_MUTATIONS.FETCH](state: any, projectUsage: ProjectUsage) {
@ -34,3 +34,50 @@ export const usageModule = {
}
}
};
const bucketPageLimit = 12;
const firstPage = 1;
export const bucketUsageModule = {
state: {
cursor: { limit: bucketPageLimit, search: '', page: firstPage } as BucketUsageCursor,
page: { bucketUsages: [] as BucketUsage[] } as BucketUsagePage,
},
mutations: {
[BUCKET_USAGE_MUTATIONS.FETCH](state: any, page: BucketUsagePage) {
state.page = page;
},
[BUCKET_USAGE_MUTATIONS.SET_PAGE](state: any, page: number) {
state.cursor.page = page;
},
[BUCKET_USAGE_MUTATIONS.SET_SEARCH](state: any, search: string) {
state.cursor.search = search;
},
[BUCKET_USAGE_MUTATIONS.CLEAR](state: any) {
state.cursor = { limit: bucketPageLimit, search: '', page: firstPage } as BucketUsageCursor;
state.page = { bucketUsages: [] as BucketUsage[] } as BucketUsagePage;
}
},
actions: {
[BUCKET_USAGE_ACTIONS.FETCH]: async function({commit, rootGetters, state}: any, page: number): Promise<RequestResponse<BucketUsagePage>> {
const projectID = rootGetters.selectedProject.id;
const before = new Date();
state.cursor.page = page;
commit(BUCKET_USAGE_MUTATIONS.SET_PAGE, page);
let result = await fetchBucketUsages(projectID, before, state.cursor);
if (result.isSuccess) {
commit(BUCKET_USAGE_MUTATIONS.FETCH, result.data);
}
return result;
},
[BUCKET_USAGE_ACTIONS.SET_SEARCH]: function({commit}, search: string) {
commit(BUCKET_USAGE_MUTATIONS.SET_SEARCH, search);
},
[BUCKET_USAGE_ACTIONS.CLEAR]: function({commit}) {
commit(BUCKET_USAGE_MUTATIONS.CLEAR);
}
}
};

View File

@ -44,6 +44,13 @@ export const PROJECT_USAGE_MUTATIONS = {
CLEAR: 'CLEAR_PROJECT_USAGE'
};
export const BUCKET_USAGE_MUTATIONS = {
FETCH: 'FETCH_BUCKET_USAGES',
SET_SEARCH: "SET_SEARCH_BUCKET_USAGE",
SET_PAGE: "SET_PAGE_BUCKET_USAGE",
CLEAR: 'CLEAR_BUCKET_USAGES'
};
export const NOTIFICATION_MUTATIONS = {
ADD: 'ADD_NOTIFICATION',
DELETE: 'DELETE_NOTIFICATION',

View File

@ -28,12 +28,3 @@ declare type TeamMemberModel = {
}
joinedAt: string,
};
// ProjectUsage sums usage for given period
declare type ProjectUsage = {
storage: number,
egress: number,
objectCount: number,
since: Date,
before: Date
};

40
web/satellite/src/types/usage.d.ts vendored Normal file
View File

@ -0,0 +1,40 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
// ProjectUsage sums usage for given period
declare type ProjectUsage = {
storage: number,
egress: number,
objectCount: number,
since: Date,
before: Date
};
// BucketUSage total usage of a bucket for given period
declare type BucketUsage = {
bucketName: string,
storage: number,
egress: number,
objectCount: number,
since: Date,
before: Date
};
// BucketUsagePage holds bucket total usages and flag
// wether more usages available
declare type BucketUsagePage = {
bucketUsages: BucketUsage[],
search: string,
limit: number,
offset: number,
pageCount: number,
currentPage: number,
totalCount: number,
};
// BucketUsageCursor holds cursor for bucket name and limit
declare type BucketUsageCursor = {
search: string,
limit: number,
page: number
};

View File

@ -71,3 +71,9 @@ export const PROJECT_USAGE_ACTIONS = {
FETCH: 'fetchProjectUsage',
CLEAR: 'clearProjectUsage',
};
export const BUCKET_USAGE_ACTIONS = {
FETCH: 'fetchBucketUsages',
SET_SEARCH: 'setSearchBucketUsage',
CLEAR: 'clearBucketUsages'
};

View File

@ -58,15 +58,15 @@ const NAVIGATION_ITEMS = {
</defs>
</svg>`
},
// BUCKETS: {
// label: 'Buckets',
// path: ROUTES.BUCKETS.path,
// svg: `<svg class="svg" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M1 12.4548V20.273C1 21.273 1.81116 22.0003 2.71245 22.0003H20.1974C21.1888 22.0003 21.9099 21.1821 21.9099 20.273V12.4548C21.9099 11.4548 21.0987 10.7275 20.1974 10.7275H2.71245C1.81116 10.7275 1 11.4548 1 12.4548ZM14.97 14.0912H7.75966C7.30901 14.0912 6.85837 13.7275 6.85837 13.1821C6.85837 12.7275 7.21888 12.273 7.75966 12.273H14.97C15.4206 12.273 15.8712 12.6366 15.8712 13.1821C15.8712 13.7275 15.5107 14.0912 14.97 14.0912Z" fill="#354049"/>
// <path d="M2.53227 9.81792C2.17175 9.81792 1.90137 9.54519 1.90137 9.18155V5.90882C1.90137 5.54519 2.17175 5.27246 2.53227 5.27246H20.4679C20.8284 5.27246 21.0988 5.54519 21.0988 5.90882V8.99973C21.0988 9.36337 20.8284 9.6361 20.4679 9.6361C20.1074 9.6361 19.837 9.36337 19.837 8.99973V6.54519H3.16317V9.18155C3.16317 9.54519 2.89278 9.81792 2.53227 9.81792Z" fill="#354049"/>
// <path d="M20.4679 4.27273H2.53227C2.17175 4.27273 1.90137 4 1.90137 3.63636C1.90137 3.27273 2.17175 3 2.53227 3H20.4679C20.8284 3 21.0988 3.27273 21.0988 3.63636C21.0988 4 20.8284 4.27273 20.4679 4.27273Z" fill="#354049"/>
// </svg>`
// },
BUCKETS: {
label: 'Buckets',
path: ROUTES.BUCKETS.path,
svg: `<svg class="svg" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12.4548V20.273C1 21.273 1.81116 22.0003 2.71245 22.0003H20.1974C21.1888 22.0003 21.9099 21.1821 21.9099 20.273V12.4548C21.9099 11.4548 21.0987 10.7275 20.1974 10.7275H2.71245C1.81116 10.7275 1 11.4548 1 12.4548ZM14.97 14.0912H7.75966C7.30901 14.0912 6.85837 13.7275 6.85837 13.1821C6.85837 12.7275 7.21888 12.273 7.75966 12.273H14.97C15.4206 12.273 15.8712 12.6366 15.8712 13.1821C15.8712 13.7275 15.5107 14.0912 14.97 14.0912Z" fill="#354049"/>
<path d="M2.53227 9.81792C2.17175 9.81792 1.90137 9.54519 1.90137 9.18155V5.90882C1.90137 5.54519 2.17175 5.27246 2.53227 5.27246H20.4679C20.8284 5.27246 21.0988 5.54519 21.0988 5.90882V8.99973C21.0988 9.36337 20.8284 9.6361 20.4679 9.6361C20.1074 9.6361 19.837 9.36337 19.837 8.99973V6.54519H3.16317V9.18155C3.16317 9.54519 2.89278 9.81792 2.53227 9.81792Z" fill="#354049"/>
<path d="M20.4679 4.27273H2.53227C2.17175 4.27273 1.90137 4 1.90137 3.63636C1.90137 3.27273 2.17175 3 2.53227 3H20.4679C20.8284 3 21.0988 3.27273 21.0988 3.63636C21.0988 4 20.8284 4.27273 20.4679 4.27273Z" fill="#354049"/>
</svg>`
},
};
export default NAVIGATION_ITEMS;

View File

@ -35,6 +35,7 @@ import {AppState} from "../utils/constants/appStateEnum";
PROJETS_ACTIONS,
USER_ACTIONS,
PROJECT_USAGE_ACTIONS,
BUCKET_USAGE_ACTIONS
} from '@/utils/constants/actionNames';
import ROUTES from '@/utils/constants/routerConstants';
import ProjectCreationSuccessPopup from '@/components/project/ProjectCreationSuccessPopup.vue';
@ -83,6 +84,11 @@ import {AppState} from "../utils/constants/appStateEnum";
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch project usage');
}
const bucketsResponse = await this.$store.dispatch(BUCKET_USAGE_ACTIONS.FETCH, 1);
if (!bucketsResponse.isSuccess) {
this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, 'Unable to fetch buckets: ' + bucketsResponse.errorMessage);
}
this.$store.dispatch(APP_STATE_ACTIONS.CHANGE_STATE, AppState.LOADED);
}, 800);
},