Browse Source

Measure traffic. (#491)

* Measure traffic.

* Naming fixed.

* Namings.

* Formatting

* Updates

* Unify costs and weight

* Background tracker.

* Renamings.

* Add AppId to response header.
pull/493/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
f587ac3a0e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  2. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  3. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  4. 3
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs
  5. 40
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  6. 6
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  7. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs
  8. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  9. 10
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  10. 1
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs
  11. 6
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  12. 4
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  13. 2
      backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj
  14. 2
      backend/src/Squidex.Infrastructure.Amazon/Squidex.Infrastructure.Amazon.csproj
  15. 2
      backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj
  16. 4
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  17. 16
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  18. 16
      backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs
  19. 26
      backend/src/Squidex.Infrastructure/UsageTracking/ApiStatsSummary.cs
  20. 101
      backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs
  21. 117
      backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  22. 18
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  23. 38
      backend/src/Squidex.Infrastructure/UsageTracking/Counters.cs
  24. 22
      backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs
  25. 8
      backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  26. 29
      backend/src/Squidex.Infrastructure/UsageTracking/Usage.cs
  27. 6
      backend/src/Squidex.Web/ApiCostsAttribute.cs
  28. 1
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  29. 1
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  30. 2
      backend/src/Squidex.Web/IApiCostsFeature.cs
  31. 17
      backend/src/Squidex.Web/IAppFeature.cs
  32. 44
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  33. 11
      backend/src/Squidex.Web/Pipeline/AppFeature.cs
  34. 10
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  35. 5
      backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs
  36. 2
      backend/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs
  37. 102
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  38. 62
      backend/src/Squidex.Web/Pipeline/UsagePipeWriter.cs
  39. 80
      backend/src/Squidex.Web/Pipeline/UsageResponseBodyFeature.cs
  40. 130
      backend/src/Squidex.Web/Pipeline/UsageStream.cs
  41. 4
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs
  42. 19
      backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs
  43. 55
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDtoDto.cs
  44. 25
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs
  45. 22
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CurrentCallsDto.cs
  46. 17
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs
  47. 41
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  48. 2
      backend/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs
  49. 2
      backend/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs
  50. 2
      backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs
  51. 2
      backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs
  52. 3
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  53. 2
      backend/src/Squidex/Config/Web/WebExtensions.cs
  54. 3
      backend/src/Squidex/Config/Web/WebServices.cs
  55. 2
      backend/src/Squidex/Pipeline/Squid/SquidMiddleware.cs
  56. 26
      backend/src/Squidex/Squidex.csproj
  57. 6
      backend/src/Squidex/Startup.cs
  58. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  59. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/ConfigAppLimitsProviderTests.cs
  60. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  61. 4
      backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj
  62. 6
      backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  63. 130
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs
  64. 201
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs
  65. 69
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs
  66. 4
      backend/tests/Squidex.Web.Tests/ApiCostsAttributeTests.cs
  67. 78
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs
  68. 6
      backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs
  69. 163
      backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
  70. 6
      backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj
  71. 2
      backend/tools/Migrate_00/Migrate_00.csproj
  72. 32
      frontend/app/features/dashboard/pages/dashboard-page.component.html
  73. 86
      frontend/app/features/dashboard/pages/dashboard-page.component.ts
  74. 10
      frontend/app/framework/angular/http/http-extensions.ts
  75. 65
      frontend/app/shared/services/usages.service.spec.ts
  76. 67
      frontend/app/shared/services/usages.service.ts

4
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -11,9 +11,9 @@
<PackageReference Include="Algolia.Search" Version="5.3.1" />
<PackageReference Include="Confluent.Kafka" Version="1.3.0" />
<PackageReference Include="CoreTweet" Version="1.0.0.483" />
<PackageReference Include="Datadog.Trace" Version="1.11.0" />
<PackageReference Include="Datadog.Trace" Version="1.13.2" />
<PackageReference Include="Elasticsearch.Net" Version="7.5.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.2" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NodaTime" Version="2.4.7" />

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -16,7 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Freezable.Fody" Version="1.9.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="3.1.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="1.7.0" />

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -17,7 +17,7 @@
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

3
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs

@ -20,7 +20,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1
MaxContributors = -1,
BlockingApiCalls = -1
};
private readonly Dictionary<string, ConfigAppLimitsPlan> plansById = new Dictionary<string, ConfigAppLimitsPlan>(StringComparer.OrdinalIgnoreCase);

40
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
@ -19,51 +18,54 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public partial class AssetUsageTracker : IAssetUsageTracker, IEventConsumer
{
private const string Category = "Default";
private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate;
private readonly IUsageRepository usageStore;
private readonly IUsageTracker usageTracker;
public AssetUsageTracker(IUsageRepository usageStore)
public AssetUsageTracker(IUsageTracker usageTracker)
{
Guard.NotNull(usageStore);
Guard.NotNull(usageTracker);
this.usageStore = usageStore;
this.usageTracker = usageTracker;
}
public async Task<long> GetTotalSizeAsync(Guid appId)
{
var key = GetKey(appId);
var entries = await usageStore.QueryAsync(key, SummaryDate, SummaryDate);
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate);
return (long)entries.Select(x => x.Counters.Get(CounterTotalSize)).FirstOrDefault();
return counters.GetInt64(CounterTotalSize);
}
public async Task<IReadOnlyList<AssetStats>> QueryAsync(Guid appId, DateTime fromDate, DateTime toDate)
{
var enriched = new List<AssetStats>();
var usagesFlat = await usageStore.QueryAsync(GetKey(appId), fromDate, toDate);
var usages = await usageTracker.QueryAsync(GetKey(appId), fromDate, toDate);
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
if (usages.TryGetValue("*", out var byCategory1))
{
var stored = usagesFlat.FirstOrDefault(x => x.Date == date && x.Category == Category);
AddCounters(enriched, byCategory1);
}
else if (usages.TryGetValue("Default", out var byCategory2))
{
AddCounters(enriched, byCategory2);
}
var totalCount = 0L;
var totalSize = 0L;
return enriched;
}
if (stored != null)
private static void AddCounters(List<AssetStats> enriched, List<(DateTime, Counters)> details)
{
totalCount = (long)stored.Counters.Get(CounterTotalCount);
totalSize = (long)stored.Counters.Get(CounterTotalSize);
}
foreach (var (date, counters) in details)
{
var totalCount = counters.GetInt64(CounterTotalCount);
var totalSize = counters.GetInt64(CounterTotalSize);
enriched.Add(new AssetStats(date, totalCount, totalSize));
}
return enriched;
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -65,11 +65,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
[CounterTotalCount] = count
};
var key = GetKey(appId);
var appKey = GetKey(appId);
return Task.WhenAll(
usageStore.TrackUsagesAsync(new UsageUpdate(date, key, Category, counters)),
usageStore.TrackUsagesAsync(new UsageUpdate(SummaryDate, key, Category, counters)));
usageTracker.TrackAsync(date, appKey, null, counters),
usageTracker.TrackAsync(SummaryDate, appKey, null, counters));
}
private static string GetKey(Guid appId)

1
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs

@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;

1
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;

10
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs

