Merge pull request #30 from Moonlight-Panel/Subscriptions

Subscriptions
This commit is contained in:
Marcel Baumgartner 2023-04-04 01:26:06 +02:00 committed by GitHub
commit 74d00174d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 741 additions and 41 deletions

View file

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Models.Forms;
public class ServerOrderDataModel
{
[Required(ErrorMessage = "You need to enter a name")]
[MaxLength(32, ErrorMessage = "The name cannot be longer that 32 characters")]
public string Name { get; set; } = "";
[Required(ErrorMessage = "You need to specify a server image")]
public Image Image { get; set; }
}

View file

@ -6,6 +6,12 @@ public class SubscriptionLimit
public int Amount { get; set; }
public List<LimitOption> Options { get; set; } = new();
public string? ReadValue(string key)
{
var d = Options.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.InvariantCultureIgnoreCase));
return d?.Value;
}
public class LimitOption
{
public string Key { get; set; } = "";

View file

@ -5,10 +5,12 @@ namespace Moonlight.App.Services.Interop;
public class AlertService
{
private readonly SweetAlertService SweetAlertService;
private readonly SmartTranslateService SmartTranslateService;
public AlertService(SweetAlertService service)
public AlertService(SweetAlertService service, SmartTranslateService smartTranslateService)
{
SweetAlertService = service;
SmartTranslateService = smartTranslateService;
}
public async Task Info(string title, string desciption)
@ -21,6 +23,11 @@ public class AlertService
});
}
public async Task Info(string desciption)
{
await Info("", desciption);
}
public async Task Success(string title, string desciption)
{
await SweetAlertService.FireAsync(new SweetAlertOptions()
@ -31,6 +38,11 @@ public class AlertService
});
}
public async Task Success(string desciption)
{
await Success("", desciption);
}
public async Task Warning(string title, string desciption)
{
await SweetAlertService.FireAsync(new SweetAlertOptions()
@ -41,6 +53,11 @@ public class AlertService
});
}
public async Task Warning(string desciption)
{
await Warning("", desciption);
}
public async Task Error(string title, string desciption)
{
await SweetAlertService.FireAsync(new SweetAlertOptions()
@ -51,6 +68,11 @@ public class AlertService
});
}
public async Task Error(string desciption)
{
await Error("", desciption);
}
public async Task<bool> YesNo(string title, string desciption, string yesText, string noText)
{
var result = await SweetAlertService.FireAsync(new SweetAlertOptions()
@ -79,4 +101,27 @@ public class AlertService
return result.Value;
}
public async Task<bool> ConfirmMath()
{
var r = new Random();
var i1 = r.Next(5, 15);
var i2 = r.Next(5, 15);
var input = await Text(
SmartTranslateService.Translate("Confirm"),
$"{i1} + {i2} =",
""
);
if (int.TryParse(input, out int i))
{
if (i == i1 + i2)
{
return true;
}
}
return false;
}
}

View file

@ -15,4 +15,9 @@ public class ClipboardService
{
await JsRuntime.InvokeVoidAsync("copyTextToClipboard", data);
}
public async Task Copy(string data)
{
await JsRuntime.InvokeVoidAsync("copyTextToClipboard", data);
}
}

View file

@ -0,0 +1,64 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services;
public class SmartDeployService
{
private readonly NodeRepository NodeRepository;
private readonly NodeService NodeService;
public SmartDeployService(NodeRepository nodeRepository, NodeService nodeService)
{
NodeRepository = nodeRepository;
NodeService = nodeService;
}
public async Task<Node?> GetNode()
{
var data = new Dictionary<Node, double>();
foreach (var node in NodeRepository.Get().ToArray())
{
var u = await GetUsageScore(node);
if(u != 0)
data.Add(node, u);
}
if (!data.Any())
return null;
return data.MaxBy(x => x.Value).Key;
}
private async Task<double> GetUsageScore(Node node)
{
var score = 0;
try
{
var cpuStats = await NodeService.GetCpuStats(node);
var memoryStats = await NodeService.GetMemoryStats(node);
var diskStats = await NodeService.GetDiskStats(node);
var cpuWeight = 0.5; // Weight of CPU usage in the final score
var memoryWeight = 0.3; // Weight of memory usage in the final score
var diskSpaceWeight = 0.2; // Weight of free disk space in the final score
var cpuScore = (1 - cpuStats.Usage) * cpuWeight; // CPU score is based on the inverse of CPU usage
var memoryScore = (1 - (memoryStats.Used / 1024)) * memoryWeight; // Memory score is based on the percentage of free memory
var diskSpaceScore = (double) diskStats.FreeBytes / 1000000000 * diskSpaceWeight; // Disk space score is based on the amount of free disk space in GB
var finalScore = cpuScore + memoryScore + diskSpaceScore;
return finalScore;
}
catch (Exception e)
{
// ignored
}
return score;
}
}

