From b08534d8277781a0948003a347b4ad7dc1494ac7 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 18 Jun 2017 19:51:33 +0200 Subject: [PATCH] Closes #57, Collect Statistics about Webhooks --- Squidex.sln.DotSettings | 1 + src/Squidex.Core/ContentValidator.cs | 2 +- .../CQRS/Commands/CommandContext.cs | 2 +- .../Http/DumpFormatter.cs | 96 ++ src/Squidex.Infrastructure/PropertyValue.cs | 6 +- .../Reflection/PropertyAccessor.cs | 4 +- .../Schemas/MongoSchemaWebhookEntity.cs | 30 +- .../Schemas/MongoSchemaWebhookRepository.cs | 87 +- ...goSchemaWebhookRepository_EventHandling.cs | 68 ++ .../Schemas/ISchemaWebhookEntity.cs | 13 +- .../Schemas/ISchemaWebhookUrlEntity.cs | 21 + .../Repositories/ISchemaWebhookRepository.cs | 4 + .../Schemas/SchemaHistoryEventsCreator.cs | 2 +- src/Squidex.Read/Schemas/WebhookInvoker.cs | 101 +- src/Squidex.Read/Schemas/WebhookResult.cs | 17 + .../Api/Webhooks/Models/WebhookCreatedDto.cs | 27 + .../Api/Webhooks/Models/WebhookDto.cs | 27 + .../Api/Webhooks/WebhooksController.cs | 12 +- .../pages/webhooks-page.component.html | 44 +- .../pages/webhooks-page.component.scss | 30 + .../webhooks/pages/webhooks-page.component.ts | 15 +- .../shared/services/webhooks.service.spec.ts | 30 +- .../app/shared/services/webhooks.service.ts | 30 +- src/Squidex/app/theme/icomoon/demo.html | 440 +++++---- .../app/theme/icomoon/fonts/icomoon.eot | Bin 17208 -> 17676 bytes .../app/theme/icomoon/fonts/icomoon.svg | 3 + .../app/theme/icomoon/fonts/icomoon.ttf | Bin 17044 -> 17512 bytes .../app/theme/icomoon/fonts/icomoon.woff | Bin 17120 -> 17588 bytes src/Squidex/app/theme/icomoon/selection.json | 912 ++++++++++-------- src/Squidex/app/theme/icomoon/style.css | 73 +- 30 files changed, 1353 insertions(+), 744 deletions(-) create mode 100644 src/Squidex.Infrastructure/Http/DumpFormatter.cs create mode 100644 src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository_EventHandling.cs create mode 100644 src/Squidex.Read/Schemas/ISchemaWebhookUrlEntity.cs create mode 100644 src/Squidex.Read/Schemas/WebhookResult.cs create mode 100644 src/Squidex/Controllers/Api/Webhooks/Models/WebhookCreatedDto.cs diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings index 010c5837d..dda3c307a 100644 --- a/Squidex.sln.DotSettings +++ b/Squidex.sln.DotSettings @@ -14,6 +14,7 @@ True False True + DO_NOT_SHOW True DO_NOT_SHOW diff --git a/src/Squidex.Core/ContentValidator.cs b/src/Squidex.Core/ContentValidator.cs index c47e6c1e2..82e8e0d3a 100644 --- a/src/Squidex.Core/ContentValidator.cs +++ b/src/Squidex.Core/ContentValidator.cs @@ -109,7 +109,7 @@ namespace Squidex.Core foreach (var partitionValues in fieldData) { - if (!partition.TryGetItem(partitionValues.Key, out var partitionItem)) + if (!partition.TryGetItem(partitionValues.Key, out var _)) { errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'", field); } diff --git a/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs b/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs index 113f85317..0d9226464 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs @@ -76,7 +76,7 @@ namespace Squidex.Infrastructure.CQRS.Commands public T Result() { - return result != null ? (T)result.Item1 : default(T); + return (T)result?.Item1; } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Http/DumpFormatter.cs b/src/Squidex.Infrastructure/Http/DumpFormatter.cs new file mode 100644 index 000000000..bd4b6972b --- /dev/null +++ b/src/Squidex.Infrastructure/Http/DumpFormatter.cs @@ -0,0 +1,96 @@ +// ========================================================================== +// DumpFormatter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +// ReSharper disable InvertIf + +namespace Squidex.Infrastructure.Http +{ + public static class DumpFormatter + { + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed) + { + var writer = new StringBuilder(); + + writer.AppendLine("Request:"); + writer.AppendRequest(request, requestBody); + + writer.AppendLine(); + writer.AppendLine(); + + writer.AppendLine("Response:"); + writer.AppendResponse(response, responseBody, elapsed); + + return writer.ToString(); + } + + private static void AppendRequest(this StringBuilder writer, HttpRequestMessage request, string requestBody) + { + var method = request.Method.ToString().ToUpperInvariant(); + + writer.AppendLine($"{method}: {request.RequestUri} HTTP/{request.Version}"); + + writer.AppendHeaders(request.Headers); + writer.AppendHeaders(request.Content?.Headers); + + if (!string.IsNullOrWhiteSpace(requestBody)) + { + writer.AppendLine(); + writer.AppendLine(requestBody); + } + } + + private static void AppendResponse(this StringBuilder writer, HttpResponseMessage response, string responseBody, TimeSpan elapsed) + { + if (response != null) + { + var responseCode = (int)response.StatusCode; + var responseText = Enum.GetName(typeof(HttpStatusCode), response.StatusCode); + + writer.AppendLine($"HTTP/{response.Version} {responseCode} {responseText}"); + + writer.AppendHeaders(response.Headers); + writer.AppendHeaders(response.Content?.Headers); + + if (!string.IsNullOrWhiteSpace(responseBody)) + { + writer.AppendLine(); + writer.AppendLine(responseBody); + } + + writer.AppendLine(); + writer.AppendLine($"Elapsed: {elapsed}"); + } + else + { + writer.AppendLine($"Timeout after {elapsed}"); + } + } + + private static void AppendHeaders(this StringBuilder writer, HttpHeaders headers) + { + if (headers == null) + { + return; + } + + foreach (var header in headers) + { + writer.Append(header.Key); + writer.Append(": "); + writer.Append(string.Join("; ", header.Value)); + writer.AppendLine(); + } + } + } +} diff --git a/src/Squidex.Infrastructure/PropertyValue.cs b/src/Squidex.Infrastructure/PropertyValue.cs index dbfabcfea..c8a47c944 100644 --- a/src/Squidex.Infrastructure/PropertyValue.cs +++ b/src/Squidex.Infrastructure/PropertyValue.cs @@ -192,13 +192,13 @@ namespace Squidex.Infrastructure } catch (OverflowException) { - string message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}' because it is either too small or large."; + var message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}' because it is either too small or large."; throw new InvalidCastException(message); } catch (InvalidCastException) { - string message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}'."; + var message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}'."; throw new InvalidCastException(message); } @@ -214,7 +214,7 @@ namespace Squidex.Infrastructure } catch (Exception ex) { - string message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}'."; + var message = $"The property has type '{valueType}' and cannot be casted to '{requestedType}'."; throw new InvalidCastException(message, ex); } diff --git a/src/Squidex.Infrastructure/Reflection/PropertyAccessor.cs b/src/Squidex.Infrastructure/Reflection/PropertyAccessor.cs index 355dd088d..1bdeccfe0 100644 --- a/src/Squidex.Infrastructure/Reflection/PropertyAccessor.cs +++ b/src/Squidex.Infrastructure/Reflection/PropertyAccessor.cs @@ -26,7 +26,7 @@ namespace Squidex.Infrastructure.Reflection } else { - getMethod = x => { throw new NotSupportedException(); }; + getMethod = x => throw new NotSupportedException(); } if (propertyInfo.CanWrite) @@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.Reflection } else { - setMethod = (x, y) => { throw new NotSupportedException(); }; + setMethod = (x, y) => throw new NotSupportedException(); } } diff --git a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookEntity.cs b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookEntity.cs index 4285a0a31..b6395dd13 100644 --- a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookEntity.cs +++ b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookEntity.cs @@ -7,6 +7,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using Squidex.Read.Schemas; @@ -24,16 +25,41 @@ namespace Squidex.Read.MongoDb.Schemas [BsonElement] public Uri Url { get; set; } + [BsonRequired] + [BsonElement] + public Guid AppId { get; set; } + + [BsonRequired] + [BsonElement] + public Guid SchemaId { get; set; } + [BsonRequired] [BsonElement] public string SharedSecret { get; set; } [BsonRequired] [BsonElement] - public Guid AppId { get; set; } + public long TotalSucceeded { get; set; } [BsonRequired] [BsonElement] - public Guid SchemaId { get; set; } + public long TotalFailed { get; set; } + + [BsonRequired] + [BsonElement] + public long TotalTimedout { get; set; } + + [BsonRequired] + [BsonElement] + public long TotalRequestTime { get; set; } + + [BsonRequired] + [BsonElement] + public List LastDumps { get; set; } = new List(); + + IEnumerable ISchemaWebhookEntity.LastDumps + { + get { return LastDumps; } + } } } diff --git a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository.cs b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository.cs index 51f6cd4ec..5e16dae3d 100644 --- a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository.cs +++ b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository.cs @@ -13,31 +13,31 @@ using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; using MongoDB.Driver; -using Squidex.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Events; -using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Reflection; using Squidex.Read.Schemas; using Squidex.Read.Schemas.Repositories; +// ReSharper disable SwitchStatementMissingSomeCases + namespace Squidex.Read.MongoDb.Schemas { - public class MongoSchemaWebhookRepository : MongoRepositoryBase, ISchemaWebhookRepository, IEventConsumer + public partial class MongoSchemaWebhookRepository : MongoRepositoryBase, ISchemaWebhookRepository, IEventConsumer { - private static readonly List EmptyWebhooks = new List(); - private Dictionary> inMemoryWebhooks; + private const int MaxDumps = 10; + private static readonly List EmptyWebhooks = new List(); + private Dictionary>> inMemoryWebhooks; private readonly SemaphoreSlim lockObject = new SemaphoreSlim(1); - public string Name + public sealed class ShortInfo : ISchemaWebhookUrlEntity { - get { return GetType().Name; } - } + public Guid Id { get; set; } - public string EventsFilter - { - get { return "^schema-"; } + public Uri Url { get; set; } + + public string SharedSecret { get; set; } } public MongoSchemaWebhookRepository(IMongoDatabase database) @@ -55,45 +55,47 @@ namespace Squidex.Read.MongoDb.Schemas return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.SchemaId)); } - public Task On(Envelope @event) - { - return this.DispatchActionAsync(@event.Payload, @event.Headers); - } - - protected async Task On(WebhookAdded @event, EnvelopeHeaders headers) + public async Task> QueryByAppAsync(Guid appId) { - await EnsureWebooksLoadedAsync(); - - var webhook = SimpleMapper.Map(@event, new MongoSchemaWebhookEntity { AppId = @event.AppId.Id, SchemaId = @event.SchemaId.Id }); - - inMemoryWebhooks.GetOrAddNew(webhook.AppId).Add(webhook); - - await Collection.InsertOneAsync(webhook); + return await Collection.Find(x => x.AppId == appId).ToListAsync(); } - protected async Task On(WebhookDeleted @event, EnvelopeHeaders headers) + public async Task> QueryUrlsBySchemaAsync(Guid appId, Guid schemaId) { await EnsureWebooksLoadedAsync(); - inMemoryWebhooks.GetOrDefault(@event.AppId.Id)?.RemoveAll(w => w.Id == @event.Id); - - await Collection.DeleteManyAsync(x => x.Id == @event.Id); + return inMemoryWebhooks.GetOrDefault(appId)?.GetOrDefault(schemaId)?.ToList() ?? EmptyWebhooks; } - protected async Task On(SchemaDeleted @event, EnvelopeHeaders headers) + public async Task AddInvokationAsync(Guid webhookId, string dump, WebhookResult result, TimeSpan elapsed) { - await EnsureWebooksLoadedAsync(); + var webhookEntity = await Collection.Find(x => x.Id == webhookId).FirstOrDefaultAsync(); - inMemoryWebhooks.GetOrDefault(@event.AppId.Id)?.RemoveAll(w => w.SchemaId == @event.SchemaId.Id); + if (webhookEntity != null) + { + switch (result) + { + case WebhookResult.Success: + webhookEntity.TotalSucceeded++; + break; + case WebhookResult.Fail: + webhookEntity.TotalFailed++; + break; + case WebhookResult.Timeout: + webhookEntity.TotalTimedout++; + break; + } - await Collection.DeleteManyAsync(x => x.SchemaId == @event.SchemaId.Id); - } + webhookEntity.TotalRequestTime += (long)elapsed.TotalMilliseconds; + webhookEntity.LastDumps.Insert(0, dump); - public async Task> QueryByAppAsync(Guid appId) - { - await EnsureWebooksLoadedAsync(); + while (webhookEntity.LastDumps.Count > MaxDumps) + { + webhookEntity.LastDumps.RemoveAt(webhookEntity.LastDumps.Count - 1); + } - return inMemoryWebhooks.GetOrDefault(appId)?.OfType()?.ToList() ?? EmptyWebhooks; + await Collection.ReplaceOneAsync(x => x.Id == webhookId, webhookEntity); + } } private async Task EnsureWebooksLoadedAsync() @@ -106,9 +108,18 @@ namespace Squidex.Read.MongoDb.Schemas if (inMemoryWebhooks == null) { + var result = new Dictionary>>(); + var webhooks = await Collection.Find(new BsonDocument()).ToListAsync(); - inMemoryWebhooks = webhooks.GroupBy(x => x.AppId).ToDictionary(x => x.Key, x => x.ToList()); + foreach (var webhook in webhooks) + { + var list = result.GetOrAddNew(webhook.AppId).GetOrAddNew(webhook.SchemaId); + + list.Add(SimpleMapper.Map(webhook, new ShortInfo())); + } + + inMemoryWebhooks = result; } } finally diff --git a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository_EventHandling.cs new file mode 100644 index 000000000..7450361f7 --- /dev/null +++ b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaWebhookRepository_EventHandling.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// MongoSchemaWebhookRepository_EventHandling.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Dispatching; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Read.MongoDb.Schemas +{ + public partial class MongoSchemaWebhookRepository + { + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return "^schema-"; } + } + + public Task On(Envelope @event) + { + return this.DispatchActionAsync(@event.Payload, @event.Headers); + } + + protected async Task On(WebhookAdded @event, EnvelopeHeaders headers) + { + await EnsureWebooksLoadedAsync(); + + var theAppId = @event.AppId.Id; + var theSchemaId = @event.SchemaId.Id; + + var webhook = SimpleMapper.Map(@event, new MongoSchemaWebhookEntity { AppId = theAppId, SchemaId = theSchemaId }); + + inMemoryWebhooks.GetOrAddNew(theAppId).GetOrAddNew(theSchemaId).Add(SimpleMapper.Map(@event, new ShortInfo())); + + await Collection.InsertOneAsync(webhook); + } + + protected async Task On(WebhookDeleted @event, EnvelopeHeaders headers) + { + await EnsureWebooksLoadedAsync(); + + inMemoryWebhooks.GetOrDefault(@event.AppId.Id)?.Remove(@event.SchemaId.Id); + + await Collection.DeleteManyAsync(x => x.Id == @event.Id); + } + + protected async Task On(SchemaDeleted @event, EnvelopeHeaders headers) + { + await EnsureWebooksLoadedAsync(); + + inMemoryWebhooks.GetOrDefault(@event.AppId.Id)?.Remove(@event.SchemaId.Id); + + await Collection.DeleteManyAsync(x => x.SchemaId == @event.SchemaId.Id); + } + } +} diff --git a/src/Squidex.Read/Schemas/ISchemaWebhookEntity.cs b/src/Squidex.Read/Schemas/ISchemaWebhookEntity.cs index e063db6b8..1f30c1634 100644 --- a/src/Squidex.Read/Schemas/ISchemaWebhookEntity.cs +++ b/src/Squidex.Read/Schemas/ISchemaWebhookEntity.cs @@ -7,17 +7,22 @@ // ========================================================================== using System; +using System.Collections.Generic; namespace Squidex.Read.Schemas { - public interface ISchemaWebhookEntity + public interface ISchemaWebhookEntity : ISchemaWebhookUrlEntity { Guid SchemaId { get; } - Guid Id { get; } + long TotalSucceeded { get; } - Uri Url { get; } + long TotalFailed { get; } - string SharedSecret { get; } + long TotalTimedout { get; } + + long TotalRequestTime { get; } + + IEnumerable LastDumps { get; } } } diff --git a/src/Squidex.Read/Schemas/ISchemaWebhookUrlEntity.cs b/src/Squidex.Read/Schemas/ISchemaWebhookUrlEntity.cs new file mode 100644 index 000000000..b9c2acc0d --- /dev/null +++ b/src/Squidex.Read/Schemas/ISchemaWebhookUrlEntity.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// ISchemaWebhookUrlEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Read.Schemas +{ + public interface ISchemaWebhookUrlEntity + { + Guid Id { get; } + + Uri Url { get; } + + string SharedSecret { get; } + } +} \ No newline at end of file diff --git a/src/Squidex.Read/Schemas/Repositories/ISchemaWebhookRepository.cs b/src/Squidex.Read/Schemas/Repositories/ISchemaWebhookRepository.cs index 587d51c0b..9b9ae67db 100644 --- a/src/Squidex.Read/Schemas/Repositories/ISchemaWebhookRepository.cs +++ b/src/Squidex.Read/Schemas/Repositories/ISchemaWebhookRepository.cs @@ -14,6 +14,10 @@ namespace Squidex.Read.Schemas.Repositories { public interface ISchemaWebhookRepository { + Task AddInvokationAsync(Guid webhookId, string dump, WebhookResult result, TimeSpan elapsed); + + Task> QueryUrlsBySchemaAsync(Guid appId, Guid schemaId); + Task> QueryByAppAsync(Guid appId); } } diff --git a/src/Squidex.Read/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Read/Schemas/SchemaHistoryEventsCreator.cs index 7ec680cd5..c3c0cdbdf 100644 --- a/src/Squidex.Read/Schemas/SchemaHistoryEventsCreator.cs +++ b/src/Squidex.Read/Schemas/SchemaHistoryEventsCreator.cs @@ -69,7 +69,7 @@ namespace Squidex.Read.Schemas { if (@event.Payload is SchemaEvent schemaEvent) { - string channel = $"schemas.{schemaEvent.SchemaId.Name}"; + var channel = $"schemas.{schemaEvent.SchemaId.Name}"; var result = ForEvent(@event.Payload, channel).AddParameter("Name", schemaEvent.SchemaId.Name); diff --git a/src/Squidex.Read/Schemas/WebhookInvoker.cs b/src/Squidex.Read/Schemas/WebhookInvoker.cs index e932c6482..250dbf0b8 100644 --- a/src/Squidex.Read/Schemas/WebhookInvoker.cs +++ b/src/Squidex.Read/Schemas/WebhookInvoker.cs @@ -7,7 +7,7 @@ // ========================================================================== using System; -using System.Linq; +using System.Diagnostics; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -17,6 +17,7 @@ using NodaTime; using Squidex.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Http; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; using Squidex.Read.Schemas.Repositories; @@ -62,15 +63,13 @@ namespace Squidex.Read.Schemas { if (@event.Payload is ContentEvent contentEvent) { - var hooks = await webhookRepository.QueryByAppAsync(contentEvent.AppId.Id); - - var schemaHooks = hooks.Where(x => x.SchemaId == contentEvent.SchemaId.Id).ToList(); - - if (schemaHooks.Count > 0) + var hooks = await webhookRepository.QueryUrlsBySchemaAsync(contentEvent.AppId.Id, contentEvent.SchemaId.Id); + + if (hooks.Count > 0) { var payload = CreatePayload(@event); - foreach (var hook in schemaHooks) + foreach (var hook in hooks) { DispatchEventAsync(payload, hook, @event.Headers.Timestamp()).Forget(); } @@ -82,39 +81,70 @@ namespace Squidex.Read.Schemas { return new JObject( new JProperty("type", @event.Payload.GetType().Name), - new JProperty("meta", JObject.FromObject(@event.Headers, webhookSerializer)), - new JProperty("data", JObject.FromObject(@event.Payload, webhookSerializer))); + new JProperty("payload", JObject.FromObject(@event.Payload, webhookSerializer)), + new JProperty("timestamp", @event.Headers.Timestamp().ToString())); } - private async Task DispatchEventAsync(JObject payload, ISchemaWebhookEntity webhook, Instant instant) + private async Task DispatchEventAsync(JObject payload, ISchemaWebhookUrlEntity webhook, Instant instant) { try { - using (log.MeasureInformation(w => w - .WriteProperty("Action", "SendToHook") - .WriteProperty("Status", "Invoked"))) - { - var signature = $"{instant.ToUnixTimeSeconds()}{webhook.SharedSecret}".Sha256Base64(); + payload = SignPayload(payload, webhook, instant); - payload["Signature"] = signature; + var requestString = payload.ToString(Formatting.Indented); + var responseString = string.Empty; - using (var client = new HttpClient()) - { - client.Timeout = Timeout; + var request = BuildRequest(requestString, webhook); + var response = (HttpResponseMessage)null; - var message = new HttpRequestMessage(HttpMethod.Post, webhook.Url) + var isTimeout = false; + + var watch = Stopwatch.StartNew(); + try + { + using (log.MeasureInformation(w => w + .WriteProperty("Action", "SendToHook") + .WriteProperty("Status", "Invoked") + .WriteProperty("RequestUrl", request.RequestUri.ToString()))) + { + using (var client = new HttpClient { Timeout = Timeout }) { - Content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json") - }; + response = await client.SendAsync(request); + } + } + } + catch (TimeoutException) + { + isTimeout = true; + } + catch (OperationCanceledException) + { + isTimeout = true; + } + finally + { + watch.Stop(); + } - message.Headers.TryAddWithoutValidation("X-Signature", signature); - message.Headers.Add("User-Agent", "Squidex"); + if (response != null) + { + responseString = await response.Content.ReadAsStringAsync(); + } - var response = await client.SendAsync(message); + var dump = DumpFormatter.BuildDump(request, response, requestString, responseString, watch.Elapsed); - response.EnsureSuccessStatusCode(); - } + var result = WebhookResult.Fail; + + if (isTimeout) + { + result = WebhookResult.Timeout; } + else if (response?.IsSuccessStatusCode == true) + { + result = WebhookResult.Success; + } + + await webhookRepository.AddInvokationAsync(webhook.Id, dump, result, watch.Elapsed); } catch (Exception ex) { @@ -123,5 +153,22 @@ namespace Squidex.Read.Schemas .WriteProperty("Status", "Failed")); } } + + private static JObject SignPayload(JObject payload, ISchemaWebhookUrlEntity webhook, Instant instant) + { + payload["signature"] = $"{instant.ToUnixTimeSeconds()}{webhook.SharedSecret}".Sha256Base64(); + + return payload; + } + + private static HttpRequestMessage BuildRequest(string requestBody, ISchemaWebhookUrlEntity webhook) + { + var request = new HttpRequestMessage(HttpMethod.Post, webhook.Url) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + + return request; + } } } diff --git a/src/Squidex.Read/Schemas/WebhookResult.cs b/src/Squidex.Read/Schemas/WebhookResult.cs new file mode 100644 index 000000000..3f42d2355 --- /dev/null +++ b/src/Squidex.Read/Schemas/WebhookResult.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// WebhookResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Read.Schemas +{ + public enum WebhookResult + { + Success, + Fail, + Timeout + } +} diff --git a/src/Squidex/Controllers/Api/Webhooks/Models/WebhookCreatedDto.cs b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookCreatedDto.cs new file mode 100644 index 000000000..fe3569132 --- /dev/null +++ b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookCreatedDto.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// WebhookCreatedDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Controllers.Api.Webhooks.Models +{ + public class WebhookCreatedDto + { + /// + /// The id of the webhook. + /// + public Guid Id { get; set; } + + /// + /// The shared secret that is used to calculate the signature. + /// + [Required] + public string SharedSecret { get; set; } + } +} diff --git a/src/Squidex/Controllers/Api/Webhooks/Models/WebhookDto.cs b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookDto.cs index 5a1660ac3..f5892859b 100644 --- a/src/Squidex/Controllers/Api/Webhooks/Models/WebhookDto.cs +++ b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookDto.cs @@ -7,6 +7,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Squidex.Controllers.Api.Webhooks.Models @@ -23,6 +24,26 @@ namespace Squidex.Controllers.Api.Webhooks.Models /// public Guid SchemaId { get; set; } + /// + /// The number of succceeded calls. + /// + public long TotalSucceeded { get; set; } + + /// + /// The number of failed calls. + /// + public long TotalFailed { get; set; } + + /// + /// The number of timedout calls. + /// + public long TotalTimedout { get; set; } + + /// + /// The average request time in milliseconds. + /// + public long AverageRequestTimeMs { get; set; } + /// /// The url of the webhook. /// @@ -34,5 +55,11 @@ namespace Squidex.Controllers.Api.Webhooks.Models /// [Required] public string SharedSecret { get; set; } + + /// + /// The last invokation dumps. + /// + [Required] + public List LastDumps { get; set; } } } diff --git a/src/Squidex/Controllers/Api/Webhooks/WebhooksController.cs b/src/Squidex/Controllers/Api/Webhooks/WebhooksController.cs index 98f413e2e..1133dadd5 100644 --- a/src/Squidex/Controllers/Api/Webhooks/WebhooksController.cs +++ b/src/Squidex/Controllers/Api/Webhooks/WebhooksController.cs @@ -56,7 +56,13 @@ namespace Squidex.Controllers.Api.Webhooks Response.Headers["ETag"] = new StringValues(App.Version.ToString()); - var response = webhooks.Select(w => SimpleMapper.Map(w, new WebhookDto())); + var response = webhooks.Select(w => + { + var count = w.TotalTimedout + w.TotalSucceeded + w.TotalFailed; + var average = count == 0 ? 0 : w.TotalRequestTime / count; + + return SimpleMapper.Map(w, new WebhookDto { AverageRequestTimeMs = average, LastDumps = w.LastDumps.ToList() }); + }); return Ok(response); } @@ -78,7 +84,7 @@ namespace Squidex.Controllers.Api.Webhooks /// [HttpPost] [Route("apps/{app}/schemas/{name}/webhooks/")] - [ProducesResponseType(typeof(WebhookDto), 201)] + [ProducesResponseType(typeof(WebhookCreatedDto), 201)] [ProducesResponseType(typeof(ErrorDto), 400)] [ProducesResponseType(typeof(ErrorDto), 409)] [ApiCosts(1)] @@ -88,7 +94,7 @@ namespace Squidex.Controllers.Api.Webhooks await CommandBus.PublishAsync(command); - return CreatedAtAction(nameof(GetWebhooks), new { app }, SimpleMapper.Map(command, new WebhookDto())); + return CreatedAtAction(nameof(GetWebhooks), new { app }, SimpleMapper.Map(command, new WebhookCreatedDto())); } /// diff --git a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.html b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.html index 00932fe3b..54a0059d9 100644 --- a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.html +++ b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.html @@ -26,7 +26,7 @@
-
+
@@ -38,7 +38,7 @@