@ -24,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
public sealed class UsageTrackerGrain : GrainOfString, IRemindable, IUsageTrackerGrain
{
private readonly IGrainState<GrainState> state;
private readonly IUsageTracker usageTracker;
private readonly IApiUsageTracker usageTracker;
public sealed class Target
{
@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
public Dictionary<Guid, Target> Targets { get; set; } = new Dictionary<Guid, Target>();
}
public UsageTrackerGrain(IGrainState<GrainState> state, IUsageTracker usageTracker)
public UsageTrackerGrain(IGrainState<GrainState> state, IApiUsageTracker usageTracker)
{
Guard.NotNull(state);
Guard.NotNull(usageTracker);
@ -83,18 +83,18 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
if (!target.Triggered.HasValue || target.Triggered < from)
{
var usage = await usageTracker.GetMonthlyCallsAsync(target.AppId.Id.ToString(), today);
var costs = await usageTracker.GetMonthCostsAsync(target.AppId.Id.ToString(), today);
var limit = target.Limits;
if (usage > limit)
if (costs > limit)
{
target.Triggered = today;
var @event = new AppUsageExceeded
{
AppId = target.AppId,
CallsCurrent = usage,
CallsCurrent = costs,
CallsLimit = limit,
RuleId = key
};

1
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Shared;

6
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -16,7 +16,7 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="14.0.0" />
<PackageReference Include="CsvHelper" Version="15.0.0" />
<PackageReference Include="Elasticsearch.Net" Version="7.5.1" />
<PackageReference Include="Equals.Fody" Version="1.9.5" />
<PackageReference Include="Fody" Version="4.2.1">
@ -28,11 +28,11 @@
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.Queries" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00005" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.0.2">
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="3.0.2" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.0" />
<PackageReference Include="NodaTime" Version="2.4.7" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

4
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -18,9 +18,9 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="IdentityServer4" Version="3.1.0" />
<PackageReference Include="IdentityServer4" Version="3.1.2" />
<PackageReference Include="Microsoft.Win32.Registry" Version="4.7.0" />
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="4.7.0" />

2
backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj

@ -16,7 +16,7 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="3.1.2" />
<PackageReference Include="Microsoft.Win32.Registry" Version="4.7.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="SharpPwned.NET" Version="1.0.8" />

2
backend/src/Squidex.Infrastructure.Amazon/Squidex.Infrastructure.Amazon.csproj

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.3.110.19" />
<PackageReference Include="AWSSDK.S3" Version="3.3.110.29" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>

2
backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj

@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.DocumentDB.ChangeFeedProcessor" Version="2.2.8" />
<PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.9.3" />
<PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.10.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3" />

4
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -13,8 +13,8 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.10.1" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.10.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="4.11.0" />

16
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -10,23 +10,23 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Equals.Fody" Version="1.9.5" />
<PackageReference Include="FluentFTP" Version="29.0.4" />
<PackageReference Include="FluentFTP" Version="31.3.1" />
<PackageReference Include="Fody" Version="4.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="McMaster.NETCore.Plugins" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.2" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="3.1.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="3.1.2" />
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="3.1.2" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.0.2">
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="3.0.2" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.0.2" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.0" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NJsonSchema" Version="10.1.5" />
<PackageReference Include="NodaTime" Version="2.4.7" />
@ -39,7 +39,7 @@
<PackageReference Include="System.Reactive" Version="4.3.2" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
<PackageReference Include="System.Text.Json" Version="4.7.0" />
<PackageReference Include="System.Text.Json" Version="4.7.1" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="4.11.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>

16
backend/src/Squidex.Infrastructure/UsageTracking/DateUsage.cs → backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs

@ -9,20 +9,24 @@ using System;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class DateUsage
public sealed class ApiStats
{
public DateTime Date { get; }
public long TotalCount { get; }
public long TotalCalls { get; }
public long TotalElapsedMs { get; }
public long TotalBytes { get; }
public DateUsage(DateTime date, long totalCount, long totalElapsedMs)
public double AverageElapsedMs { get; }
public ApiStats(DateTime date, long totalCalls, double averageElapsedMs, long totalBytes)
{
Date = date;
TotalCount = totalCount;
TotalElapsedMs = totalElapsedMs;
TotalCalls = totalCalls;
TotalBytes = totalBytes;
AverageElapsedMs = averageElapsedMs;
}
}
}

26
backend/src/Squidex.Infrastructure/UsageTracking/ApiStatsSummary.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class ApiStatsSummary
{
public long TotalCalls { get; }
public long TotalBytes { get; }
public double AverageElapsedMs { get; }
public ApiStatsSummary(long totalCalls, double averageElapsedMs, long totalBytes)
{
TotalCalls = totalCalls;
TotalBytes = totalBytes;
AverageElapsedMs = averageElapsedMs;
}
}
}

101
backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs

@ -0,0 +1,101 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class ApiUsageTracker : IApiUsageTracker
{
public const string CounterTotalBytes = "TotalBytes";
public const string CounterTotalCalls = "TotalCalls";
public const string CounterTotalElapsedMs = "TotalElapsedMs";
private readonly IUsageTracker usageTracker;
public ApiUsageTracker(IUsageTracker usageTracker)
{
this.usageTracker = usageTracker;
}
public async Task<long> GetMonthCostsAsync(string key, DateTime date)
{
var apiKey = GetKey(key);
var counters = await usageTracker.GetForMonthAsync(apiKey, date);
return counters.GetInt64(CounterTotalCalls);
}
public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes)
{
var apiKey = GetKey(key);
var counters = new Counters
{
[CounterTotalCalls] = weight,
[CounterTotalElapsedMs] = elapsedMs,
[CounterTotalBytes] = bytes
};
return usageTracker.TrackAsync(date, apiKey, category, counters);
}
public async Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
var apiKey = GetKey(key);
var queries = await usageTracker.QueryAsync(apiKey, fromDate, toDate);
var details = new Dictionary<string, List<ApiStats>>();
var summaryBytes = 0L;
var summaryCalls = 0L;
var summaryElapsed = 0L;
foreach (var (category, usages) in queries)
{
var resultByCategory = new List<ApiStats>();
foreach (var (date, counters) in usages)
{
var dateBytes = counters.GetInt64(CounterTotalBytes);
var dateCalls = counters.GetInt64(CounterTotalCalls);
var dateElapsed = counters.GetInt64(CounterTotalElapsedMs);
var dateElapsedAvg = CalculateAverage(dateCalls, dateElapsed);
resultByCategory.Add(new ApiStats(date, dateCalls, dateElapsedAvg, dateBytes));
summaryBytes += dateBytes;
summaryCalls += dateCalls;
summaryElapsed += dateElapsed;
}
details[category] = resultByCategory;
}
var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed);
var summary = new ApiStatsSummary(summaryCalls, summaryElapsedAvg, summaryBytes);
return (summary, details);
}
private static double CalculateAverage(long calls, long elapsed)
{
return calls > 0 ? Math.Round((double)elapsed / calls, 2) : 0;
}
private static string GetKey(string key)
{
Guard.NotNullOrEmpty(key);
return $"{key}_API";
}
}
}

117
backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -20,32 +20,12 @@ namespace Squidex.Infrastructure.UsageTracking
{
public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
{
public const string CounterTotalCalls = "TotalCalls";
public const string CounterTotalElapsedMs = "TotalElapsedMs";
public const string FallbackCategory = "*";
private const int Intervall = 60 * 1000;
private const string FallbackCategory = "*";
private readonly IUsageRepository usageRepository;
private readonly ISemanticLog log;
private readonly CompletionTimer timer;
private ConcurrentDictionary<(string Key, string Category), Usage> usages = new ConcurrentDictionary<(string Key, string Category), Usage>();
private struct Usage
{
public readonly double Count;
public readonly double ElapsedMs;
public Usage(double elapsed, double count)
{
ElapsedMs = elapsed;
Count = count;
}
public Usage Add(double elapsed, double weight)
{
return new Usage(ElapsedMs + elapsed, Count + weight);
}
}
private ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters> jobs = new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>();
public BackgroundUsageTracker(IUsageRepository usageRepository, ISemanticLog log)
{
@ -78,9 +58,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
try
{
var today = DateTime.Today;
var localUsages = Interlocked.Exchange(ref usages, new ConcurrentDictionary<(string Key, string Category), Usage>());
var localUsages = Interlocked.Exchange(ref jobs, new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>());
if (localUsages.Count > 0)
{
@ -89,16 +67,10 @@ namespace Squidex.Infrastructure.UsageTracking
foreach (var (key, value) in localUsages)
{
var counters = new Counters
{
[CounterTotalCalls] = value.Count,
[CounterTotalElapsedMs] = value.ElapsedMs
};
updates[updateIndex].Key = key.Key;
updates[updateIndex].Category = key.Category;
updates[updateIndex].Counters = counters;
updates[updateIndex].Date = today;
updates[updateIndex].Counters = value;
updates[updateIndex].Date = key.Date;
updateIndex++;
}
@ -114,101 +86,90 @@ namespace Squidex.Infrastructure.UsageTracking
}
}
public Task TrackAsync(string key, string? category, double weight, double elapsedMs)
public Task TrackAsync(DateTime date, string key, string? category, Counters counters)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
Guard.NotNull(counters);
ThrowIfDisposed();
if (weight > 0)
{
category = GetCategory(category);
usages.AddOrUpdate((key, category), _ => new Usage(elapsedMs, weight), (k, x) => x.Add(elapsedMs, weight));
}
jobs.AddOrUpdate((key, category, date), counters, (k, p) => p.SumUp(counters));
return Task.CompletedTask;
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
public async Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
ThrowIfDisposed();
var usagesFlat = await usageRepository.QueryAsync(key, fromDate, toDate);
var usagesByCategory = usagesFlat.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList());
var usages = await usageRepository.QueryAsync(key, fromDate, toDate);
var result = new Dictionary<string, IReadOnlyList<DateUsage>>();
var result = new Dictionary<string, List<(DateTime Date, Counters Counters)>>();
if (usagesByCategory.Count == 0)
var categories = usages.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList());
if (categories.Keys.Count == 0)
{
var enriched = new List<DateUsage>();
var enriched = new List<(DateTime Date, Counters Counters)>();
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
enriched.Add(new DateUsage(date, 0, 0));
enriched.Add((date, new Counters()));
}
result[FallbackCategory] = enriched;
}
else
{
foreach (var category in usagesByCategory.Keys)
{
var enriched = new List<DateUsage>();
var usagesDictionary = usagesByCategory[category].ToDictionary(x => x.Date);
foreach (var (category, value) in categories)
{
var enriched = new List<(DateTime Date, Counters Counters)>();
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
var stored = usagesDictionary.GetOrDefault(date);
var totalCount = 0L;
var totalElapsedMs = 0L;
var counters = value.FirstOrDefault(x => x.Date == date)?.Counters;
if (stored != null)
{
totalCount = (long)stored.Counters.Get(CounterTotalCalls);
totalElapsedMs = (long)stored.Counters.Get(CounterTotalElapsedMs);
}
enriched.Add(new DateUsage(date, totalCount, totalElapsedMs));
enriched.Add((date, counters ?? new Counters()));
}
result[category] = enriched;
}
}
return result;
}
public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
public Task<Counters> GetForMonthAsync(string key, DateTime date)
{
return GetPreviousCallsAsync(key, new DateTime(date.Year, date.Month, 1), date);
var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1);
return GetAsync(key, dateFrom, dateTo);
}
public async Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
public async Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
ThrowIfDisposed();
var originalUsages = await usageRepository.QueryAsync(key, fromDate, toDate);
var queried = await usageRepository.QueryAsync(key, fromDate, toDate);
return originalUsages.Sum(x => (long)x.Counters.Get(CounterTotalCalls));
}
var result = new Counters();
private static string GetCategory(string? category)
foreach (var usage in queried)
{
return !string.IsNullOrWhiteSpace(category) ? category.Trim() : FallbackCategory;
result.SumUp(usage.Counters);
}
private static string GetKey(string key)
{
Guard.NotNull(key);
return result;
}
return $"{key}_API";
private static string GetCategory(string? category)
{
return !string.IsNullOrWhiteSpace(category) ? category.Trim() : FallbackCategory;
}
}
}

18
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -26,45 +26,45 @@ namespace Squidex.Infrastructure.UsageTracking
this.inner = inner;
}
public Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
public Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
Guard.NotNull(key);
return inner.QueryAsync(key, fromDate, toDate);
}
public Task TrackAsync(string key, string? category, double weight, double elapsedMs)
public Task TrackAsync(DateTime date, string key, string? category, Counters counters)
{
Guard.NotNull(key);
return inner.TrackAsync(key, category, weight, elapsedMs);
return inner.TrackAsync(date, key, category, counters);
}
public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
public Task<Counters> GetForMonthAsync(string key, DateTime date)
{
Guard.NotNull(key);
var cacheKey = string.Join("$", "Usage", nameof(GetMonthlyCallsAsync), key, date);
var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetMonthlyCallsAsync(key, date);
return inner.GetForMonthAsync(key, date);
});
}
public Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
public Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate)
{
Guard.NotNull(key);
var cacheKey = string.Join("$", "Usage", nameof(GetPreviousCallsAsync), key, fromDate, toDate);
var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetPreviousCallsAsync(key, fromDate, toDate);
return inner.GetAsync(key, fromDate, toDate);
});
}
}