View file

@ -78,49 +78,45 @@ public class SubscriptionService
await OneTimeJwtService.Revoke(code);
}
public async Task Cancel()
{
if (await GetCurrent() != null)
{
var user = await GetCurrentUser();
user.CurrentSubscription = null;
UserRepository.Update(user);
}
}
public async Task<SubscriptionLimit> GetLimit(string identifier)
{
var configSection = ConfigService.GetSection("Moonlight").GetSection("Subscriptions");
var defaultLimits = configSection.GetValue<SubscriptionLimit[]>("defaultLimits");
var subscription = await GetCurrent();
if (subscription == null)
{
var foundDefault = defaultLimits.FirstOrDefault(x => x.Identifier == identifier);
if (foundDefault != null)
return foundDefault;
return new()
{
Identifier = identifier,
Amount = 0
};
}
else
var subscriptionLimits =
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson)
?? Array.Empty<SubscriptionLimit>();
var foundLimit = subscriptionLimits.FirstOrDefault(x => x.Identifier == identifier);
if (foundLimit != null)
return foundLimit;
return new()
{
var subscriptionLimits =
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson)
?? Array.Empty<SubscriptionLimit>();
var foundLimit = subscriptionLimits.FirstOrDefault(x => x.Identifier == identifier);
if (foundLimit != null)
return foundLimit;
var foundDefault = defaultLimits.FirstOrDefault(x => x.Identifier == identifier);
if (foundDefault != null)
return foundDefault;
return new()
{
Identifier = identifier,
Amount = 0
};
}
Identifier = identifier,
Amount = 0
};
}
private async Task<User?> GetCurrentUser()

View file

@ -91,6 +91,7 @@ namespace Moonlight
builder.Services.AddScoped<NotificationAdminService>();
builder.Services.AddScoped<NotificationClientService>();
builder.Services.AddScoped<ModalService>();
builder.Services.AddScoped<SmartDeployService>();
builder.Services.AddScoped<GoogleOAuth2Service>();
builder.Services.AddScoped<DiscordOAuth2Service>();

View file

@ -161,7 +161,8 @@
await JsRuntime.InvokeVoidAsync("document.body.removeAttribute", "data-kt-app-page-loading");
await JsRuntime.InvokeVoidAsync("KTMenu.createInstances");
await JsRuntime.InvokeVoidAsync("KTDrawer.createInstances");
await JsRuntime.InvokeVoidAsync("createSnow");
//await JsRuntime.InvokeVoidAsync("createSnow");
await SessionService.Register();

View file

