satellite/{console, web}: detailed usage report for a single project

Reworked usage report endpoint to return CSV for a single OR all the project user owns.
Added buttons to download usage report CSV for a single project.

Issue:
https://github.com/storj/storj/issues/6154

Change-Id: I55104088180dcf6be49dcde6c9c495f07ba01c5a
This commit is contained in:
Vitalii 2023-11-01 14:07:48 +02:00
parent f0f73fc8ae
commit f6e357be52
12 changed files with 147 additions and 34 deletions

View File

@ -165,9 +165,17 @@ type BucketUsageRollup struct {
Before time.Time `json:"before"`
}
// ProjectBucketUsageRollup is total bucket usage info with project details for certain period.
type ProjectBucketUsageRollup struct {
ProjectName string `json:"projectName"`
BucketUsageRollup
}
// ToStringSlice converts rollup values to a slice of strings.
func (b *BucketUsageRollup) ToStringSlice() []string {
func (b *ProjectBucketUsageRollup) ToStringSlice() []string {
return []string{
b.ProjectName,
b.ProjectID.String(),
b.BucketName,
fmt.Sprintf("%f", b.TotalStoredData),

View File

@ -106,8 +106,8 @@ func (ul *UsageLimits) TotalUsageLimits(w http.ResponseWriter, r *http.Request)
}
}
// TotalUsageReport returns total usage report for all the projects that user owns.
func (ul *UsageLimits) TotalUsageReport(w http.ResponseWriter, r *http.Request) {
// UsageReport returns usage report for all the projects that user owns or a single user's project.
func (ul *UsageLimits) UsageReport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
@ -126,7 +126,18 @@ func (ul *UsageLimits) TotalUsageReport(w http.ResponseWriter, r *http.Request)
since := time.Unix(sinceStamp, 0).UTC()
before := time.Unix(beforeStamp, 0).UTC()
usage, err := ul.service.GetTotalUsageReport(ctx, since, before)
var projectID uuid.UUID
idParam := r.URL.Query().Get("projectID")
if idParam != "" {
projectID, err = uuid.FromString(idParam)
if err != nil {
ul.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid project id: %v", err))
return
}
}
usage, err := ul.service.GetUsageReport(ctx, since, before, projectID)
if err != nil {
if console.ErrUnauthorized.Has(err) {
ul.serveJSONError(ctx, w, http.StatusUnauthorized, err)
@ -137,14 +148,15 @@ func (ul *UsageLimits) TotalUsageReport(w http.ResponseWriter, r *http.Request)
return
}
fileName := "storj-report-" + since.Format("2006-01-02") + "-to-" + before.Format("2006-01-02") + ".csv"
dateFormat := "2006-01-02"
fileName := "storj-report-" + idParam + "-" + since.Format(dateFormat) + "-to-" + before.Format(dateFormat) + ".csv"
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment;filename="+fileName)
wr := csv.NewWriter(w)
csvHeaders := []string{"ProjectID", "BucketName", "TotalStoredData GB-hour", "TotalSegments GB-hour", "ObjectCount GB-hour", "MetadataSize GB-hour", "RepairEgress GB", "GetEgress GB", "AuditEgress GB", "Since", "Before"}
csvHeaders := []string{"ProjectName", "ProjectID", "BucketName", "TotalStoredData GB-hour", "TotalSegments GB-hour", "ObjectCount GB-hour", "MetadataSize GB-hour", "RepairEgress GB", "GetEgress GB", "AuditEgress GB", "Since", "Before"}
err = wr.Write(csvHeaders)
if err != nil {

View File

@ -235,29 +235,48 @@ func Test_TotalUsageReport(t *testing.T) {
err = satelliteSys.DB.ProjectAccounting().SaveTallies(ctx, inFiveMinutes, bucketTallies)
require.NoError(t, err)
endpoint := fmt.Sprintf("projects/total-usage-report?since=%s&before=%s", since, before)
endpoint := fmt.Sprintf("projects/usage-report?since=%s&before=%s&projectID=", since, before)
body, status, err := doRequestWithAuth(ctx, t, satelliteSys, user, http.MethodGet, endpoint, nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
content := string(body)
reader := csv.NewReader(strings.NewReader(content))
reader := csv.NewReader(strings.NewReader(string(body)))
records, err := reader.ReadAll()
require.NoError(t, err)
require.Len(t, records, 3)
expectedHeaders := []string{"ProjectID", "BucketName", "TotalStoredData GB-hour", "TotalSegments GB-hour", "ObjectCount GB-hour", "MetadataSize GB-hour", "RepairEgress GB", "GetEgress GB", "AuditEgress GB", "Since", "Before"}
expectedHeaders := []string{"ProjectName", "ProjectID", "BucketName", "TotalStoredData GB-hour", "TotalSegments GB-hour", "ObjectCount GB-hour", "MetadataSize GB-hour", "RepairEgress GB", "GetEgress GB", "AuditEgress GB", "Since", "Before"}
for i, header := range expectedHeaders {
require.Equal(t, header, records[0][i])
}
require.Equal(t, project1.PublicID.String(), records[1][0])
require.Equal(t, project2.PublicID.String(), records[2][0])
require.Equal(t, bucketName, records[1][1])
require.Equal(t, bucketName, records[2][1])
for i := 2; i < 9; i++ {
require.Equal(t, project1.Name, records[1][0])
require.Equal(t, project2.Name, records[2][0])
require.Equal(t, project1.PublicID.String(), records[1][1])
require.Equal(t, project2.PublicID.String(), records[2][1])
require.Equal(t, bucketName, records[1][2])
require.Equal(t, bucketName, records[2][2])
for i := 3; i < 10; i++ {
require.Equal(t, expectedCSVValue, records[1][i])
require.Equal(t, expectedCSVValue, records[2][i])
}
endpoint = fmt.Sprintf("projects/usage-report?since=%s&before=%s&projectID=%s", since, before, project1.PublicID)
body, status, err = doRequestWithAuth(ctx, t, satelliteSys, user, http.MethodGet, endpoint, nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
reader = csv.NewReader(strings.NewReader(string(body)))
records, err = reader.ReadAll()
require.NoError(t, err)
require.Len(t, records, 2)
require.Equal(t, project1.Name, records[1][0])
require.Equal(t, project1.PublicID.String(), records[1][1])
require.Equal(t, bucketName, records[1][2])
for i := 3; i < 10; i++ {
require.Equal(t, expectedCSVValue, records[1][i])
}
})
}

View File

@ -297,7 +297,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
projectsRouter.Handle("/{id}/usage-limits", http.HandlerFunc(usageLimitsController.ProjectUsageLimits)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/usage-limits", http.HandlerFunc(usageLimitsController.TotalUsageLimits)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/{id}/daily-usage", http.HandlerFunc(usageLimitsController.DailyUsage)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/total-usage-report", http.HandlerFunc(usageLimitsController.TotalUsageReport)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/usage-report", http.HandlerFunc(usageLimitsController.UsageReport)).Methods(http.MethodGet, http.MethodOptions)
authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL)
authRouter := router.PathPrefix("/api/v0/auth").Subrouter()

View File

@ -2723,22 +2723,35 @@ func (s *Service) GetAllBucketNames(ctx context.Context, projectID uuid.UUID) (_
return list, nil
}
// GetTotalUsageReport retrieves usage rollups for every bucket of all user owned projects for a given period.
func (s *Service) GetTotalUsageReport(ctx context.Context, since, before time.Time) ([]accounting.BucketUsageRollup, error) {
// GetUsageReport retrieves usage rollups for every bucket of a single or all the user owned projects for a given period.
func (s *Service) GetUsageReport(ctx context.Context, since, before time.Time, projectID uuid.UUID) ([]accounting.ProjectBucketUsageRollup, error) {
var err error
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get user report")
user, err := s.getUserAndAuditLog(ctx, "get usage report")
if err != nil {
return nil, Error.Wrap(err)
}
projects, err := s.store.Projects().GetOwn(ctx, user.ID)
var projects []Project
if projectID.IsZero() {
pr, err := s.store.Projects().GetOwn(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
usage := make([]accounting.BucketUsageRollup, 0)
projects = append(projects, pr...)
} else {
_, pr, err := s.isProjectOwner(ctx, user.ID, projectID)
if err != nil {
return nil, ErrUnauthorized.Wrap(err)
}
projects = append(projects, *pr)
}
usage := make([]accounting.ProjectBucketUsageRollup, 0)
for _, p := range projects {
rollups, err := s.projectAccounting.GetBucketUsageRollups(ctx, p.ID, since, before)
@ -2746,9 +2759,23 @@ func (s *Service) GetTotalUsageReport(ctx context.Context, since, before time.Ti
return nil, Error.Wrap(err)
}
for i := range rollups {
rollups[i].ProjectID = p.PublicID
usage = append(usage, rollups[i])
for _, r := range rollups {
usage = append(usage, accounting.ProjectBucketUsageRollup{
ProjectName: p.Name,
BucketUsageRollup: accounting.BucketUsageRollup{
ProjectID: p.PublicID,
BucketName: r.BucketName,
TotalStoredData: r.TotalStoredData,
TotalSegments: r.TotalSegments,
ObjectCount: r.ObjectCount,
MetadataSize: r.MetadataSize,
RepairEgress: r.RepairEgress,
GetEgress: r.GetEgress,
AuditEgress: r.AuditEgress,
Since: r.Since,
Before: r.Before,
},
})
}
}

View File

@ -204,10 +204,10 @@ export class ProjectsHttpApi implements ProjectsApi {
*
* @throws Error
*/
public getTotalUsageReportLink(start: Date, end: Date): string {
public getTotalUsageReportLink(start: Date, end: Date, projectID: string): string {
const since = Time.toUnixTimestamp(start).toString();
const before = Time.toUnixTimestamp(end).toString();
return `${this.ROOT_PATH}/total-usage-report?since=${since}&before=${before}`;
return `${this.ROOT_PATH}/usage-report?since=${since}&before=${before}&projectID=${projectID}`;
}
/**

View File

@ -187,8 +187,9 @@ function balanceClicked(): void {
* Handles download usage report click logic.
*/
function downloadUsageReport(): void {
const link = projectsStore.getTotalUsageReportLink();
const link = projectsStore.getUsageReportLink();
Download.fileByLink(link);
notify.success('Usage report download started successfully.');
}
/**

View File

@ -51,6 +51,15 @@
<p class="price">{{ centsToDollars(charge.segmentPrice) }}</p>
</div>
</div>
<v-button
class="usage-charges-item-container__detailed-info-container__btn"
label="Download Report"
width="140px"
height="30px"
font-size="14px"
is-transparent
:on-press="downloadUsageReport"
/>
</div>
</template>
</div>
@ -66,6 +75,10 @@ import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
import { useBillingStore } from '@/store/modules/billingStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { Download } from '@/utils/download';
import { useNotify } from '@/utils/hooks';
import VButton from '@/components/common/VButton.vue';
import GreyChevron from '@/../static/images/common/greyChevron.svg';
@ -86,6 +99,8 @@ const props = withDefaults(defineProps<{
const billingStore = useBillingStore();
const projectsStore = useProjectsStore();
const notify = useNotify();
/**
* isDetailedInfoShown indicates if area with detailed information about project charges is expanded.
*/
@ -118,6 +133,15 @@ const projectCharges = computed((): ProjectCharges => {
return billingStore.state.projectCharges as ProjectCharges;
});
/**
* Handles download usage report click logic.
*/
function downloadUsageReport(): void {
const link = projectsStore.getUsageReportLink(props.projectId);
Download.fileByLink(link);
notify.success('Usage report download started successfully.');
}
/**
* Returns project usage price model from store.
*/
@ -325,6 +349,10 @@ function toggleDetailedInfo(): void {
width: 40%;
}
}
&__btn {
margin-top: 16px;
}
}
}

View File

@ -49,12 +49,12 @@ export const useProjectsStore = defineStore('projects', () => {
const api: ProjectsApi = new ProjectsHttpApi();
function getTotalUsageReportLink(): string {
function getUsageReportLink(projectID = ''): string {
const now = new Date();
const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes()));
const startUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0));
return api.getTotalUsageReportLink(startUTC, endUTC);
return api.getTotalUsageReportLink(startUTC, endUTC, projectID);
}
async function getProjects(): Promise<Project[]> {
@ -349,7 +349,7 @@ export const useProjectsStore = defineStore('projects', () => {
requestLimitIncrease,
getProjectLimits,
getTotalLimits,
getTotalUsageReportLink,
getUsageReportLink,
getProjectSalt,
getUserInvitations,
respondToInvitation,

View File

@ -69,7 +69,7 @@ export interface ProjectsApi {
*
* @throws Error
*/
getTotalUsageReportLink(start: Date, end: Date): string
getTotalUsageReportLink(start: Date, end: Date, projectID: string): string
/**
* Get project daily usage by specific date range.

View File

@ -63,6 +63,9 @@
</tr>
</tbody>
</v-table>
<v-btn class="mt-4 ml-4" variant="outlined" color="default" size="small" @click="downloadReport">
Download Report
</v-btn>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
@ -72,6 +75,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
VBtn,
VCard,
VCol,
VExpansionPanel,
@ -89,6 +93,8 @@ import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
import { useBillingStore } from '@/store/modules/billingStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { Download } from '@/utils/download';
import { useNotify } from '@/utils/hooks';
/**
* HOURS_IN_MONTH constant shows amount of hours in 30-day month.
@ -107,6 +113,8 @@ const props = withDefaults(defineProps<{
const billingStore = useBillingStore();
const projectsStore = useProjectsStore();
const notify = useNotify();
/**
* An array of tuples containing the partner name and usage charge for the specified project ID.
*/
@ -134,6 +142,15 @@ const projectCharges = computed((): ProjectCharges => {
return billingStore.state.projectCharges as ProjectCharges;
});
/**
* Handles download usage report click logic.
*/
function downloadReport(): void {
const link = projectsStore.getUsageReportLink(props.projectId);
Download.fileByLink(link);
notify.success('Usage report download started successfully.');
}
/**
* Returns project usage price model from store.
*/

View File

@ -330,8 +330,9 @@ const isCouponActive = computed((): boolean => {
});
function downloadReport(): void {
const link = projectsStore.getTotalUsageReportLink();
const link = projectsStore.getUsageReportLink();
Download.fileByLink(link);
notify.success('Usage report download started successfully.');
}
function goToTransactionsTab() {