38
backend/src/Squidex.Infrastructure/UsageTracking/Counters.cs

@ -11,6 +11,15 @@ namespace Squidex.Infrastructure.UsageTracking
{
public sealed class Counters : Dictionary<string, double>
{
public Counters()
{
}
public Counters(Counters source)
: base(source)
{
}
public double Get(string name)
{
if (name == null)
@ -22,5 +31,34 @@ namespace Squidex.Infrastructure.UsageTracking
return value;
}
public long GetInt64(string name)
{
if (name == null)
{
return 0;
}
TryGetValue(name, out var value);
return (long)value;
}
public Counters SumUp(Counters counters)
{
foreach (var (key, value) in counters)
{
var newValue = value;
if (TryGetValue(key, out var temp))
{
newValue += temp;
}
this[key] = newValue;
}
return this;
}
}
}

22
backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.UsageTracking
{
public interface IApiUsageTracker
{
Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes);
Task<long> GetMonthCostsAsync(string key, DateTime date);
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate);
}
}

8
backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs

@ -13,12 +13,12 @@ namespace Squidex.Infrastructure.UsageTracking
{
public interface IUsageTracker
{
Task TrackAsync(string key, string? category, double weight, double elapsedMs);
Task TrackAsync(DateTime date, string key, string? category, Counters counters);
Task<long> GetMonthlyCallsAsync(string key, DateTime date);
Task<Counters> GetForMonthAsync(string key, DateTime date);
Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate);
Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate);
Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
}
}

29
backend/src/Squidex.Infrastructure/UsageTracking/Usage.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1401 // Fields must be private
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class Usage
{
public readonly double Count;
public readonly double ElapsedMs;
public Usage(double elapsed, double count)
{
ElapsedMs = elapsed;
Count = count;
}
public Usage Add(double elapsed, double weight)
{
return new Usage(ElapsedMs + elapsed, Count + weight);
}
}
}

6
backend/src/Squidex.Web/ApiCostsAttribute.cs

@ -14,12 +14,12 @@ namespace Squidex.Web
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class ApiCostsAttribute : ServiceFilterAttribute, IApiCostsFeature
{
public double Weight { get; }
public double Costs { get; }
public ApiCostsAttribute(double weight)
public ApiCostsAttribute(double costs)
: base(typeof(ApiCostsFilter))
{
Weight = weight;
Costs = costs;
}
}
}

1
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs

@ -8,6 +8,7 @@
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;

1
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure;

2
backend/src/Squidex.Web/IApiCostsFeature.cs

@ -9,6 +9,6 @@ namespace Squidex.Web
{
public interface IApiCostsFeature
{
double Weight { get; }
double Costs { get; }
}
}

17
backend/src/Squidex.Web/IAppFeature.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Web
{
public interface IAppFeature
{
NamedId<Guid> AppId { get; }
}
}

44
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -9,12 +9,9 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
@ -22,23 +19,16 @@ namespace Squidex.Web.Pipeline
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{
private readonly IAppPlansProvider appPlansProvider;
private readonly IAppLogStore appLogStore;
private readonly IUsageTracker usageTracker;
private readonly IClock clock;
private readonly IApiUsageTracker usageTracker;
public ApiCostsFilter(IAppLogStore appLogStore, IAppPlansProvider appPlansProvider, IUsageTracker usageTracker, IClock clock)
public ApiCostsFilter(IAppPlansProvider appPlansProvider, IApiUsageTracker usageTracker)
{
Guard.NotNull(appLogStore);
Guard.NotNull(appPlansProvider);
Guard.NotNull(usageTracker);
Guard.NotNull(clock);
this.appLogStore = appLogStore;
this.appPlansProvider = appPlansProvider;
this.usageTracker = usageTracker;
this.clock = clock;
}
IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@ -65,13 +55,13 @@ namespace Squidex.Web.Pipeline
{
var appId = app.Id.ToString();
if (FilterDefinition.Weight > 0)
if (FilterDefinition.Costs > 0)
{
using (Profiler.Trace("CheckUsage"))
{
var (plan, _) = appPlansProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCallsAsync(appId, DateTime.Today);
var usage = await usageTracker.GetMonthCostsAsync(appId, DateTime.Today);
if (plan.BlockingApiCalls >= 0 && usage > plan.BlockingApiCalls)
{
@ -80,35 +70,9 @@ namespace Squidex.Web.Pipeline
}
}
}
var watch = ValueStopwatch.StartNew();
try
{
await next();
}
finally
{
var elapsedMs = watch.Stop();
await appLogStore.LogAsync(app.Id, clock.GetCurrentInstant(),
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.User.OpenIdSubject(),
context.HttpContext.User.OpenIdClientId(),
elapsedMs,
FilterDefinition.Weight);
if (FilterDefinition.Weight > 0)
{
await usageTracker.TrackAsync(appId, context.HttpContext.User.OpenIdClientId(), FilterDefinition.Weight, elapsedMs);
}
}
}
else
{
await next();
}
}
}
}

11
backend/src/Squidex.Domain.Apps.Entities/DomainEntityExtensions.cs → backend/src/Squidex.Web/Pipeline/AppFeature.cs

@ -6,16 +6,17 @@
// ==========================================================================
using System;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
namespace Squidex.Web.Pipeline
{
public static class DomainEntityExtensions
public sealed class AppFeature : IAppFeature
{
public static NamedId<Guid> NamedId(this IAppEntity entity)
public NamedId<Guid> AppId { get; }
public AppFeature(NamedId<Guid> appId)
{
return new NamedId<Guid>(entity.Id, entity.Name);
AppId = appId;
}
}
}

10
backend/src/Squidex.Web/Pipeline/AppResolver.cs

@ -74,16 +74,24 @@ namespace Squidex.Web.Pipeline
requestContext.App = app;
requestContext.UpdatePermissions();
if (!requestContext.Permissions.Includes(Permissions.ForApp(Permissions.App, appName)) && !AllowAnonymous(context))
if (!AllowAnonymous(context) && !HasPermission(appName, requestContext))
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set<IAppFeature>(new AppFeature(app.NamedId()));
context.HttpContext.Response.Headers.Add("X-AppId", app.Id.ToString());
}
await next();
}
private static bool HasPermission(string appName, Context requestContext)
{
return requestContext.Permissions.Includes(Permissions.ForApp(Permissions.App, appName));
}
private static bool AllowAnonymous(ActionExecutingContext context)
{
return context.ActionDescriptor.EndpointMetadata.Any(x => x is AllowAnonymousAttribute);

5
backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs

@ -7,7 +7,6 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure;
namespace Squidex.Web.Pipeline
{
@ -17,12 +16,10 @@ namespace Squidex.Web.Pipeline
public CleanupHostMiddleware(RequestDelegate next)
{
Guard.NotNull(next);
this.next = next;
}
public Task Invoke(HttpContext context)
public Task InvokeAsync(HttpContext context)
{
var request = context.Request;

2
backend/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs

@ -84,7 +84,7 @@ namespace Squidex.Web.Pipeline
c.WriteProperty(nameof(clientId), clientId);
}
var costs = httpContext.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
var costs = httpContext.Features.Get<IApiCostsFeature>()?.Costs ?? 0;
c.WriteProperty(nameof(costs), costs);
}

102
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -0,0 +1,102 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
{
public sealed class UsageMiddleware : IMiddleware
{
private readonly IAppLogStore log;
private readonly IApiUsageTracker usageTracker;
private readonly IClock clock;
public UsageMiddleware(IAppLogStore log, IApiUsageTracker usageTracker, IClock clock)
{
Guard.NotNull(log);
Guard.NotNull(usageTracker);
Guard.NotNull(clock);
this.log = log;
this.usageTracker = usageTracker;
this.clock = clock;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var usageBody = SetUsageBody(context);
var watch = ValueStopwatch.StartNew();
try
{
await next(context);
}
finally
{
if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests)
{
var appId = context.Features.Get<IAppFeature>()?.AppId;
var costs = context.Features.Get<IApiCostsFeature>()?.Costs ?? 0;
if (appId != null)
{
var elapsedMs = watch.Stop();
var now = clock.GetCurrentInstant();
var userId = context.User.OpenIdSubject();
var userClient = context.User.OpenIdClientId();
await log.LogAsync(appId.Id, now,
context.Request.Method,
context.Request.Path,
userId,
userClient,
elapsedMs,
costs);
if (costs > 0)
{
var bytes = usageBody.BytesWritten;
if (context.Request.ContentLength != null)
{
bytes += context.Request.ContentLength.Value;
}
var date = now.ToDateTimeUtc().Date;
await usageTracker.TrackAsync(date, appId.Id.ToString(), userClient, costs, elapsedMs, bytes);
}
}
}
}
}
private static UsageResponseBodyFeature SetUsageBody(HttpContext context)
{
var originalBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
var usageBody = new UsageResponseBodyFeature(originalBodyFeature);
context.Features.Set<IHttpResponseBodyFeature>(usageBody);
return usageBody;
}
}
}

62
backend/src/Squidex.Web/Pipeline/UsagePipeWriter.cs

@ -0,0 +1,62 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Web.Pipeline
{
public sealed class UsagePipeWriter : PipeWriter
{
private readonly PipeWriter inner;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten; }
}
public UsagePipeWriter(PipeWriter inner)
{
this.inner = inner;
}
public override void Advance(int bytes)
{
inner.Advance(bytes);
bytesWritten += bytes;
}
public override void CancelPendingFlush()
{
inner.CancelPendingFlush();
}
public override void Complete(Exception? exception = null)
{
inner.Complete();
}
public override ValueTask<FlushResult> FlushAsync(CancellationToken cancellationToken = default)
{
return inner.FlushAsync(cancellationToken);
}
public override Memory<byte> GetMemory(int sizeHint = 0)
{
return inner.GetMemory(sizeHint);
}
public override Span<byte> GetSpan(int sizeHint = 0)
{
return inner.GetSpan(sizeHint);
}
}
}

80
backend/src/Squidex.Web/Pipeline/UsageResponseBodyFeature.cs