@ -0,0 +1,168 @@
@page "/admin/subscriptions/edit/{Id:int}"
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@inject NavigationManager NavigationManager
@inject SubscriptionRepository SubscriptionRepository
@inject SubscriptionAdminService SubscriptionAdminService
<OnlyAdmin>
<div class="card card-body p-10">
<LazyLoader Load="Load">
@if (Subscription == null)
{
<div class="alert alert-danger">
No subscription with this id has been found
</div>
}
else
{
<SmartForm Model="Model" OnValidSubmit="OnSubmit">
<label class="form-label">
<TL>Name</TL>
</label>
<div class="input-group mb-5">
<InputText @bind-Value="Model.Name" class="form-control"></InputText>
</div>
<label class="form-label">
<TL>Description</TL>
</label>
<div class="input-group mb-5">
<InputText @bind-Value="Model.Description" class="form-control"></InputText>
</div>
<div>
@foreach (var limitPart in Limits.Chunk(3))
{
<div class="row row-cols-3 mb-5">
@foreach (var limit in limitPart)
{
<div class="col">
<div class="card card-body border">
<label class="form-label">
<TL>Identifier</TL>
</label>
<div class="input-group mb-5">
<input @bind="limit.Identifier" type="text" class="form-control">
</div>
<label class="form-label">
<TL>Amount</TL>
</label>
<div class="input-group mb-5">
<input @bind="limit.Amount" type="number" class="form-control">
</div>
<div class="d-flex flex-column mb-15 fv-row">
<div class="fs-5 fw-bold form-label mb-3">
<TL>Options</TL>
</div>
<div class="table-responsive">
<div class="dataTables_wrapper dt-bootstrap4 no-footer">
<div class="table-responsive">
<table class="table align-middle table-row-dashed fw-semibold fs-6 gy-5 dataTable no-footer">
<thead>
<tr class="text-start text-muted fw-bold fs-7 text-uppercase gs-0">
<th class="pt-0 sorting_disabled">
<TL>Key</TL>
</th>
<th class="pt-0 sorting_disabled">
<TL>Value</TL>
</th>
<th class="pt-0 text-end sorting_disabled">
<TL>Remove</TL>
</th>
</tr>
</thead>
<tbody>
@foreach (var option in limit.Options)
{
<tr class="odd">
<td>
<input @bind="option.Key" type="text" class="form-control form-control-solid">
</td>
<td>
<input @bind="option.Value" type="text" class="form-control form-control-solid">
</td>
<td class="text-end">
<button @onclick="() => limit.Options.Remove(option)" type="button" class="btn btn-icon btn-flex btn-active-light-primary w-30px h-30px me-3" data-kt-action="field_remove">
<i class="bx bx-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="row">
<div class="col-sm-12 col-md-5 d-flex align-items-center justify-content-center justify-content-md-start"></div>
<div class="col-sm-12 col-md-7 d-flex align-items-center justify-content-center justify-content-md-end"></div>
</div>
</div>
</div>
<div class="btn-group mt-5">
<button @onclick:preventDefault @onclick="() => limit.Options.Add(new())" type="button" class="btn btn-light-primary me-auto">Add option</button>
<button @onclick:preventDefault @onclick="() => Limits.Remove(limit)" class="btn btn-danger float-end">
<i class="bx bx-trash"></i>
</button>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
<div class="float-end">
<button @onclick:preventDefault @onclick="() => Limits.Add(new())" class="btn btn-primary">
<TL>Add new limit</TL>
</button>
<button type="submit" class="btn btn-success">
<TL>Save subscription</TL>
</button>
</div>
</SmartForm>
}
</LazyLoader>
</div>
</OnlyAdmin>
@code
{
[Parameter]
public int Id { get; set; }
private Subscription? Subscription;
private SubscriptionDataModel Model = new();
private List<SubscriptionLimit> Limits = new();
private async Task OnSubmit()
{
Subscription!.Name = Model.Name;
Subscription.Description = Model.Description;
SubscriptionRepository.Update(Subscription);
await SubscriptionAdminService.SaveLimits(Subscription, Limits.ToArray());
NavigationManager.NavigateTo("/admin/subscriptions");
}
private async Task Load(LazyLoader arg)
{
Subscription = SubscriptionRepository
.Get()
.FirstOrDefault(x => x.Id == Id);
if (Subscription != null)
{
Model.Name = Subscription.Name;
Model.Description = Subscription.Description;
Limits = (await SubscriptionAdminService.GetLimits(Subscription)).ToList();
}
}
}

View file

@ -3,10 +3,15 @@
@using Moonlight.App.Database.Entities
@using Moonlight.App.Repositories
@using BlazorTable
@using Moonlight.App.Services.Interop
@inject SmartTranslateService SmartTranslateService
@inject SubscriptionRepository SubscriptionRepository
@inject SubscriptionAdminService SubscriptionAdminService
@inject AlertService AlertService
@inject ClipboardService ClipboardService
<OnlyAdmin>
<div class="card">
<LazyLoader @ref="LazyLoader" Load="Load">
@ -36,9 +41,16 @@
</a>
</Template>
</Column>
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false">
<Column TableItem="Subscription" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false">
<Template>
<DeleteButton Confirm="true" OnClick="() => Delete(context)" />
<div class="float-end">
<WButton Text="@(SmartTranslateService.Translate("Create code"))"
WorkingText="@(SmartTranslateService.Translate("Working"))"
CssClasses="btn-primary"
OnClick="() => GenerateCode(context)">
</WButton>
<DeleteButton Confirm="true" OnClick="() => Delete(context)"/>
</div>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
@ -69,4 +81,21 @@
await LazyLoader.Reload();
}
private async Task GenerateCode(Subscription subscription)
{
var durationText = await AlertService.Text(
SmartTranslateService.Translate("Duration"),
SmartTranslateService.Translate("Enter duration of subscription"),
"30"
);
if (int.TryParse(durationText, out int duration))
{
var code = await SubscriptionAdminService.GenerateCode(subscription, duration);
await ClipboardService.Copy(code);
await AlertService.Success(SmartTranslateService.Translate("Copied code to clipboard"));
}
}
}

View file

@ -0,0 +1,112 @@
@page "/profile/subscriptions"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services.Interop
@inject ConfigService ConfigService
@inject AlertService AlertService
@inject SubscriptionService SubscriptionService
@inject SmartTranslateService SmartTranslateService
<ProfileNavigation Index="2"/>
<div class="card mb-3">
<div class="row g-0">
<div class="col-md-4 p-10">
<img src="/assets/media/svg/subscription.svg" class="img-fluid rounded-start" alt="Subscription">
</div>
<div class="col-md-8">
<div class="card-body">
<LazyLoader @ref="LazyLoader" Load="Load">
@if (Subscription == null)
{
var config = ConfigService
.GetSection("Moonlight")
.GetSection("Subscriptions")
.GetSection("Sellpass");
var enableSellpass = config.GetValue<bool>("Enable");
var url = config.GetValue<string>("Url");
<h3 class="mb-2">
<div class="input-group mb-3">
<input @bind="Code" type="text" class="form-control" placeholder="@(SmartTranslateService.Translate("Enter code"))">
<WButton Text="@(SmartTranslateService.Translate("Submit"))"
WorkingText="@(SmartTranslateService.Translate("Working"))"
CssClasses="btn btn-primary"
OnClick="OnSubmit">
</WButton>
</div>
</h3>
if (enableSellpass)
{
<div class="d-flex justify-content-end pb-0 px-0">
<a href="@(url)" class="btn btn-light">Buy subscription</a>
</div>
}
}
else
{
var d = User.SubscriptionSince.AddDays(User.SubscriptionDuration).ToUniversalTime();
<h3 class="mb-2">
<TL>Active until</TL> @(Formatter.FormatDateOnly(d))
</h3>
<p class="fs-5 text-gray-600 fw-semibold">
<TL>Current subscription</TL>: @(Subscription.Name)
</p>
<p class="fs-6 text-gray-600 fw-semibold">
@(Subscription.Description)
</p>
<p class="fs-7 text-gray-600 fw-semibold">
<TL>We will send you a notification upon subscription expiration</TL>
</p>
<div class="d-flex justify-content-end pb-0 px-0">
<WButton Text="@(SmartTranslateService.Translate("Cancel"))"
WorkingText="@(SmartTranslateService.Translate("Working"))"
CssClasses="btn btn-light"
OnClick="Cancel">
</WButton>
</div>
}
</LazyLoader>
</div>
</div>
</div>
</div>
@code
{
[CascadingParameter]
public User User { get; set; }
private Subscription? Subscription;
private LazyLoader LazyLoader;
private string Code = "";
private async Task Load(LazyLoader arg)
{
Subscription = await SubscriptionService.GetCurrent();
}
private async Task Cancel()
{
if (await AlertService.ConfirmMath())
{
await SubscriptionService.Cancel();
await LazyLoader.Reload();
}
}
private async Task OnSubmit()
{
await SubscriptionService.ApplyCode(Code);
Code = "";
await LazyLoader.Reload();
}
}

View file

@ -0,0 +1,226 @@
@page "/servers/create"
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Repositories.Servers
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Exceptions
@inject SubscriptionService SubscriptionService
@inject ImageRepository ImageRepository
@inject SmartTranslateService SmartTranslateService
@inject SmartDeployService SmartDeployService
@inject ServerRepository ServerRepository
@inject NavigationManager NavigationManager
@inject ServerService ServerService
<LazyLoader Load="Load">
@if (DeployNode == null)
{
<div class="d-flex justify-content-center flex-center">
<div class="card">
<img src="/assets/media/svg/nodata.svg" class="card-img-top w-25 mx-auto pt-5" alt="Not found image"/>
<div class="card-body text-center">
<h4 class="card-title">
<TL>No node found</TL>
</h4>
<p class="card-text">
<TL>No node found to deploy to found</TL>
</p>
</div>
</div>
</div>
}
else
{
<div class="d-flex flex-column flex-lg-row">
<div class="w-100 flex-lg-row-auto w-lg-300px mb-7 me-7 me-lg-10" data-select2-id="select2-data-131-dr2d">
<div class="card card-flush py-4" data-select2-id="select2-data-130-ru5y">
<div class="card-header">
<div class="card-title">
<h2>
<TL>Server details</TL>
</h2>
</div>
</div>
<div class="card-body pt-0">
<div class="d-flex flex-column gap-10">
<div class="fv-row">
<label class="form-label">Node</label>
<div class="fw-bold fs-3">@(DeployNode.Name)</div>
</div>
@if (Model.Image != null)
{
var limit = Images[Model.Image];
<div class="fv-row">
<label class="form-label"><TL>Image</TL></label>
<div class="fw-bold fs-3">@(Model.Image.Name)</div>
</div>
<div class="fv-row">
<label class="form-label"><TL>CPU</TL></label>
<div class="fw-bold fs-3">
@{
var cpu = limit.ReadValue("cpu");
if (cpu == null)
cpu = "N/A";
else
cpu = (int.Parse(cpu) / 100).ToString();
}
@(cpu) <TL>Cores</TL>
</div>
</div>
<div class="fv-row">
<label class="form-label"><TL>Memory</TL></label>
<div class="fw-bold fs-3">@(limit.ReadValue("memory")) MB</div>
</div>
<div class="fv-row">
<label class="form-label"><TL>Disk</TL></label>
<div class="fw-bold fs-3">@(limit.ReadValue("disk")) MB</div>
</div>
}
</div>
</div>
</div>
</div>
<div class="d-flex flex-column flex-lg-row-fluid gap-7 gap-lg-10">
<div class="card card-flush py-4">
<div class="card-header">
<div class="card-title">
<h2>
<TL>Configure your server</TL>
</h2>
</div>
</div>
<div class="card-body pt-0">
<SmartForm Model="Model" OnValidSubmit="OnValidSubmit">
<label class="form-label">
<TL>Name</TL>
</label>
<div class="input-group mb-5">
<InputText @bind-Value="Model.Name" class="form-control"></InputText>
</div>
@if (Images.Any())
{
<label class="form-label">
<TL>Image</TL>
</label>
<SmartSelect TField="Image"
@bind-Value="Model.Image"
Items="Images.Keys.ToArray()"
DisplayField="@(x => x.Name)">
</SmartSelect>
<button type="submit" class="mt-5 float-end btn btn-primary">
<TL>Create</TL>
</button>
}
else
{
<div class="alert alert-warning d-flex align-items-center p-5 mb-10">
<span>
<TL>You reached the maximum amount of servers for every image of your subscription</TL>: @(Subscription == null ? SmartTranslateService.Translate("Default") : Subscription.Name)
</span>
</div>
}
</SmartForm>
</div>
</div>
</div>
</div>
}
</LazyLoader>
@code
{
[CascadingParameter]
public User User { get; set; }
private Node? DeployNode;
private Subscription? Subscription;
private Dictionary<Image, SubscriptionLimit> Images = new();
private ServerOrderDataModel Model = new();
private async Task Load(LazyLoader lazyLoader)
{
// Reset state
Images.Clear();
Model = new();
await lazyLoader.SetText(SmartTranslateService.Translate("Loading your subscription"));
Subscription = await SubscriptionService.GetCurrent();
await lazyLoader.SetText(SmartTranslateService.Translate("Searching for deploy node"));
DeployNode = await SmartDeployService.GetNode();
await lazyLoader.SetText(SmartTranslateService.Translate("Searching for available images"));
var images = ImageRepository.Get().ToArray();
foreach (var image in images)
{
var limit = await SubscriptionService.GetLimit("image." + image.Id);
if (limit.Amount > 0)
{
var serversCount = ServerRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.Image)
.Where(x => x.Owner.Id == User.Id)
.Count(x => x.Image.Id == image.Id);
if(serversCount < limit.Amount)
Images.Add(image, limit);
}
}
}
private async Task OnValidSubmit()
{
var limit = await SubscriptionService.GetLimit("image." + Model.Image.Id);
if (limit.Amount > 0)
{
var serversCount = ServerRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.Image)
.Where(x => x.Owner.Id == User.Id)
.Count(x => x.Image.Id == Model.Image.Id);
if (serversCount < limit.Amount)
{
if(int.TryParse(limit.ReadValue("cpu"), out int cpu) &&
int.TryParse(limit.ReadValue("memory"), out int memory) &&
int.TryParse(limit.ReadValue("disk"), out int disk))
{
var server = await ServerService.Create(
Model.Name,
cpu,
memory,
disk,
User,
Model.Image,
DeployNode
);
NavigationManager.NavigateTo($"/server/{server.Uuid}");
}
else
{
throw new DisplayException("Limits cannot be parsed");
}
}
}
}
}