- Schema: {{webhook.schema.name}} + Schema: {{w.schema.name}}

@@ -50,7 +50,7 @@
Url: - +
Secret: - +
+ +
+
+
+
+
+ + {{w.webhook.totalSucceeded}} + +
+
+ + {{w.webhook.totalFailed}} + +
+
+ + {{w.webhook.totalTimedout}} + +
+
+ + {{w.webhook.averageRequestTimeMs}} ms + +
+
+
+ +
+
+ +
+
diff --git a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.scss b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.scss index 7799839dd..5c3968afc 100644 --- a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.scss +++ b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.scss @@ -3,4 +3,34 @@ .schemas-control { width: 10rem; +} + +.failed { + color: $color-theme-error; +} + +.success { + color: $color-theme-green; +} + +.webhook { + &-stats { + border-top: 1px solid $color-border; + margin-left: -1.25rem; + margin-right: -1.25rem; + padding: 1rem 1rem 0; + } + + &-detail-link { + color: $color-theme-blue !important; + cursor: pointer; + font-size: .9rem; + } + + &-dump { + margin-top: 1rem; + font-size: .8rem; + font-weight: normal; + height: 20rem; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.ts b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.ts index be17dc995..5d106d808 100644 --- a/src/Squidex/app/features/webhooks/pages/webhooks-page.component.ts +++ b/src/Squidex/app/features/webhooks/pages/webhooks-page.component.ts @@ -21,7 +21,7 @@ import { WebhooksService } from 'shared'; -interface WebhookWithSchema { webhook: WebhookDto; schema: SchemaDto; }; +interface WebhookWithSchema { webhook: WebhookDto; schema: SchemaDto; showDetails: boolean; }; @Component({ selector: 'sqx-webhooks-page', @@ -68,7 +68,7 @@ export class WebhooksPageComponent extends AppComponentBase implements OnInit { this.appNameOnce() .switchMap(app => this.schemasService.getSchemas(app) - .withLatestFrom(this.webhooksService.getWebhooks(app), + .combineLatest(this.webhooksService.getWebhooks(app), (s, w) => { return { webhooks: w, schemas: s }; })) .subscribe(dtos => { this.schemas = dtos.schemas; @@ -76,7 +76,7 @@ export class WebhooksPageComponent extends AppComponentBase implements OnInit { this.webhooks = ImmutableArray.of( dtos.webhooks.map(w => { - return { webhook: w, schema: dtos.schemas.find(s => s.id === w.schemaId) }; + return { webhook: w, schema: dtos.schemas.find(s => s.id === w.schemaId), showDetails: false }; }).filter(w => !!w.schema)); if (showInfo) { @@ -86,7 +86,6 @@ export class WebhooksPageComponent extends AppComponentBase implements OnInit { this.notifyError(error); }); } - public resetWebhookForm() { this.addWebhookFormSubmitted = false; this.addWebhookForm.enable(); @@ -105,7 +104,9 @@ export class WebhooksPageComponent extends AppComponentBase implements OnInit { this.appNameOnce() .switchMap(app => this.webhooksService.postWebhook(app, schema.name, requestDto, this.version)) .subscribe(dto => { - this.webhooks = this.webhooks.push({ schema, webhook: dto }); + const webhook = new WebhookDto(dto.id, schemaId, dto.sharedSecret, requestDto.url, 0, 0, 0, 0, []); + + this.webhooks = this.webhooks.push({ schema, webhook, showDetails: false }); this.resetWebhookForm(); }, error => { this.notifyError(error); @@ -114,6 +115,10 @@ export class WebhooksPageComponent extends AppComponentBase implements OnInit { } } + public toggleDetails(webhook: WebhookWithSchema) { + this.webhooks = this.webhooks.replace(webhook, { webhook: webhook.webhook, schema: webhook.schema, showDetails: !webhook.showDetails }); + } + public deleteWebhook(webhook: WebhookWithSchema) { this.appNameOnce() .switchMap(app => this.webhooksService.deleteWebhook(app, webhook.schema.name, webhook.webhook.id, this.version)) diff --git a/src/Squidex/app/shared/services/webhooks.service.spec.ts b/src/Squidex/app/shared/services/webhooks.service.spec.ts index a96eb2b60..0bce3acd4 100644 --- a/src/Squidex/app/shared/services/webhooks.service.spec.ts +++ b/src/Squidex/app/shared/services/webhooks.service.spec.ts @@ -14,6 +14,7 @@ import { AuthService, CreateWebhookDto, Version, + WebhookCreatedDto, WebhookDto, WebhooksService } from './../'; @@ -37,12 +38,22 @@ describe('WebhooksService', () => { id: 'id1', schemaId: 'schemaId1', sharedSecret: 'token1', - url: 'http://squidex.io/1' + url: 'http://squidex.io/1', + totalSucceeded: 1, + totalFailed: 2, + totalTimedout: 3, + averageRequestTimeMs: 4, + dumps: ['dump1'] }, { id: 'id2', schemaId: 'schemaId2', sharedSecret: 'token2', - url: 'http://squidex.io/2' + url: 'http://squidex.io/2', + totalSucceeded: 5, + totalFailed: 6, + totalTimedout: 7, + averageRequestTimeMs: 8, + dumps: ['dump2'] }] }) ) @@ -56,8 +67,8 @@ describe('WebhooksService', () => { }).unsubscribe(); expect(webhooks).toEqual([ - new WebhookDto('id1', 'schemaId1', 'token1', 'http://squidex.io/1'), - new WebhookDto('id2', 'schemaId2', 'token2', 'http://squidex.io/2') + new WebhookDto('id1', 'schemaId1', 'token1', 'http://squidex.io/1', 1, 2, 3, 4, ['dump1']), + new WebhookDto('id2', 'schemaId2', 'token2', 'http://squidex.io/2', 5, 6, 7, 8, ['dump2']) ]); authService.verifyAll(); @@ -70,24 +81,19 @@ describe('WebhooksService', () => { .returns(() => Observable.of( new Response( new ResponseOptions({ - body: { - id: 'id1', - schemaId: 'schemaId1', - sharedSecret: 'token1', - url: 'http://squidex.io/1' - } + body: { id: 'id1', sharedSecret: 'token1' } }) ) )) .verifiable(Times.once()); - let webhook: WebhookDto | null = null; + let webhook: WebhookCreatedDto | null = null; webhooksService.postWebhook('my-app', 'my-schema', dto, version).subscribe(result => { webhook = result; }).unsubscribe(); - expect(webhook).toEqual(new WebhookDto('id1', 'schemaId1', 'token1', 'http://squidex.io/1')); + expect(webhook).toEqual(new WebhookCreatedDto('id1', 'token1')); authService.verifyAll(); }); diff --git a/src/Squidex/app/shared/services/webhooks.service.ts b/src/Squidex/app/shared/services/webhooks.service.ts index a0c9a2067..9d5710522 100644 --- a/src/Squidex/app/shared/services/webhooks.service.ts +++ b/src/Squidex/app/shared/services/webhooks.service.ts @@ -18,7 +18,20 @@ export class WebhookDto { public readonly id: string, public readonly schemaId: string, public readonly sharedSecret: string, - public readonly url: string + public readonly url: string, + public readonly totalSucceeded: number, + public readonly totalFailed: number, + public readonly totalTimedout: number, + public readonly averageRequestTimeMs: number, + public readonly lastDumps: string[] + ) { + } +} + +export class WebhookCreatedDto { + constructor( + public readonly id: string, + public readonly sharedSecret: string ) { } } @@ -51,23 +64,26 @@ export class WebhooksService { item.id, item.schemaId, item.sharedSecret, - item.url); + item.url, + item.totalSucceeded, + item.totalFailed, + item.totalTimedout, + item.averageRequestTimeMs, + item.lastDumps); }); }) .catchError('Failed to load webhooks. Please reload.'); } - public postWebhook(appName: string, schemaName: string, dto: CreateWebhookDto, version?: Version): Observable { + public postWebhook(appName: string, schemaName: string, dto: CreateWebhookDto, version?: Version): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${schemaName}/webhooks`); return this.authService.authPost(url, dto, version) .map(response => response.json()) .map(response => { - return new WebhookDto( + return new WebhookCreatedDto( response.id, - response.schemaId, - response.sharedSecret, - response.url); + response.sharedSecret); }) .catchError('Failed to create webhook. Please reload.'); } diff --git a/src/Squidex/app/theme/icomoon/demo.html b/src/Squidex/app/theme/icomoon/demo.html index a62b67de5..39ee8f805 100644 --- a/src/Squidex/app/theme/icomoon/demo.html +++ b/src/Squidex/app/theme/icomoon/demo.html @@ -9,11 +9,206 @@
-

Font Name: icomoon (Glyphs: 66)

+

Font Name: icomoon (Glyphs: 69)

-

Grid Size: Unknown

+

Grid Size: 16

+
+
+ + + + icon-elapsed +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-timeout +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-checkmark +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-microsoft +
+
+ + +
+
+ liga: + +
+
+
+ + + + icon-google +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-unlocked +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-lock +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-reset +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-pause +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-play +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-settings2 +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-bin2 +
+
+ + +
+
+ liga: + +
+
+
+
+

Grid Size: Unknown

+
@@ -29,7 +224,7 @@
-
+
@@ -45,7 +240,7 @@
-
+
@@ -61,7 +256,7 @@
-
+
@@ -77,7 +272,7 @@
-
+
@@ -93,7 +288,7 @@
-
+
@@ -109,7 +304,7 @@
-
+
@@ -125,7 +320,7 @@
-
+
@@ -141,7 +336,7 @@
-
+
@@ -157,7 +352,7 @@
-
+
@@ -173,7 +368,7 @@
-
+
@@ -189,7 +384,7 @@
-
+
@@ -205,7 +400,7 @@
-
+
@@ -221,7 +416,7 @@
-
+
@@ -237,7 +432,7 @@
-
+
@@ -253,7 +448,7 @@
-
+
@@ -269,7 +464,7 @@
-
+
@@ -285,7 +480,7 @@
-
+
@@ -301,7 +496,7 @@
-
+
@@ -317,7 +512,7 @@
-
+
@@ -333,7 +528,7 @@
-
+
@@ -349,7 +544,7 @@
-
+
@@ -365,7 +560,7 @@
-
+
@@ -381,7 +576,7 @@
-
+
@@ -397,7 +592,7 @@
-
+
@@ -413,7 +608,7 @@
-
+
@@ -429,7 +624,7 @@
-
+
@@ -445,7 +640,7 @@
-
+
@@ -461,7 +656,7 @@
-
+
@@ -477,7 +672,7 @@
-
+
@@ -493,7 +688,7 @@
-
+
@@ -509,7 +704,7 @@
-
+
@@ -525,7 +720,7 @@
-
+
@@ -541,7 +736,7 @@
-
+
-
+
@@ -573,7 +768,7 @@
-
+
@@ -589,7 +784,7 @@
-
+
@@ -605,7 +800,7 @@
-
+
@@ -621,7 +816,7 @@
-
+
@@ -637,7 +832,7 @@
-
+
@@ -653,7 +848,7 @@
-
+
@@ -669,7 +864,7 @@
-
+
@@ -685,7 +880,7 @@
-
+
@@ -701,7 +896,7 @@
-
+
@@ -717,7 +912,7 @@
-
+
@@ -733,7 +928,7 @@
-
+
@@ -749,7 +944,7 @@
-
+
@@ -765,7 +960,7 @@
-
+
@@ -782,153 +977,6 @@
-
-

Grid Size: 16

-
-
- - - - icon-microsoft -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-google -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-unlocked -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-lock -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-reset -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-pause -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-play -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-settings2 -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-bin2 -
-
- - -
-
- liga: - -
-
-

Grid Size: 24

diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index fa1ece3820bea7b59349bd89448e0a581a05f1cb..3d59108aee3f7616bf1628189288ead52a7dff73 100644 GIT binary patch delta 743 zcmYjNO-vJE5S{rJmJ~~yw!5uqYk`GQ5ZF}u7Yww!+BQNjg_y>OL^kE82Wqzw62he= zL=s6O!g@eYBD14scc?R9hr~sHxJ#V;A__h09*oyak&{~+8nX007w;j z7tYavfi#X%{+9C2Tz+AxIcq+k{43RT&KI(Z>*v)PzzrkaKg%miDoo*B%6BMld!XcJ zo{t)SQC_9@Yii-)0x)y~@mu;H7zV4_G{!Kly~Gs8_rBrEaH*{U7zr8y4CrezATw2K zJKn)EDKnCeY7_wh>8#3>nJRUWsdp$43U>9Pz+S-sZ(?}PLjnU)5(-0S!m#$hK-kB4(BVOzW19$+y{@XuTOO;-4avpQb|$l3l!xXj#r{P}TI?77ez8{_ zwxiW-wxWGl?bRpbOqN_;uS=5IWMOMum&nEK%=lIzdHP5{I2ZT%N$nkHwQ_i!`1{Uh z`VTbL15{}M8~7js2}scxf{E~@3vXuxk!Fa}EFQvhCW29#qN_t-n>>u;npdRBSe~;x zqKwVbVQCK$nA}}XCYN_7|Mk?WGB~KLrv7%kXKYc^V%j+{F_v(jes@x=wiw-sv56NF z(tl4b&;A$dCcnR2kk@U5IrKm(MIXy zzIo^eTxzKE&-1fo9q3(q8jIk_-eK%A8ucwLNiXeKdSdtNx4K)`>oy~J2kg2Ch}M47 Jwx`Lj@DJw1w!Q!W delta 253 zcmeC_V%*Wj$Y$Zpz%a#WBAXe@SBLAB6CKJ0H5eEe_5g80a&BUQc-W;A6Hmz0_pQHh zo`Hd}f`P#{AR{#~gaD-wDW90qT*+ z$xlxFbMws(1_lQkpnP9$VnqQ%EMpXqe+I}`$V<#ko$1B-8_0hHRNq#RUtGcfG@XIL zsRSg?z|73JcXA4&J)`5~C5+yTj+;L)HrmPqr7EvGe2wR~`O3h}0#eR!HQnwYjGi3g dXwK-ixyMn0aq + + + \ No newline at end of file diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index ce2c5f39914c6c036345e78bdf404929fdff63eb..5c06467da5db05f5635edde2557640a505e72d3d 100644 GIT binary patch delta 762 zcmYjNO-vI}5T1Dpi-pok+igu-3l>U2U|stgOetGJ8=)o^G>s97Y!J$U+HHUYxwM2} z)DVrp0X>lrFI>5D*cju*lSZRQ6E7T0j0e3a#o3~fmrTC-X5RPZeQ$FW4<29%5C9sW z3>-*TuU!b3ExbsReX5DcyrMd?@7@5QoA~vdGN%%rCa&YD-0Z#S(5~w(@kM}s+su@5 z#~f{129VciADN+nfz=KZe?`1)CO^MeKWTnR{D3rVvxUio;`({M0x)2t3!8amQH2S- zNqn1l%UvZu^=#Pii?~LD`)Xlso*qhP@Up%Kj?s0A>f zzs`W%M5X2PHkMhLll87b9WaoOG_K5PWRW1_a4;Nd??Nws7K4I`6Koz948&PDBr>ZB zTfOam5yPQKG>%b@w+90eKj%S*2L&5n$Dr(UX)a%RWGxp<6iZv#>{c-m%dHiA7G!y$ zN0cP7OYO6x#cZ~qy-)4ZC+sL8yL>*EoZwT1^-*1tC~jp(*9)n`RsF)TxLabC*EWmA zhL@PseLU0S*H!~)6u=68h(Z$56vJy`Lg~cYIj=|=;*`b11e=LroKm#6digpJ=eXz- zDH$)=?2b5RZE9^g7iKWNvy@6L?Tr7!^or8ktE{B|BHl7Ksu?lk42+E=-G|?u)T@m~ zcXDLxxs3Ghsio=vV%_BTrxWt34SOg^&WzgWt@lTg9q+xJYQ`xE9m$BlA|O;WnS$sM z0X{w{eYtIp{eXs=D*rG$UDg{NH@?QBIJDQ+e+G^EmP*!ZBPKt#d-j{%&5x=!LwE!1 Qx(L|D@_=>koBRU*01kDteE*h2Z$4ra}x{1!!Dg*U|^I1@R~X;019w2vAQ!b*aNu? zDjB&Y75|d?oq&85pdN{w{A9<(KR4g(U|?{t0V?RrO{^$jh-Hid^3MSI3VDgSsWZJe ze*^h%fEwBg@{3D=4g~_I5|BItGxOew2kaRgCoy_6I&Su1Y_ydJidSBD_!`e|^Ob>{ z1*DeYYP#J)7(ID|gE^zy<`)hUjFWks`hXVv=v z@&Rdr45m=?YMMv@rHSTF20d3hmd-3L6Xz#CaoAZuW7?n3&XDfL@NE*kWU&9bRLQ8z zbn)aWnU*PZMZg#EcX`mMyMR8?$cuu5 z^Yw?hfM4QTjM(Aqa!VNS2SZ^D@j8pIKj`KiXmy|{=;}Bqxa?)St1!Nq8dP%mQZiY} zDTAraT;Gx`FZD@YujJ8&ENC{F%xD?XJdELv71{1`*=0o+%Wh3Di;^oPC$_S&qje@Y zG5318%6q|V7Vs+P?LFZrIK^WD=!F(=LkOY}rx;EnC+5$5JMWYzLzuESI8iWi7^W0m z9Zp@HgSTFGNt8?{3KnaaZ)xsmz8K(OYI`LXTiKraSL18yz<|0IKdpGz(5NM(gsp#a zJlcBn!$!Tv7h*gVFBK&K@mc^NQWk zpt~X>RLU|1VG#koKF@!yI%luq{HLMn|L_#>aqeaf6ft?a$hT;?XnZdEw P*4p;8x@L68q3q*d(&Dr} delta 337 zcmdne$@rj^QKa18&5ePP0SKHPFmQwEDU%nv2v6SN!ZJ}?q+TOAH?e?$fiVLp6#~NI zVV6#%Cl-Um_5k@DP%Mz1Q<(-7JHo(V8vw$6>o1(oNKH&(V6Ybfsxbp$ew{Ye3tnL}PB^5w1keL!7{4bf`DJMS}sLs!!1E{DEg#X-pvm-aL0;tFlsE9!U zjAI$2@)C1XfnpXwjcp)2(~I+WL4I)w&_@U0>X?BnX6C(Zap8UtboY8HwiK7JL