@ -0,0 +1,80 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http.Features;
namespace Squidex.Web.Pipeline
{
internal sealed class UsageResponseBodyFeature : IHttpResponseBodyFeature
{
private readonly IHttpResponseBodyFeature inner;
private readonly UsageStream usageStream;
private readonly UsagePipeWriter usageWriter;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten + usageStream.BytesWritten + usageWriter.BytesWritten; }
}
public Stream Stream
{
get { return usageStream; }
}
public PipeWriter Writer
{
get { return usageWriter; }
}
public UsageResponseBodyFeature(IHttpResponseBodyFeature inner)
{
usageStream = new UsageStream(inner.Stream);
usageWriter = new UsagePipeWriter(inner.Writer);
this.inner = inner;
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
return inner.StartAsync(cancellationToken);
}
public Task CompleteAsync()
{
return inner.CompleteAsync();
}
public void DisableBuffering()
{
inner.DisableBuffering();
}
public async Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default)
{
await inner.SendFileAsync(path, offset, count, cancellationToken);
if (count != null)
{
bytesWritten += count.Value;
}
else
{
var file = new FileInfo(path);
if (file.Exists)
{
bytesWritten += file.Length;
}
}
}
}
}

130
backend/src/Squidex.Web/Pipeline/UsageStream.cs

@ -0,0 +1,130 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Web.Pipeline
{
internal sealed class UsageStream : Stream
{
private readonly Stream inner;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten; }
}
public override bool CanRead
{
get { return false; }
}
public override bool CanSeek
{
get { return false; }
}
public override bool CanWrite
{
get { return inner.CanWrite; }
}
public override long Length
{
get { throw new NotSupportedException(); }
}
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public UsageStream(Stream inner)
{
this.inner = inner;
}
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object? state)
{
var result = inner.BeginWrite(buffer, offset, count, callback, state);
bytesWritten += count;
return result;
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await base.WriteAsync(buffer, offset, count, cancellationToken);
bytesWritten += count;
}
public override void Write(byte[] buffer, int offset, int count)
{
inner.Write(buffer, offset, count);
bytesWritten += count;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await base.WriteAsync(buffer, cancellationToken);
bytesWritten += buffer.Length;
}
public override void Write(ReadOnlySpan<byte> buffer)
{
inner.Write(buffer);
bytesWritten += buffer.Length;
}
public override void WriteByte(byte value)
{
inner.WriteByte(value);
bytesWritten++;
}
public override Task FlushAsync(CancellationToken cancellationToken)
{
return inner.FlushAsync(cancellationToken);
}
public override void Flush()
{
inner.Flush();
}
public override void EndWrite(IAsyncResult asyncResult)
{
inner.EndWrite(asyncResult);
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
}
}

4
backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs

@ -52,7 +52,9 @@ namespace Squidex.Areas.Api.Controllers.Comments.Models
public static CommentDto FromCommand(CreateComment command)
{
return SimpleMapper.Map(command, new CommentDto { Id = command.CommentId, User = command.Actor, Time = SystemClock.Instance.GetCurrentInstant() });
var time = SystemClock.Instance.GetCurrentInstant();
return SimpleMapper.Map(command, new CommentDto { Id = command.CommentId, User = command.Actor, Time = time });
}
}
}

19
backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs

@ -18,9 +18,9 @@ namespace Squidex.Areas.Api.Controllers.News.Service
{
private const int FeatureVersion = 8;
private readonly QueryContext flatten = QueryContext.Default.Flatten();
private readonly SquidexClient<NewsEntity, FeatureDto> client;
private readonly IContentsClient<NewsEntity, FeatureDto> client;
public sealed class NewsEntity : SquidexEntityBase<FeatureDto>
public sealed class NewsEntity : Content<FeatureDto>
{
}
@ -28,12 +28,17 @@ namespace Squidex.Areas.Api.Controllers.News.Service
{
if (options.Value.IsConfigured())
{
var clientManager = new SquidexClientManager("https://cloud.squidex.io",
options.Value.AppName,
options.Value.ClientId,
options.Value.ClientSecret);
var squidexOptions = new SquidexOptions
{
AppName = options.Value.AppName,
ClientId = options.Value.ClientId,
ClientSecret = options.Value.ClientSecret,
Url = "https://cloud.squidex.io"
};
var clientManager = new SquidexClientManager(squidexOptions);
client = clientManager.GetClient<NewsEntity, FeatureDto>("feature-news");
client = clientManager.CreateContentsClient<NewsEntity, FeatureDto>("feature-news");
}
}

55
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDtoDto.cs

@ -0,0 +1,55 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class CallsUsageDtoDto
{
/// <summary>
/// The total number of API calls.
/// </summary>
public long TotalCalls { get; set; }
/// <summary>
/// The total number of bytes transferred.
/// </summary>
public long TotalBytes { get; set; }
/// <summary>
/// The allowed API calls.
/// </summary>
public long AllowedCalls { get; set; }
/// <summary>
/// The average duration in milliseconds.
/// </summary>
public double AverageElapsedMs { get; set; }
/// <summary>
/// The statistics by date and group.
/// </summary>
[Required]
public Dictionary<string, CallsUsagePerDateDto[]> Details { get; set; }
public static CallsUsageDtoDto FromStats(long allowedCalls, ApiStatsSummary summary, Dictionary<string, List<ApiStats>> details)
{
return new CallsUsageDtoDto
{
AllowedCalls = allowedCalls,
AverageElapsedMs = summary.AverageElapsedMs,
TotalBytes = summary.TotalBytes,
TotalCalls = summary.TotalCalls,
Details = details.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsagePerDateDto.FromStats).ToArray())
};
}
}
}

25
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs → backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class CallsUsageDto
public sealed class CallsUsagePerDateDto
{
/// <summary>
/// The date when the usage was tracked.
@ -18,20 +18,31 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models
public DateTime Date { get; set; }
/// <summary>
/// The number of calls.
/// The total number of API calls.
/// </summary>
public long Count { get; set; }
public long TotalCalls { get; set; }
/// <summary>
/// The total number of bytes transferred.
/// </summary>
public long TotalBytes { get; set; }
/// <summary>
/// The average duration in milliseconds.
/// </summary>
public long AverageMs { get; set; }
public double AverageElapsedMs { get; set; }
public static CallsUsageDto FromUsage(DateUsage usage)
public static CallsUsagePerDateDto FromStats(ApiStats stats)
{
var result = new CallsUsagePerDateDto
{
var averageMs = usage.TotalCount == 0 ? 0 : usage.TotalElapsedMs / usage.TotalCount;
Date = stats.Date,
TotalBytes = stats.TotalBytes,
TotalCalls = stats.TotalCalls,
AverageElapsedMs = stats.AverageElapsedMs,
};
return new CallsUsageDto { Date = usage.Date, Count = usage.TotalCount, AverageMs = averageMs };
return result;
}
}
}

22
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CurrentCallsDto.cs

@ -1,22 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class CurrentCallsDto
{
/// <summary>
/// The number of calls.
/// </summary>
public long Count { get; set; }
/// <summary>
/// The number of maximum allowed calls.
/// </summary>
public long MaxAllowed { get; set; }
}
}

17
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsageDto.cs → backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs

@ -10,7 +10,7 @@ using Squidex.Domain.Apps.Entities.Assets;
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class StorageUsageDto
public sealed class StorageUsagePerDateDto
{
/// <summary>
/// The date when the usage was tracked.
@ -20,16 +20,23 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models
/// <summary>
/// The number of assets.
/// </summary>
public long Count { get; set; }
public long TotalCount { get; set; }
/// <summary>
/// The size in bytes.
/// </summary>
public long Size { get; set; }
public long TotalSize { get; set; }
public static StorageUsageDto FromStats(AssetStats stats)
public static StorageUsagePerDateDto FromStats(AssetStats stats)
{
return new StorageUsageDto { Date = stats.Date, Count = stats.TotalCount, Size = stats.TotalSize };
var result = new StorageUsagePerDateDto
{
Date = stats.Date,
TotalCount = stats.TotalCount,
TotalSize = stats.TotalSize
};
return result;
}
}
}

41
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.DataProtection;
@ -29,7 +28,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
[ApiExplorerSettings(GroupName = nameof(Statistics))]
public sealed class UsagesController : ApiController
{
private readonly IUsageTracker usageTracker;
private readonly IApiUsageTracker usageTracker;
private readonly IAppLogStore appLogStore;
private readonly IAppPlansProvider appPlansProvider;
private readonly IAssetUsageTracker assetStatsRepository;
@ -38,7 +37,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
public UsagesController(
ICommandBus commandBus,
IUsageTracker usageTracker,
IApiUsageTracker usageTracker,
IAppLogStore appLogStore,
IAppPlansProvider appPlansProvider,
IAssetUsageTracker assetStatsRepository,
@ -80,30 +79,6 @@ namespace Squidex.Areas.Api.Controllers.Statistics
return Ok(response);
}
/// <summary>
/// Get api calls for this month.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Usage tracking results returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/calls/month/")]
[ProducesResponseType(typeof(CurrentCallsDto), 200)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> GetMonthlyCalls(string app)
{
var count = await usageTracker.GetMonthlyCallsAsync(AppId.ToString(), DateTime.Today);
var (plan, _) = appPlansProvider.GetPlanForApp(App);
var response = new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls };
return Ok(response);
}
/// <summary>
/// Get api calls in date range.
/// </summary>
@ -117,7 +92,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(Dictionary<string, CallsUsageDto[]>), 200)]
[ProducesResponseType(typeof(CallsUsageDtoDto), 200)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> GetUsages(string app, DateTime fromDate, DateTime toDate)
@ -127,9 +102,11 @@ namespace Squidex.Areas.Api.Controllers.Statistics
return BadRequest();
}
var usages = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date);
var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date);
var (plan, _) = appPlansProvider.GetPlanForApp(App);
var response = usages.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsageDto.FromUsage).ToArray());
var response = CallsUsageDtoDto.FromStats(plan.MaxApiCalls, summary, details);
return Ok(response);
}
@ -171,7 +148,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/storage/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(StorageUsageDto[]), 200)]
[ProducesResponseType(typeof(StorageUsagePerDateDto[]), 200)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> GetStorageSizes(string app, DateTime fromDate, DateTime toDate)
@ -183,7 +160,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var usages = await assetStatsRepository.QueryAsync(AppId, fromDate.Date, toDate.Date);
var models = usages.Select(StorageUsageDto.FromStats).ToArray();
var models = usages.Select(StorageUsagePerDateDto.FromStats).ToArray();
return Ok(models);
}