View file

@ -462,8 +462,3 @@ Create subscription;Create subscription
Options;Options
Amount;Amount
Do you really want to delete it?;Do you really want to delete it?
Change your password;Change your password
You need to change your password in order to use moonlight;You need to change your password in order to use moonlight
You need to enter your full name in order to use moonlight;You need to enter your full name in order to use moonlight
Enter your information;Enter your information
The field FirstName must be a string or array type with a minimum length of '2'.;The field FirstName must be a string or array type with a minimum length of '2'.

View file

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" width="733.82" height="503.768" viewBox="0 0 733.82 503.768" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Group_16" data-name="Group 16" transform="translate(-196.555 -165.086)">
<path id="Path_204-85" data-name="Path 204" d="M261.846,378.459a45.126,45.126,0,1,1,0-90.252H672.56a45.126,45.126,0,1,1,0,90.252Z" transform="translate(-20.165 -123.12)" fill="#e6e6e6"/>
<path id="Path_205-86" data-name="Path 205" d="M264.96,297.207a39.24,39.24,0,0,0,0,78.48H675.674a39.24,39.24,0,1,0,0-78.48Z" transform="translate(-23.279 -126.234)" fill="#fff"/>
<rect id="Rectangle_15" data-name="Rectangle 15" width="1.308" height="78.48" transform="translate(364.961 170.972)" fill="#e6e6e6"/>
<rect id="Rectangle_17" data-name="Rectangle 17" width="1.308" height="78.48" transform="translate(527.808 170.972)" fill="#e6e6e6"/>
<rect id="Rectangle_7" data-name="Rectangle 7" width="161.539" height="78.48" transform="translate(366.269 170.972)" fill="#6c63ff"/>
<path id="Path_198-87" data-name="Path 198" d="M276.268,206.815a24,24,0,1,0,24,24,24,24,0,0,0-24-24Zm0,7.2a7.2,7.2,0,1,1-7.2,7.2A7.2,7.2,0,0,1,276.268,214.016Zm0,34.662a17.484,17.484,0,0,1-14.4-7.685c.115-4.8,9.6-7.442,14.4-7.442s14.285,2.642,14.4,7.442a17.513,17.513,0,0,1-14.4,7.685Z" transform="translate(170.844 -20.53)" fill="#fff"/>
<path id="Path_200-88" data-name="Path 200" d="M620.7,206.815a24,24,0,1,0,24,24,24,24,0,0,0-24-24Zm0,7.2a7.2,7.2,0,1,1-7.2,7.2A7.2,7.2,0,0,1,620.7,214.015Zm0,34.662a17.484,17.484,0,0,1-14.4-7.685c.115-4.8,9.6-7.442,14.4-7.442s14.285,2.642,14.4,7.442a17.513,17.513,0,0,1-14.4,7.685Z" transform="translate(-336.439 -20.53)" fill="#e6e6e6"/>
<path id="Path_242-89" data-name="Path 242" d="M620.7,206.815a24,24,0,1,0,24,24,24,24,0,0,0-24-24Zm0,7.2a7.2,7.2,0,1,1-7.2,7.2A7.2,7.2,0,0,1,620.7,214.015Zm0,34.662a17.484,17.484,0,0,1-14.4-7.685c.115-4.8,9.6-7.442,14.4-7.442s14.285,2.642,14.4,7.442a17.513,17.513,0,0,1-14.4,7.685Z" transform="translate(-10.892 -20.53)" fill="#e6e6e6"/>
<rect id="Rectangle_9" data-name="Rectangle 9" width="56" height="56" rx="6" transform="translate(419.112 288.229)" fill="#6c63ff"/>
<ellipse id="Ellipse_29" data-name="Ellipse 29" cx="134.439" cy="18" rx="134.439" ry="18" transform="translate(661.497 632.854)" fill="#e6e6e6"/>
<rect id="Rectangle_12" data-name="Rectangle 12" width="56" height="56" rx="6" transform="translate(581.812 288.049)" fill="#e6e6e6"/>
<rect id="Rectangle_13" data-name="Rectangle 13" width="40.798" height="40.798" transform="translate(589.812 295.83)" fill="#fff"/>
<path id="Path_202-90" data-name="Path 202" d="M253.345,218.766l-7.075-9.1,4.114-3.2,3.35,4.307,11.318-11.946,3.785,3.585Z" transform="translate(191.14 106.158)" fill="#fff"/>
<path id="Path_203-91" data-name="Path 203" d="M425.345,218.766l-7.075-9.1,4.114-3.2,3.35,4.307,11.317-11.946,3.785,3.585Z" transform="translate(182.106 106.158)" fill="#e6e6e6"/>
<rect id="Rectangle_18" data-name="Rectangle 18" width="56" height="56" rx="6" transform="translate(256.265 288.049)" fill="#e6e6e6"/>
<rect id="Rectangle_19" data-name="Rectangle 19" width="40.798" height="40.798" transform="translate(264.265 295.83)" fill="#fff"/>
<path id="Path_243-92" data-name="Path 243" d="M425.345,218.766l-7.075-9.1,4.114-3.2,3.35,4.307,11.317-11.946,3.785,3.585Z" transform="translate(-143.441 106.158)" fill="#e6e6e6"/>
<g id="Group_15" data-name="Group 15">
<path id="Path_257-93" data-name="Path 257" d="M340.66,397.363H327.48l-6.268-50.837,19.452,0Z" transform="translate(545.904 239.259)" fill="#ffb8b8"/>
<path id="Path_258-94" data-name="Path 258" d="M320.6,387.355h25.418v16H304.6a16,16,0,0,1,16-16Z" transform="translate(543.364 245.5)" fill="#2f2e41"/>
<path id="Path_259-95" data-name="Path 259" d="M223.865,397.363h-13.18l-6.268-50.837,19.452,0Z" transform="translate(528.049 239.259)" fill="#ffb8b8"/>
<path id="Path_260-96" data-name="Path 260" d="M203.81,387.355h25.418v16H187.806a16,16,0,0,1,16-16Z" transform="translate(525.51 245.5)" fill="#2f2e41"/>
<path id="Path_261-97" data-name="Path 261" d="M487.471,249.585V243.82a37.18,37.18,0,0,1,37.18-37.18h0a37.18,37.18,0,0,1,37.18,37.18v5.764a26.8,26.8,0,0,1-26.8,26.8H514.275a26.8,26.8,0,0,1-26.8-26.8Z" transform="translate(308.465 9.946)" fill="#2f2e41"/>
<ellipse id="Ellipse_36" data-name="Ellipse 36" cx="28.316" cy="28.316" rx="28.316" ry="28.316" transform="translate(804.801 231.687)" fill="#ffb8b8"/>
<path id="Path_263-98" data-name="Path 263" d="M386.583,329.1a10.811,10.811,0,0,1,16.463,1.934l24.273-4.591,6.388,14.07-34.37,6A10.869,10.869,0,0,1,386.583,329.1Z" transform="translate(292.514 28.216)" fill="#ffb8b8"/>
<path id="Path_264-99" data-name="Path 264" d="M515.087,284.516l.317.481-39.8,26.221-67.164,21.447a4.044,4.044,0,0,0-2.781,4.31l1.465,12.62a4.036,4.036,0,0,0,4.854,3.48l63.212-13.549a22.833,22.833,0,0,0,8.5-3.742L528.4,303.969A11.5,11.5,0,0,0,515.4,285Z" transform="translate(295.954 21.634)" fill="#ccc"/>
<path id="Path_265-100" data-name="Path 265" d="M574.076,590.876a5.209,5.209,0,0,1-4.771-3.115l-60.421-149.3a1.729,1.729,0,0,0-3.238.182L456.351,583.993a5.189,5.189,0,0,1-6.781,3.333l-16.53-6.2a5.175,5.175,0,0,1-3.34-4.271c-7.437-64.782,57.413-228.3,58.069-229.946l.182-.455,59.116,13.077.123.134c23.585,25.73,42.971,188.012,46.618,220.283a5.163,5.163,0,0,1-3.425,5.472l-14.591,5.16a5.139,5.139,0,0,1-1.716.295Z" transform="translate(299.543 31.32)" fill="#2f2e41"/>
<path id="Path_266-101" data-name="Path 266" d="M515.547,375.9c-14.323,0-30.291-2.856-35.206-14.642l-.113-.271.153-.251c3.88-6.366,9.007-17.224,6.251-19.263-5.429-4.014-8.064-10.618-7.83-19.628.508-19.559,13.835-36.925,33.163-43.212h0a147.146,147.146,0,0,1,16.443-4.234,27.993,27.993,0,0,1,23.21,5.732,28.276,28.276,0,0,1,10.486,21.755c.2,20.9-3.015,50.015-19.5,70a5.128,5.128,0,0,1-3.036,1.765A140.9,140.9,0,0,1,515.547,375.9Z" transform="translate(307.138 20.219)" fill="#ccc"/>
<path id="Path_267-102" data-name="Path 267" d="M506.106,364.845a11.017,11.017,0,0,1,13.464-7.683,10.843,10.843,0,0,1,1.669.618l18.43-16.773,12.818,8.635L526.13,372.966a11,11,0,0,1-12.466,5.288,10.83,10.83,0,0,1-7.558-13.409Z" transform="translate(311.251 30.487)" fill="#ffb8b8"/>
<path id="Path_268-103" data-name="Path 268" d="M534.283,373.874A5.174,5.174,0,0,1,531,372.7l-7.268-5.939a5.188,5.188,0,0,1,.126-8.134l30.484-23.38a1.733,1.733,0,0,0,.327-2.415l-18.815-24.875a15.316,15.316,0,0,1,1.023-19.731h0a15.273,15.273,0,0,1,20.622-1.649l.119.126,19.647,28.133a17.515,17.515,0,0,1-.415,27.883l-39.481,30.134a5.2,5.2,0,0,1-3.088,1.017Z" transform="translate(313.718 21.67)" fill="#ccc"/>
<path id="Path_269-104" data-name="Path 269" d="M497.965,240.705V226.656L523.047,215.7l23.916,10.952v14.049a2.306,2.306,0,0,1-2.306,2.306H500.271a2.306,2.306,0,0,1-2.306-2.306Z" transform="translate(310.07 11.332)" fill="#2f2e41"/>
<circle id="Ellipse_30" data-name="Ellipse 30" cx="15.722" cy="15.722" r="15.722" transform="translate(838.852 199.377)" fill="#2f2e41"/>
<path id="Path_185-105" data-name="Path 185" d="M896.5,218.806a15.715,15.715,0,0,1,18.8-15.417,15.715,15.715,0,1,0-9.764,29.629,15.709,15.709,0,0,1-9.032-14.212Z" transform="translate(-56.438 -12.141)" fill="#2f2e41"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB