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"` 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. // ToStringSlice converts rollup values to a slice of strings.
func (b *BucketUsageRollup) ToStringSlice() []string { func (b *ProjectBucketUsageRollup) ToStringSlice() []string {
return []string{ return []string{
b.ProjectName,
b.ProjectID.String(), b.ProjectID.String(),
b.BucketName, b.BucketName,
fmt.Sprintf("%f", b.TotalStoredData), 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. // UsageReport returns usage report for all the projects that user owns or a single user's project.
func (ul *UsageLimits) TotalUsageReport(w http.ResponseWriter, r *http.Request) { func (ul *UsageLimits) UsageReport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
var err error var err error
defer mon.Task()(&ctx)(&err) 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() since := time.Unix(sinceStamp, 0).UTC()
before := time.Unix(beforeStamp, 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 err != nil {
if console.ErrUnauthorized.Has(err) { if console.ErrUnauthorized.Has(err) {
ul.serveJSONError(ctx, w, http.StatusUnauthorized, err) ul.serveJSONError(ctx, w, http.StatusUnauthorized, err)
@ -137,14 +148,15 @@ func (ul *UsageLimits) TotalUsageReport(w http.ResponseWriter, r *http.Request)
return 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-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment;filename="+fileName) w.Header().Set("Content-Disposition", "attachment;filename="+fileName)
wr := csv.NewWriter(w) 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) err = wr.Write(csvHeaders)
if err != nil { if err != nil {

View File

@ -235,29 +235,48 @@ func Test_TotalUsageReport(t *testing.T) {
err = satelliteSys.DB.ProjectAccounting().SaveTallies(ctx, inFiveMinutes, bucketTallies) err = satelliteSys.DB.ProjectAccounting().SaveTallies(ctx, inFiveMinutes, bucketTallies)
require.NoError(t, err) 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) body, status, err := doRequestWithAuth(ctx, t, satelliteSys, user, http.MethodGet, endpoint, nil)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, status) require.Equal(t, http.StatusOK, status)
content := string(body) reader := csv.NewReader(strings.NewReader(string(body)))
reader := csv.NewReader(strings.NewReader(content))
records, err := reader.ReadAll() records, err := reader.ReadAll()
require.NoError(t, err) require.NoError(t, err)
require.Len(t, records, 3) 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 { for i, header := range expectedHeaders {
require.Equal(t, header, records[0][i]) require.Equal(t, header, records[0][i])
} }
require.Equal(t, project1.PublicID.String(), records[1][0]) require.Equal(t, project1.Name, records[1][0])
require.Equal(t, project2.PublicID.String(), records[2][0]) require.Equal(t, project2.Name, records[2][0])
require.Equal(t, bucketName, records[1][1]) require.Equal(t, project1.PublicID.String(), records[1][1])
require.Equal(t, bucketName, records[2][1]) require.Equal(t, project2.PublicID.String(), records[2][1])
for i := 2; i < 9; i++ { 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[1][i])
require.Equal(t, expectedCSVValue, records[2][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("/{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("/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("/{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) 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() 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 return list, nil
} }
// GetTotalUsageReport retrieves usage rollups for every bucket of all user owned projects for a given period. // GetUsageReport retrieves usage rollups for every bucket of a single or all the user owned projects for a given period.
func (s *Service) GetTotalUsageReport(ctx context.Context, since, before time.Time) ([]accounting.BucketUsageRollup, error) { func (s *Service) GetUsageReport(ctx context.Context, since, before time.Time, projectID uuid.UUID) ([]accounting.ProjectBucketUsageRollup, error) {
var err error var err error
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "get user report") user, err := s.getUserAndAuditLog(ctx, "get usage report")
if err != nil { if err != nil {
return nil, Error.Wrap(err) 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 { if err != nil {
return nil, Error.Wrap(err) 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 { for _, p := range projects {
rollups, err := s.projectAccounting.GetBucketUsageRollups(ctx, p.ID, since, before) 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) return nil, Error.Wrap(err)
} }
for i := range rollups { for _, r := range rollups {
rollups[i].ProjectID = p.PublicID usage = append(usage, accounting.ProjectBucketUsageRollup{
usage = append(usage, rollups[i]) 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 * @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 since = Time.toUnixTimestamp(start).toString();
const before = Time.toUnixTimestamp(end).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. * Handles download usage report click logic.
*/ */
function downloadUsageReport(): void { function downloadUsageReport(): void {
const link = projectsStore.getTotalUsageReportLink(); const link = projectsStore.getUsageReportLink();
Download.fileByLink(link); Download.fileByLink(link);
notify.success('Usage report download started successfully.');
} }
/** /**

View File

@ -51,6 +51,15 @@
<p class="price">{{ centsToDollars(charge.segmentPrice) }}</p> <p class="price">{{ centsToDollars(charge.segmentPrice) }}</p>
</div> </div>
</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> </div>
</template> </template>
</div> </div>
@ -66,6 +75,10 @@ import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
import { useBillingStore } from '@/store/modules/billingStore'; import { useBillingStore } from '@/store/modules/billingStore';
import { useProjectsStore } from '@/store/modules/projectsStore'; 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'; import GreyChevron from '@/../static/images/common/greyChevron.svg';
@ -86,6 +99,8 @@ const props = withDefaults(defineProps<{
const billingStore = useBillingStore(); const billingStore = useBillingStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const notify = useNotify();
/** /**
* isDetailedInfoShown indicates if area with detailed information about project charges is expanded. * 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; 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. * Returns project usage price model from store.
*/ */
@ -325,6 +349,10 @@ function toggleDetailedInfo(): void {
width: 40%; width: 40%;
} }
} }
&__btn {
margin-top: 16px;
}
} }
} }

View File

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

View File

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

View File

@ -63,6 +63,9 @@
</tr> </tr>
</tbody> </tbody>
</v-table> </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-text>
</v-expansion-panel> </v-expansion-panel>
</v-expansion-panels> </v-expansion-panels>
@ -72,6 +75,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { import {
VBtn,
VCard, VCard,
VCol, VCol,
VExpansionPanel, VExpansionPanel,
@ -89,6 +93,8 @@ import { Size } from '@/utils/bytesSize';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
import { useBillingStore } from '@/store/modules/billingStore'; import { useBillingStore } from '@/store/modules/billingStore';
import { useProjectsStore } from '@/store/modules/projectsStore'; 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. * HOURS_IN_MONTH constant shows amount of hours in 30-day month.
@ -107,6 +113,8 @@ const props = withDefaults(defineProps<{
const billingStore = useBillingStore(); const billingStore = useBillingStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const notify = useNotify();
/** /**
* An array of tuples containing the partner name and usage charge for the specified project ID. * 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; 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. * Returns project usage price model from store.
*/ */

View File

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