2
backend/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs

@ -21,7 +21,7 @@ namespace Squidex.Areas.Frontend.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
if (context.IsHtmlPath() && context.Response.StatusCode != 304)
{

2
backend/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs

@ -24,7 +24,7 @@ namespace Squidex.Areas.Frontend.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
if (context.IsIndex() && context.Response.StatusCode != 304)
{

2
backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs

@ -27,7 +27,7 @@ namespace Squidex.Areas.OrleansDashboard.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var authentication = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

2
backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs

@ -22,7 +22,7 @@ namespace Squidex.Areas.Portal.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var authentication = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

3
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -71,6 +71,9 @@ namespace Squidex.Config.Domain
c.GetRequiredService<IMemoryCache>()))
.As<IUsageTracker>();
services.AddSingletonAs<ApiUsageTracker>()
.As<IApiUsageTracker>();
services.AddSingletonAs<BackgroundUsageTracker>()
.AsSelf();

2
backend/src/Squidex/Config/Web/WebExtensions.cs

@ -36,6 +36,7 @@ namespace Squidex.Config.Web
public static IApplicationBuilder UseSquidexTracking(this IApplicationBuilder app)
{
app.UseMiddleware<RequestExceptionMiddleware>();
app.UseMiddleware<UsageMiddleware>();
app.UseMiddleware<RequestLogPerformanceMiddleware>();
return app;
@ -113,6 +114,7 @@ namespace Squidex.Config.Web
app.UseForwardedHeaders(GetForwardingOptions(config));
app.UseMiddleware<EnforceHttpsMiddleware>();
app.UseMiddleware<CleanupHostMiddleware>();
}

3
backend/src/Squidex/Config/Web/WebServices.cs

@ -46,6 +46,9 @@ namespace Squidex.Config.Web
services.AddSingletonAs<LocalCacheMiddleware>()
.AsSelf();
services.AddSingletonAs<UsageMiddleware>()
.AsSelf();
services.AddSingletonAs<RequestExceptionMiddleware>()
.AsSelf();

2
backend/src/Squidex/Pipeline/Squid/SquidMiddleware.cs

@ -26,7 +26,7 @@ namespace Squidex.Pipeline.Squid
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var request = context.Request;

26
backend/src/Squidex/Squidex.csproj

@ -36,28 +36,28 @@
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="3.0.0" />
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.1.0.23" />
<PackageReference Include="IdentityServer4" Version="3.1.0" />
<PackageReference Include="IdentityServer4" Version="3.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.2" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="1.1.0" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.4" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.0.2" />
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="3.0.2" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.0.2" />
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
<PackageReference Include="Namotion.Reflection" Version="1.0.8" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.0" />
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="3.1.0" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.0" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="Namotion.Reflection" Version="1.0.10" />
<PackageReference Include="NJsonSchema" Version="10.1.5" />
<PackageReference Include="NSwag.AspNetCore" Version="13.2.2" />
<PackageReference Include="NSwag.AspNetCore" Version="13.2.3" />
<PackageReference Include="OpenCover" Version="4.7.922" PrivateAssets="all" />
<PackageReference Include="Orleans.Providers.MongoDB" Version="3.1.1" />
<PackageReference Include="OrleansDashboard" Version="3.0.8" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="4.4.6" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="4.1.1" />
<PackageReference Include="ReportGenerator" Version="4.5.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="4.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq" Version="4.3.0" />
<PackageReference Include="System.Runtime" Version="4.3.1" />

6
backend/src/Squidex/Startup.cs

@ -72,12 +72,12 @@ namespace Squidex
public void Configure(IApplicationBuilder app)
{
app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt();
app.UseSquidexForwardingRules(config);
app.UseSquidexTracking();
app.UseSquidexLocalCache();
app.UseSquidexCors();
app.UseSquidexForwardingRules(config);
app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt();
app.ConfigureApi();
app.ConfigurePortal();

4
backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/ConfigAppLimitsProviderTests.cs

@ -22,7 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1
MaxContributors = -1,
BlockingApiCalls = -1
};
private static readonly ConfigAppLimitsPlan FreePlan = new ConfigAppLimitsPlan
@ -31,7 +32,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
Name = "Free",
MaxApiCalls = 50000,
MaxAssetSize = 1024 * 1024 * 10,
MaxContributors = 2
MaxContributors = 2,
BlockingApiCalls = 50000
};
private static readonly ConfigAppLimitsPlan BasicPlan = new ConfigAppLimitsPlan
@ -42,7 +44,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
MaxAssetSize = 1024 * 1024 * 2,
MaxContributors = 5,
YearlyCosts = "100€",
YearlyId = "basic_yearly"
YearlyId = "basic_yearly",
BlockingApiCalls = 150000
};
private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan };

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -18,10 +18,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="GraphQL" Version="2.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj

@ -14,8 +14,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

6
backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -20,10 +20,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="Google.Cloud.Storage.V1" Version="2.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

130
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs

@ -0,0 +1,130 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Xunit;
namespace Squidex.Infrastructure.UsageTracking
{
public class ApiUsageTrackerTests
{
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>();
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly ApiUsageTracker sut;
public ApiUsageTrackerTests()
{
sut = new ApiUsageTracker(usageTracker);
}
[Fact]
public async Task Should_track_usage()
{
Counters? measuredCounters = null;
A.CallTo(() => usageTracker.TrackAsync(date, $"{key}_API", null, A<Counters>.Ignored))
.Invokes(args =>
{
measuredCounters = args.GetArgument<Counters>(3)!;
});
await sut.TrackAsync(date, key, null, 4, 120, 1024);
measuredCounters.Should().BeEquivalentTo(new Counters
{
[ApiUsageTracker.CounterTotalBytes] = 1024,
[ApiUsageTracker.CounterTotalCalls] = 4,
[ApiUsageTracker.CounterTotalElapsedMs] = 120
});
}
[Fact]
public async Task Should_query_from_tracker()
{
var counters = new Counters
{
[ApiUsageTracker.CounterTotalCalls] = 4
};
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date))
.Returns(counters);
var result = await sut.GetMonthCostsAsync(key, date);
Assert.Equal(4, result);
}
[Fact]
public async Task Should_query_stats_from_tracker()
{
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
var counters = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["my-category"] = new List<(DateTime Date, Counters Counters)>
{
(dateFrom.AddDays(0), Counters(0, 0, 0)),
(dateFrom.AddDays(1), Counters(4, 100, 2048)),
(dateFrom.AddDays(2), Counters(0, 0, 0)),
(dateFrom.AddDays(3), Counters(2, 60, 1024)),
(dateFrom.AddDays(4), Counters(3, 30, 512))
},
["*"] = new List<(DateTime Date, Counters Counters)>
{
(dateFrom.AddDays(0), Counters(1, 20, 128)),
(dateFrom.AddDays(1), Counters(0, 0, 0)),
(dateFrom.AddDays(2), Counters(5, 90, 16)),
(dateFrom.AddDays(3), Counters(0, 0, 0)),
(dateFrom.AddDays(4), Counters(0, 0, 0))
}
};
A.CallTo(() => usageTracker.QueryAsync($"{key}_API", dateFrom, dateTo))
.Returns(counters);
var (summary, stats) = await sut.QueryAsync(key, dateFrom, dateTo);
stats.Should().BeEquivalentTo(new Dictionary<string, List<ApiStats>>
{
["my-category"] = new List<ApiStats>
{
new ApiStats(dateFrom.AddDays(0), 0, 0, 0),
new ApiStats(dateFrom.AddDays(1), 4, 25, 2048),
new ApiStats(dateFrom.AddDays(2), 0, 0, 0),
new ApiStats(dateFrom.AddDays(3), 2, 30, 1024),
new ApiStats(dateFrom.AddDays(4), 3, 10, 512)
},
["*"] = new List<ApiStats>
{
new ApiStats(dateFrom.AddDays(0), 1, 20, 128),
new ApiStats(dateFrom.AddDays(1), 0, 0, 0),
new ApiStats(dateFrom.AddDays(2), 5, 18, 16),
new ApiStats(dateFrom.AddDays(3), 0, 0, 0),
new ApiStats(dateFrom.AddDays(4), 0, 0, 0)
}
});
summary.Should().BeEquivalentTo(new ApiStatsSummary(15, 20, 3728));
}
private static Counters Counters(long calls, long elapsed, long bytes)
{
return new Counters
{
[ApiUsageTracker.CounterTotalBytes] = bytes,
[ApiUsageTracker.CounterTotalCalls] = calls,
[ApiUsageTracker.CounterTotalElapsedMs] = elapsed
};
}
}
}

201
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

@ -20,6 +20,7 @@ namespace Squidex.Infrastructure.UsageTracking
private readonly IUsageRepository usageStore = A.Fake<IUsageRepository>();
private readonly ISemanticLog log = A.Fake<ISemanticLog>();
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly BackgroundUsageTracker sut;
public BackgroundUsageTrackerTests()
@ -32,7 +33,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.TrackAsync(key, "category1", 1, 1000));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.TrackAsync(date, key, "category1", new Counters()));
}
[Fact]
@ -40,142 +41,138 @@ namespace Squidex.Infrastructure.UsageTracking
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.QueryAsync(key, DateTime.Today, DateTime.Today.AddDays(1)));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.QueryAsync(key, date, date.AddDays(1)));
}
[Fact]
public async Task Should_throw_exception_if_querying_montly_usage_on_disposed_object()
public async Task Should_throw_exception_if_querying_monthly_counters_on_disposed_object()
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetMonthlyCallsAsync(key, DateTime.Today));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetForMonthAsync(key, date));
}
[Fact]
public async Task Should_throw_exception_if_querying_summary_counters_on_disposed_object()
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetAsync(key, date, date));
}
[Fact]
public async Task Should_sum_up_when_getting_monthly_calls()
{
var date = new DateTime(2016, 1, 15);
var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1);
IReadOnlyList<StoredUsage> originalData = new List<StoredUsage>
var originalData = new List<StoredUsage>
{
new StoredUsage("category1", date.AddDays(1), Counters(10, 15)),
new StoredUsage("category1", date.AddDays(3), Counters(13, 18)),
new StoredUsage("category1", date.AddDays(5), Counters(15, 20)),
new StoredUsage("category1", date.AddDays(7), Counters(17, 22))
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22))
};
A.CallTo(() => usageStore.QueryAsync($"{key}_API", new DateTime(2016, 1, 1), new DateTime(2016, 1, 15)))
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.GetMonthlyCallsAsync(key, date);
var result = await sut.GetForMonthAsync(key, date);
Assert.Equal(55, result);
Assert.Equal(38, result["A"]);
Assert.Equal(55, result["B"]);
}
[Fact]
public async Task Should_sum_up_when_getting_last_calls_calls()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(10);
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
IReadOnlyList<StoredUsage> originalData = new List<StoredUsage>
var originalData = new List<StoredUsage>
{
new StoredUsage("category1", f.AddDays(1), Counters(10, 15)),
new StoredUsage("category1", f.AddDays(3), Counters(13, 18)),
new StoredUsage("category1", f.AddDays(5), Counters(15, 20)),
new StoredUsage("category1", f.AddDays(7), Counters(17, 22))
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22))
};
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.GetPreviousCallsAsync(key, f, t);
var result = await sut.GetAsync(key, dateFrom, dateTo);
Assert.Equal(55, result);
Assert.Equal(38, result["A"]);
Assert.Equal(55, result["B"]);
}
[Fact]
public async Task Should_fill_missing_days()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(4);
var originalData = new List<StoredUsage>
public async Task Should_create_empty_results_with_default_category_is_result_is_empty()
{
new StoredUsage("MyCategory1", f.AddDays(1), Counters(10, 15)),
new StoredUsage("MyCategory1", f.AddDays(3), Counters(13, 18)),
new StoredUsage("MyCategory1", f.AddDays(4), Counters(15, 20)),
new StoredUsage(null, f.AddDays(0), Counters(17, 22)),
new StoredUsage(null, f.AddDays(2), Counters(11, 14))
};
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
.Returns(originalData);
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(new List<StoredUsage>());
var result = await sut.QueryAsync(key, f, t);
var result = await sut.QueryAsync(key, dateFrom, dateTo);
var expected = new Dictionary<string, List<DateUsage>>
var expected = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["MyCategory1"] = new List<DateUsage>
["*"] = new List<(DateTime Date, Counters Counters)>
{
new DateUsage(f.AddDays(0), 00, 00),
new DateUsage(f.AddDays(1), 10, 15),
new DateUsage(f.AddDays(2), 00, 00),
new DateUsage(f.AddDays(3), 13, 18),
new DateUsage(f.AddDays(4), 15, 20)
},
["*"] = new List<DateUsage>
{
new DateUsage(f.AddDays(0), 17, 22),
new DateUsage(f.AddDays(1), 00, 00),
new DateUsage(f.AddDays(2), 11, 14),
new DateUsage(f.AddDays(3), 00, 00),
new DateUsage(f.AddDays(4), 00, 00)
(dateFrom.AddDays(0), new Counters()),
(dateFrom.AddDays(1), new Counters()),
(dateFrom.AddDays(2), new Counters()),
(dateFrom.AddDays(3), new Counters()),
(dateFrom.AddDays(4), new Counters()),
}
};
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Should_fill_missing_days_with_star()
public async Task Should_create_results_with_filled_days()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(4);
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
.Returns(new List<StoredUsage>());
var originalData = new List<StoredUsage>
{
new StoredUsage("my-category", dateFrom.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("my-category", dateFrom.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("my-category", dateFrom.AddDays(4), Counters(a: 15, b: 20)),
new StoredUsage(null, dateFrom.AddDays(0), Counters(a: 17, b: 22)),
new StoredUsage(null, dateFrom.AddDays(2), Counters(a: 11, b: 14))
};
var result = await sut.QueryAsync(key, f, t);
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.QueryAsync(key, dateFrom, dateTo);
var expected = new Dictionary<string, List<DateUsage>>
var expected = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["my-category"] = new List<(DateTime Date, Counters Counters)>
{
["*"] = new List<DateUsage>
(dateFrom.AddDays(0), Counters()),
(dateFrom.AddDays(1), Counters(a: 10, b: 15)),
(dateFrom.AddDays(2), Counters()),
(dateFrom.AddDays(3), Counters(a: 13, b: 18)),
(dateFrom.AddDays(4), Counters(a: 15, b: 20))
},
["*"] = new List<(DateTime Date, Counters Counters)>
{
new DateUsage(f.AddDays(0), 00, 00),
new DateUsage(f.AddDays(1), 00, 00),
new DateUsage(f.AddDays(2), 00, 00),
new DateUsage(f.AddDays(3), 00, 00),
new DateUsage(f.AddDays(4), 00, 00)
(dateFrom.AddDays(0), Counters(a: 17, b: 22)),
(dateFrom.AddDays(1), Counters()),
(dateFrom.AddDays(2), Counters(a: 11, b: 14)),
(dateFrom.AddDays(3), Counters()),
(dateFrom.AddDays(4), Counters())
}
};
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Should_not_track_if_weight_less_than_zero()
{
await sut.TrackAsync(key, "MyCategory", -1, 1000);
await sut.TrackAsync(key, "MyCategory", 0, 1000);
sut.Next();
sut.Dispose();
A.CallTo(() => usageStore.TrackUsagesAsync(A<UsageUpdate[]>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_aggregate_and_store_on_dispose()
{
@ -183,18 +180,16 @@ namespace Squidex.Infrastructure.UsageTracking
var key2 = Guid.NewGuid().ToString();
var key3 = Guid.NewGuid().ToString();
var today = DateTime.Today;
await sut.TrackAsync(key1, "MyCategory1", 1, 1000);
await sut.TrackAsync(date, key1, "my-category", Counters(a: 1, b: 1000));
await sut.TrackAsync(key2, "MyCategory1", 1.0, 2000);
await sut.TrackAsync(key2, "MyCategory1", 0.5, 3000);
await sut.TrackAsync(date, key2, "my-category", Counters(a: 1.0, b: 2000));
await sut.TrackAsync(date, key2, "my-category", Counters(a: 0.5, b: 3000));
await sut.TrackAsync(key3, "MyCategory1", 0.3, 4000);
await sut.TrackAsync(key3, "MyCategory1", 0.1, 5000);
await sut.TrackAsync(date, key3, "my-category", Counters(a: 0.3, b: 4000));
await sut.TrackAsync(date, key3, "my-category", Counters(a: 0.1, b: 5000));
await sut.TrackAsync(key3, null, 0.5, 2000);
await sut.TrackAsync(key3, null, 0.5, 6000);
await sut.TrackAsync(date, key3, null, Counters(a: 0.5, b: 2000));
await sut.TrackAsync(date, key3, null, Counters(a: 0.5, b: 6000));
UsageUpdate[]? updates = null;
@ -206,23 +201,31 @@ namespace Squidex.Infrastructure.UsageTracking
updates.Should().BeEquivalentTo(new[]
{
new UsageUpdate(today, $"{key1}_API", "MyCategory1", Counters(1.0, 1000)),
new UsageUpdate(today, $"{key2}_API", "MyCategory1", Counters(1.5, 5000)),
new UsageUpdate(today, $"{key3}_API", "MyCategory1", Counters(0.4, 9000)),
new UsageUpdate(today, $"{key3}_API", "*", Counters(1, 8000))
new UsageUpdate(date, key1, "my-category", Counters(a: 1.0, b: 1000)),
new UsageUpdate(date, key2, "my-category", Counters(a: 1.5, b: 5000)),
new UsageUpdate(date, key3, "my-category", Counters(a: 0.4, b: 9000)),
new UsageUpdate(date, key3, "*", Counters(1, 8000))
}, o => o.ComparingByMembers<UsageUpdate>());
A.CallTo(() => usageStore.TrackUsagesAsync(A<UsageUpdate[]>._))
.MustHaveHappened();
}
private static Counters Counters(double count, long ms)
private static Counters Counters(double? a = null, double? b = null)
{
return new Counters
var result = new Counters();
if (a != null)
{
[BackgroundUsageTracker.CounterTotalCalls] = count,
[BackgroundUsageTracker.CounterTotalElapsedMs] = ms
};
result["A"] = a.Value;
}
if (b != null)
{
result["B"] = b.Value;
}
return result;
}
}
}

69
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs

@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly IUsageTracker inner = A.Fake<IUsageTracker>();
private readonly IUsageTracker sut;
@ -29,54 +30,78 @@ namespace Squidex.Infrastructure.UsageTracking
[Fact]
public async Task Should_forward_track_call()
{
await sut.TrackAsync(key, "MyCategory", 123, 456);
var counters = new Counters();
A.CallTo(() => inner.TrackAsync(key, "MyCategory", 123, 456))
await sut.TrackAsync(date, key, "my-category", counters);
A.CallTo(() => inner.TrackAsync(date, key, "my-category", counters))
.MustHaveHappened();
}
[Fact]
public async Task Should_forward_query_call()
{
await sut.QueryAsync(key, DateTime.MaxValue, DateTime.MinValue);
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
await sut.QueryAsync(key, dateFrom, dateTo);
A.CallTo(() => inner.QueryAsync(key, DateTime.MaxValue, DateTime.MinValue))
A.CallTo(() => inner.QueryAsync(key, dateFrom, dateTo))
.MustHaveHappened();
}
[Fact]
public async Task Should_cache_monthly_usage()
{
A.CallTo(() => inner.GetMonthlyCallsAsync(key, DateTime.Today))
.Returns(100);
var counters = new Counters();
A.CallTo(() => inner.GetForMonthAsync(key, date))
.Returns(counters);
var result1 = await sut.GetMonthlyCallsAsync(key, DateTime.Today);
var result2 = await sut.GetMonthlyCallsAsync(key, DateTime.Today);
var result1 = await sut.GetForMonthAsync(key, date);
var result2 = await sut.GetForMonthAsync(key, date);
Assert.Equal(100, result1);
Assert.Equal(100, result2);
Assert.Same(counters, result1);
Assert.Same(counters, result2);
A.CallTo(() => inner.GetMonthlyCallsAsync(key, DateTime.Today))
.MustHaveHappened(1, Times.Exactly);
A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_cache_days_usage()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(10);
var counters = new Counters();
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo))
.Returns(counters);
var result1 = await sut.GetAsync(key, dateFrom, dateTo);
var result2 = await sut.GetAsync(key, dateFrom, dateTo);
A.CallTo(() => inner.GetPreviousCallsAsync(key, f, t))
.Returns(120);
Assert.Same(counters, result1);
Assert.Same(counters, result2);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_not_cache_queries()
{
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
var result1 = await sut.GetPreviousCallsAsync(key, f, t);
var result2 = await sut.GetPreviousCallsAsync(key, f, t);
var result1 = await sut.QueryAsync(key, dateFrom, dateTo);
var result2 = await sut.QueryAsync(key, dateFrom, dateTo);
Assert.Equal(120, result1);
Assert.Equal(120, result2);
Assert.NotSame(result2, result1);
A.CallTo(() => inner.GetPreviousCallsAsync(key, f, t))
.MustHaveHappened(1, Times.Exactly);
A.CallTo(() => inner.QueryAsync(key, dateFrom, dateTo))
.MustHaveHappenedTwiceOrMore();
}
}
}

4
backend/tests/Squidex.Web.Tests/ApiCostsAttributeTests.cs

@ -12,11 +12,11 @@ namespace Squidex.Web
public class ApiCostsAttributeTests
{
[Fact]
public void Should_assign_weight()
public void Should_assign_costs()
{
var sut = new ApiCostsAttribute(10.5);
Assert.Equal(10.5, sut.Weight);
Assert.Equal(10.5, sut.Costs);
}
}
}

78
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -13,9 +13,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure.UsageTracking;
@ -25,13 +23,10 @@ namespace Squidex.Web.Pipeline
{
public class ApiCostsFilterTests
{
private readonly IActionContextAccessor actionContextAccessor = A.Fake<IActionContextAccessor>();
private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IClock clock = A.Fake<IClock>();
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly ActionExecutingContext actionContext;
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly ActionExecutionDelegate next;
@ -48,9 +43,6 @@ namespace Squidex.Web.Pipeline
new ActionDescriptor()),
new List<IFilterMetadata>(), new Dictionary<string, object>(), null);
A.CallTo(() => actionContextAccessor.ActionContext)
.Returns(actionContext);
A.CallTo(() => appPlansProvider.GetPlan(null))
.Returns(appPlan);
@ -60,7 +52,7 @@ namespace Squidex.Web.Pipeline
A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthlyCallsAsync(A<string>._, DateTime.Today))
A.CallTo(() => usageTracker.GetMonthCostsAsync(A<string>._, DateTime.Today))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
next = () =>
@ -70,7 +62,7 @@ namespace Squidex.Web.Pipeline
return Task.FromResult<ActionExecutedContext?>(null);
};
sut = new ApiCostsFilter(appLogStore, appPlansProvider, usageTracker, clock);
sut = new ApiCostsFilter(appPlansProvider, usageTracker);
}
[Fact]
@ -87,9 +79,6 @@ namespace Squidex.Web.Pipeline
Assert.Equal(429, (actionContext.Result as StatusCodeResult)?.StatusCode);
Assert.False(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
@ -105,9 +94,6 @@ namespace Squidex.Web.Pipeline
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, 13, A<double>._))
.MustHaveHappened();
}
[Fact]
@ -123,64 +109,6 @@ namespace Squidex.Web.Pipeline
await sut.OnActionExecutionAsync(actionContext, next);
Assert.False(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, 13, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_weight_is_zero()
{
sut.FilterDefinition = new ApiCostsAttribute(0);
SetupApp();
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_app_not_defined()
{
sut.FilterDefinition = new ApiCostsAttribute(1);
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_log_request_event_if_weight_is_zero()
{
sut.FilterDefinition = new ApiCostsAttribute(0);
SetupApp();
httpContext.Request.Method = "GET";
httpContext.Request.Path = "/my-path";
var instant = SystemClock.Instance.GetCurrentInstant();
A.CallTo(() => clock.GetCurrentInstant())
.Returns(instant);
await sut.OnActionExecutionAsync(actionContext, next);
A.CallTo(() => appLogStore.LogAsync(appEntity.Id, instant, "GET", "/my-path", null, null, A<long>._, 0))
.MustHaveHappened();
}
private void SetupApp()

6
backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs

@ -38,7 +38,7 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("host", 443);
await sut.Invoke(httpContext);
await sut.InvokeAsync(httpContext);
Assert.Null(httpContext.Request.Host.Port);
Assert.True(isNextCalled);
@ -52,7 +52,7 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Scheme = "http";
httpContext.Request.Host = new HostString("host", 80);
await sut.Invoke(httpContext);
await sut.InvokeAsync(httpContext);
Assert.Null(httpContext.Request.Host.Port);
Assert.True(isNextCalled);
@ -66,7 +66,7 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Scheme = "http";
httpContext.Request.Host = new HostString("host", 8080);
await sut.Invoke(httpContext);
await sut.InvokeAsync(httpContext);
Assert.Equal(8080, httpContext.Request.Host.Port);
Assert.True(isNextCalled);

163
backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs

@ -0,0 +1,163 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Http;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking;
using Xunit;
namespace Squidex.Web.Pipeline
{
public class UsageMiddlewareTests
{
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly IClock clock = A.Fake<IClock>();
private readonly Instant instant = SystemClock.Instance.GetCurrentInstant();
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly RequestDelegate next;
private readonly UsageMiddleware sut;
private bool isNextCalled;
public UsageMiddlewareTests()
{
A.CallTo(() => clock.GetCurrentInstant())
.Returns(instant);
next = x =>
{
isNextCalled = true;
return Task.CompletedTask;
};
sut = new UsageMiddleware(appLogStore, usageTracker, clock);
}
[Fact]
public async Task Should_not_track_if_app_not_defined()
{
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_call_blocked()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_track_if_calls_left()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, A<long>._))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_request_bytes()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
httpContext.Request.ContentLength = 1024;
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 1024))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_response_bytes()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, async x =>
{
await x.Response.WriteAsync("Hello World");
await next(x);
});
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 11))
.MustHaveHappened();
}
[Fact]
public async Task Should_not_track_if_costs_are_zero()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0));
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_log_request_even_if_costs_are_zero()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0));
httpContext.Request.Method = "GET";
httpContext.Request.Path = "/my-path";
await sut.InvokeAsync(httpContext, next);
A.CallTo(() => appLogStore.LogAsync(appId.Id, instant, "GET", "/my-path", null, null, A<long>._, 0))
.MustHaveHappened();
}
}
}

6
backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj

@ -12,9 +12,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="IdentityServer4" Version="3.1.0" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="IdentityServer4" Version="3.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" />

2
backend/tools/Migrate_00/Migrate_00.csproj

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>

32
frontend/app/features/dashboard/pages/dashboard-page.component.html

@ -85,11 +85,11 @@
<div class="card card-lg">
<div class="card-header">
API Performance (ms)
API Performance (ms): {{callsPerformance}}ms avg
<div class="float-right">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isPerformanceStacked" />
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isStacked" />
<label class="form-check-label" for="stacked">
Stacked
</label>
@ -97,7 +97,7 @@
</div>
</div>
<div class="card-body">
<chart type="bar" [data]="chartCallsPerformance" [options]="isPerformanceStacked ? stackedChartOptions : chartOptions"></chart>
<chart type="bar" [data]="chartCallsPerformance" [options]="isStacked ? stackedChartOptions : chartOptions"></chart>
</div>
</div>
@ -107,7 +107,7 @@
<div class="aggregation" *ngIf="callsCurrent >= 0">
<div class="aggregation-label">This month</div>
<div class="aggregation-value">{{callsCurrent | sqxKNumber}}</div>
<div class="aggregation-label" *ngIf="callsMax > 0">Monthly limit: {{callsMax | sqxKNumber}}</div>
<div class="aggregation-label" *ngIf="callsAllowed > 0">Monthly limit: {{callsAllowed | sqxKNumber}}</div>
</div>
</div>
</div>
@ -122,10 +122,10 @@
<div class="card card">
<div class="card-header">Assets Size (MB)</div>
<div class="card-body">
<div class="aggregation" *ngIf="assetsCurrent >= 0">
<div class="aggregation" *ngIf="storageCurrent >= 0">
<div class="aggregation-label">Total Size</div>
<div class="aggregation-value">{{assetsCurrent | sqxFileSize}}</div>
<div class="aggregation-label" *ngIf="assetsMax > 0">Total limit: {{assetsMax | sqxFileSize}}</div>
<div class="aggregation-value">{{storageCurrent | sqxFileSize}}</div>
<div class="aggregation-label" *ngIf="storageAllowed > 0">Total limit: {{storageAllowed | sqxFileSize}}</div>
</div>
</div>
</div>
@ -137,6 +137,24 @@
</div>
</div>
<div class="card card-lg">
<div class="card-header">
Traffic (MB): {{callsBytes | sqxFileSize}} total
<div class="float-right">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isStacked" />
<label class="form-check-label" for="stacked">
Stacked
</label>
</div>
</div>
</div>
<div class="card-body">
<chart type="bar" [data]="chartCallsBytes" [options]="isStacked ? stackedChartOptions : chartOptions"></chart>
</div>
</div>
<div class="card card-lg">
<div class="card-header">History</div>
<div class="card-body card-history card-body-scroll">

86
frontend/app/features/dashboard/pages/dashboard-page.component.ts

@ -15,6 +15,7 @@ import {
fadeAnimation,
HistoryEventDto,
HistoryService,
LocalStoreService,
ResourceOwner,
UsagesService
} from '@app/shared';
@ -42,14 +43,7 @@ const COLORS: ReadonlyArray<string> = [
]
})
export class DashboardPageComponent extends ResourceOwner implements OnInit {
public profileDisplayName = '';
public chartStorageCount: any;
public chartStorageSize: any;
public chartCallsCount: any;
public chartCallsPerformance: any;
public isPerformanceStacked = false;
private isStackedValue: boolean;
public chartOptions = {
responsive: true,
@ -87,19 +81,42 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
public history: ReadonlyArray<HistoryEventDto> = [];
public assetsCurrent = 0;
public assetsMax = 0;
public profileDisplayName = '';
public chartStorageCount: any;
public chartStorageSize: any;
public chartCallsCount: any;
public chartCallsBytes: any;
public chartCallsPerformance: any;
public storageCurrent = 0;
public storageAllowed = 0;
public callsPerformance = 0;
public callsCurrent = 0;
public callsMax = 0;
public callsAllowed = 0;
public callsBytes = 0;
public get isStacked() {
return this.isStackedValue;
}
public set isStacked(value: boolean) {
this.localStore.setBoolean('dashboard.charts.stacked', value);
this.isStackedValue = value;
}
constructor(
public readonly appsState: AppsState,
public readonly authState: AuthService,
private readonly historyService: HistoryService,
private readonly usagesService: UsagesService
private readonly usagesService: UsagesService,
private readonly localStore: LocalStoreService
) {
super();
this.isStackedValue = localStore.getBoolean('dashboard.charts.stacked');
}
public ngOnInit() {
@ -107,16 +124,8 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getTodayStorage(app.name)))
.subscribe(dto => {
this.assetsCurrent = dto.size;
this.assetsMax = dto.maxAllowed;
}));
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getMonthCalls(app.name)))
.subscribe(dto => {
this.callsCurrent = dto.count;
this.callsMax = dto.maxAllowed;
this.storageCurrent = dto.size;
this.storageAllowed = dto.maxAllowed;
}));
this.own(
@ -142,7 +151,7 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
backgroundColor: `rgba(${COLORS[0]}, 0.6)`,
borderColor: `rgba(${COLORS[0]}, 1)`,
borderWidth: 1,
data: dtos.map(x => x.count)
data: dtos.map(x => x.totalCount)
}
]
};
@ -157,7 +166,7 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
backgroundColor: `rgba(${COLORS[0]}, 0.6)`,
borderColor: `rgba(${COLORS[0]}, 1)`,
borderWidth: 1,
data: dtos.map(x => Math.round(100 * (x.size / (1024 * 1024))) / 100)
data: dtos.map(x => Math.round(100 * (x.totalSize / (1024 * 1024))) / 100)
}
]
};
@ -166,32 +175,49 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getCallsUsages(app.name, DateTime.today().addDays(-20), DateTime.today())))
.subscribe(dtos => {
const labels = createLabelsFromSet(dtos);
.subscribe(({ details, totalBytes, totalCalls, allowedCalls, averageElapsedMs }) => {
const labels = createLabelsFromSet(details);
this.chartCallsCount = {
labels,
datasets: Object.keys(dtos).map((k, i) => (
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: details[k].map(x => x.totalCalls)
}))
};
this.chartCallsBytes = {
labels,
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: dtos[k].map(x => x.count)
data: details[k].map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100)
}))
};
this.chartCallsPerformance = {
labels,
datasets: Object.keys(dtos).map((k, i) => (
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: dtos[k].map(x => x.averageMs)
data: details[k].map(x => x.averageElapsedMs)
}))
};
this.callsPerformance = averageElapsedMs;
this.callsBytes = totalBytes;
this.callsCurrent = totalCalls;
this.callsAllowed = allowedCalls;
}));
}

10
frontend/app/framework/angular/http/http-extensions.ts

@ -100,10 +100,16 @@ export const pretifyError = (message: string) => <T>(source: Observable<T>) =>
errorDto = { message: 'Failed to make the request.', details: [] };
}
if (response.status === 412) {
switch (response.status) {
case 412:
result = new ErrorDto(response.status, 'Failed to make the update. Another user has made a change. Please reload.', [], response);
} else if (response.status !== 500) {
break;
case 429:
result = new ErrorDto(response.status, 'You have exceeded the maximum limit of API calls.', [], response);
break;
case 500:
result = new ErrorDto(response.status, errorDto.message, errorDto.details, response);
break;
}
} catch (e) {
result = new ErrorDto(500, 'Failed to make the request.', [], response);

65
frontend/app/shared/services/usages.service.spec.ts

@ -11,10 +11,10 @@ import { inject, TestBed } from '@angular/core/testing';
import {
ApiUrlConfig,
CallsUsageDto,
CurrentCallsDto,
CallsUsagePerDateDto,
CurrentStorageDto,
DateTime,
StorageUsageDto,
StorageUsagePerDateDto,
UsagesService
} from '@app/shared/internal';
@ -38,7 +38,7 @@ describe('UsagesService', () => {
it('should make get request to get calls usages',
inject([UsagesService, HttpTestingController], (usagesService: UsagesService, httpMock: HttpTestingController) => {
let usages: { [category: string]: ReadonlyArray<CallsUsageDto> };
let usages: CallsUsageDto;
usagesService.getCallsUsages('my-app', DateTime.parseISO_UTC('2017-10-12'), DateTime.parseISO_UTC('2017-10-13')).subscribe(result => {
usages = result;
@ -50,51 +50,42 @@ describe('UsagesService', () => {
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
allowedCalls: 100,
totalBytes: 1024,
totalCalls: 40,
averageElapsedMs: 12.4,
details: {
category1: [
{
date: '2017-10-12',
count: 10,
averageMs: 130
totalBytes: 10,
totalCalls: 130,
averageElapsedMs: 12.3
},
{
date: '2017-10-13',
count: 13,
averageMs: 170
totalBytes: 13,
totalCalls: 170,
averageElapsedMs: 33.3
}
]
}
});
expect(usages!).toEqual({
expect(usages!).toEqual(
new CallsUsageDto(100, 1024, 40, 12.4, {
category1: [
new CallsUsageDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130),
new CallsUsageDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170)
new CallsUsagePerDateDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130, 12.3),
new CallsUsagePerDateDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170, 33.3)
]
});
}));
it('should make get request to get month calls',
inject([UsagesService, HttpTestingController], (usagesService: UsagesService, httpMock: HttpTestingController) => {
let usages: CurrentCallsDto;
usagesService.getMonthCalls('my-app').subscribe(result => {
usages = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/usages/calls/month');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ count: 130, maxAllowed: 150 });
expect(usages!).toEqual(new CurrentCallsDto(130, 150));
})
);
}));
it('should make get request to get storage usages',
inject([UsagesService, HttpTestingController], (usagesService: UsagesService, httpMock: HttpTestingController) => {
let usages: ReadonlyArray<StorageUsageDto>;
let usages: ReadonlyArray<StorageUsagePerDateDto>;
usagesService.getStorageUsages('my-app', DateTime.parseISO_UTC('2017-10-12'), DateTime.parseISO_UTC('2017-10-13')).subscribe(result => {
usages = result;
@ -108,20 +99,20 @@ describe('UsagesService', () => {
req.flush([
{
date: '2017-10-12',
count: 10,
size: 130
totalCount: 10,
totalSize: 130
},
{
date: '2017-10-13',
count: 13,
size: 170
totalCount: 13,
totalSize: 170
}
]);
expect(usages!).toEqual(
[
new StorageUsageDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130),
new StorageUsageDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170)
new StorageUsagePerDateDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130),
new StorageUsagePerDateDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170)
]);
}));

67
frontend/app/shared/services/usages.service.ts

@ -18,33 +18,37 @@ import {
export class CallsUsageDto {
constructor(
public readonly date: DateTime,
public readonly count: number,
public readonly averageMs: number
public readonly allowedCalls: number,
public readonly totalBytes: number,
public readonly totalCalls: number,
public readonly averageElapsedMs: number,
public readonly details: { [category: string]: ReadonlyArray<CallsUsagePerDateDto> }
) {
}
}
export class StorageUsageDto {
export class CallsUsagePerDateDto {
constructor(
public readonly date: DateTime,
public readonly count: number,
public readonly size: number
public readonly totalBytes: number,
public readonly totalCalls: number,
public readonly averageElapsedMs: number
) {
}
}
export class CurrentStorageDto {
export class StorageUsagePerDateDto {
constructor(
public readonly size: number,
public readonly maxAllowed: number
public readonly date: DateTime,
public readonly totalCount: number,
public readonly totalSize: number
) {
}
}
export class CurrentCallsDto {
export class CurrentStorageDto {
constructor(
public readonly count: number,
public readonly size: number,
public readonly maxAllowed: number
) {
}
@ -68,16 +72,6 @@ export class UsagesService {
pretifyError('Failed to load monthly api calls. Please reload.'));
}
public getMonthCalls(app: string): Observable<CurrentCallsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/month`);
return this.http.get<any>(url).pipe(
map(body => {
return new CurrentCallsDto(body.count, body.maxAllowed);
}),
pretifyError('Failed to load monthly api calls. Please reload.'));
}
public getTodayStorage(app: string): Observable<CurrentStorageDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`);
@ -88,36 +82,45 @@ export class UsagesService {
pretifyError('Failed to load todays storage size. Please reload.'));
}
public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<{ [category: string]: ReadonlyArray<CallsUsageDto> }> {
public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<CallsUsageDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/${fromDate.toUTCStringFormat('YYYY-MM-DD')}/${toDate.toUTCStringFormat('YYYY-MM-DD')}`);
return this.http.get<any>(url).pipe(
map(body => {
const usages: { [category: string]: CallsUsageDto[] } = {};
const details: { [category: string]: CallsUsagePerDateDto[] } = {};
for (let category of Object.keys(body)) {
usages[category] = body[category].map((item: any) =>
new CallsUsageDto(
for (let category of Object.keys(body.details)) {
details[category] = body.details[category].map((item: any) =>
new CallsUsagePerDateDto(
DateTime.parseISO_UTC(item.date),
item.count,
item.averageMs));
item.totalBytes,
item.totalCalls,
item.averageElapsedMs));
}
const usages =
new CallsUsageDto(
body.allowedCalls,
body.totalBytes,
body.totalCalls,
body.averageElapsedMs,
details);
return usages;
}),
pretifyError('Failed to load calls usage. Please reload.'));
}
public getStorageUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<ReadonlyArray<StorageUsageDto>> {
public getStorageUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<ReadonlyArray<StorageUsagePerDateDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/${fromDate.toUTCStringFormat('YYYY-MM-DD')}/${toDate.toUTCStringFormat('YYYY-MM-DD')}`);
return this.http.get<any[]>(url).pipe(
map(body => {
const usages = body.map(item =>
new StorageUsageDto(
new StorageUsagePerDateDto(
DateTime.parseISO_UTC(item.date),
item.count,
item.size));
item.totalCount,
item.totalSize));
return usages;
}),

Loading…
Cancel
Save