Compare commits

..

7 Commits

38 changed files with 697 additions and 198 deletions

View File

@ -200,6 +200,8 @@ public class Controller : ControllerBase
bucketPath = "s3://" + installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodioHome) else if (installation.Product == (int)ProductType.SodioHome)
bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodistoreGrid)
bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp;
else else
bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp;
Console.WriteLine("Fetching data for "+startTimestamp); Console.WriteLine("Fetching data for "+startTimestamp);
@ -537,16 +539,29 @@ public class Controller : ControllerBase
public ActionResult<IEnumerable<Installation>> GetAllSodioHomeInstallations(Token authToken) public ActionResult<IEnumerable<Installation>> GetAllSodioHomeInstallations(Token authToken)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user is null) if (user is null)
return Unauthorized(); return Unauthorized();
return user return user
.AccessibleInstallations(product:(int)ProductType.SodioHome) .AccessibleInstallations(product:(int)ProductType.SodioHome)
.ToList(); .ToList();
} }
[HttpGet(nameof(GetAllSodistoreGridInstallations))]
public ActionResult<IEnumerable<Installation>> GetAllSodistoreGridInstallations(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null)
return Unauthorized();
return user
.AccessibleInstallations(product:(int)ProductType.SodistoreGrid)
.ToList();
}
[HttpGet(nameof(GetAllFolders))] [HttpGet(nameof(GetAllFolders))]
public ActionResult<IEnumerable<Folder>> GetAllFolders(Token authToken) public ActionResult<IEnumerable<Folder>> GetAllFolders(Token authToken)
@ -1513,6 +1528,7 @@ public class Controller : ControllerBase
0 => config.GetConfigurationSalimax(), // Salimax 0 => config.GetConfigurationSalimax(), // Salimax
3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax 3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax
2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome 2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome
4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid
_ => config.GetConfigurationString() // fallback _ => config.GetConfigurationString() // fallback
}; };
@ -1719,6 +1735,17 @@ public class Controller : ControllerBase
"AlarmKnowledgeBaseChecked.cs"); "AlarmKnowledgeBaseChecked.cs");
} }
[HttpGet(nameof(DryRunS3Cleanup))]
public async Task<ActionResult<String>> DryRunS3Cleanup(Token authToken, long? installationId = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var result = await DeleteOldData.DeleteOldDataFromS3.DryRun(installationId);
return Ok(result);
}
} }

View File

@ -48,6 +48,12 @@ public class Configuration
$"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ $"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}"; $"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}";
} }
// TODO: SodistoreGrid — update configuration fields when defined
public string GetConfigurationSodistoreGrid()
{
return "";
}
} }
public enum CalibrationChargeType public enum CalibrationChargeType

View File

@ -7,7 +7,8 @@ public enum ProductType
Salimax = 0, Salimax = 0,
Salidomo = 1, Salidomo = 1,
SodioHome =2, SodioHome =2,
SodiStoreMax=3 SodiStoreMax=3,
SodistoreGrid=4
} }
public enum StatusType public enum StatusType

View File

@ -145,6 +145,7 @@ public static class ExoCmd
const String method = "iam-role"; const String method = "iam-role";
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
@ -263,42 +264,70 @@ public static class ExoCmd
public static async Task<Boolean> RevokeReadKey(this Installation installation) public static async Task<Boolean> RevokeReadKey(this Installation installation)
{ {
//Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature
var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3Key}"; var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3Key}";
var method = $"access-key/{installation.S3Key}"; var method = $"access-key/{installation.S3Key}";
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60; var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60;
var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime); var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime);
var client = new HttpClient(); var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader); new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader);
var response = await client.DeleteAsync(url); try
return response.IsSuccessStatusCode; {
var response = await client.DeleteAsync(url);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"Successfully revoked read key for installation {installation.Id}.");
return true;
}
Console.WriteLine($"Failed to revoke read key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"Error occurred while revoking read key: {ex.Message}");
return false;
}
} }
public static async Task<Boolean> RevokeWriteKey(this Installation installation) public static async Task<Boolean> RevokeWriteKey(this Installation installation)
{ {
//Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature
var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3WriteKey}"; var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3WriteKey}";
var method = $"access-key/{installation.S3WriteKey}"; var method = $"access-key/{installation.S3WriteKey}";
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60; var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60;
var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime); var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime);
var client = new HttpClient(); var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader); new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader);
var response = await client.DeleteAsync(url); try
return response.IsSuccessStatusCode; {
var response = await client.DeleteAsync(url);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"Successfully revoked write key for installation {installation.Id}.");
return true;
}
Console.WriteLine($"Failed to revoke write key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"Error occurred while revoking write key: {ex.Message}");
return false;
}
} }
public static async Task<(String, String)> CreateWriteKey(this Installation installation) public static async Task<(String, String)> CreateWriteKey(this Installation installation)
@ -320,6 +349,7 @@ public static class ExoCmd
const String method = "iam-role"; const String method = "iam-role";
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
var contentString = $$""" var contentString = $$"""
@ -371,10 +401,35 @@ public static class ExoCmd
return await s3Region.PutBucket(installation.BucketName()) != null; return await s3Region.PutBucket(installation.BucketName()) != null;
} }
public static async Task<Boolean> DeleteBucket(this Installation installation) public static async Task<Boolean> PurgeAndDeleteBucket(this Installation installation)
{ {
var s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!); var s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!);
return await s3Region.DeleteBucket(installation.BucketName()) ; var bucket = s3Region.Bucket(installation.BucketName());
try
{
var purged = await bucket.PurgeBucket();
if (!purged)
{
Console.WriteLine($"Failed to purge bucket {installation.BucketName()} for installation {installation.Id}.");
return false;
}
var deleted = await s3Region.DeleteBucket(installation.BucketName());
if (!deleted)
{
Console.WriteLine($"Failed to delete bucket {installation.BucketName()} for installation {installation.Id}.");
return false;
}
Console.WriteLine($"Successfully purged and deleted bucket {installation.BucketName()} for installation {installation.Id}.");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Error occurred while purging/deleting bucket {installation.BucketName()}: {ex.Message}");
return false;
}
} }

View File

@ -10,6 +10,7 @@ public static class InstallationMethods
private static readonly String BucketNameSalt = "3e5b3069-214a-43ee-8d85-57d72000c19d"; private static readonly String BucketNameSalt = "3e5b3069-214a-43ee-8d85-57d72000c19d";
private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"; private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e";
private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa"; private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa";
private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8";
public static String BucketName(this Installation installation) public static String BucketName(this Installation installation)
{ {
@ -17,12 +18,17 @@ public static class InstallationMethods
{ {
return $"{installation.S3BucketId}-{BucketNameSalt}"; return $"{installation.S3BucketId}-{BucketNameSalt}";
} }
if (installation.Product == (int)ProductType.SodioHome) if (installation.Product == (int)ProductType.SodioHome)
{ {
return $"{installation.S3BucketId}-{SodioHomeBucketNameSalt}"; return $"{installation.S3BucketId}-{SodioHomeBucketNameSalt}";
} }
if (installation.Product == (int)ProductType.SodistoreGrid)
{
return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}";
}
return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}"; return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}";
} }

View File

@ -239,7 +239,7 @@ public static class SessionMethods
} }
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome) if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid)
{ {
return user is not null return user is not null
&& user.UserType != 0 && user.UserType != 0
@ -295,9 +295,9 @@ public static class SessionMethods
.Apply(Db.Update); .Apply(Db.Update);
} }
if (installation.Product == (int)ProductType.SodiStoreMax) if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid)
{ {
return user is not null return user is not null
&& installation is not null && installation is not null
&& original is not null && original is not null
@ -305,7 +305,7 @@ public static class SessionMethods
&& user.HasAccessTo(installation) && user.HasAccessTo(installation)
&& installation && installation
.WithParentOf(original) // prevent moving .WithParentOf(original) // prevent moving
.Apply(Db.Update); .Apply(Db.Update);
} }
@ -324,23 +324,22 @@ public static class SessionMethods
{ {
var user = session?.User; var user = session?.User;
if (user is not null if (user is null || installation is null || user.UserType == 0)
&& installation is not null return false;
&& user.UserType != 0)
{
return
Db.Delete(installation)
&& await installation.RevokeReadKey()
&& await installation.RevokeWriteKey()
&& await installation.RemoveReadRole()
&& await installation.RemoveWriteRole()
&& await installation.DeleteBucket();
}
return false; // Try all Exoscale operations independently (don't short-circuit)
var readKeyOk = await installation.RevokeReadKey();
var writeKeyOk = await installation.RevokeWriteKey();
var readRoleOk = await installation.RemoveReadRole();
var writeRoleOk = await installation.RemoveWriteRole();
var bucketOk = await installation.PurgeAndDeleteBucket();
if (!readKeyOk || !writeKeyOk || !readRoleOk || !writeRoleOk || !bucketOk)
Console.WriteLine($"[Delete] Partial Exoscale cleanup for installation {installation.Id}: " +
$"readKey={readKeyOk}, writeKey={writeKeyOk}, readRole={readRoleOk}, writeRole={writeRoleOk}, bucket={bucketOk}");
// Always delete from DB (best-effort — admin wants it gone)
return Db.Delete(installation);
} }
public static Boolean Create(this Session? session, User newUser) public static Boolean Create(this Session? session, User newUser)

View File

@ -71,6 +71,11 @@ public static partial class Db
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
// One-time migration: rebrand to inesco Energy
Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'");
Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'");
Connection.Execute("UPDATE User SET Name = 'inesco Energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
//UpdateKeys(); //UpdateKeys();
CleanupSessions().SupressAwaitWarning(); CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning(); DeleteSnapshots().SupressAwaitWarning();

View File

@ -102,10 +102,28 @@ public static partial class Db
InstallationAccess.Delete(i => i.InstallationId == installation.Id); InstallationAccess.Delete(i => i.InstallationId == installation.Id);
if (installation.Product == (int)ProductType.Salimax) if (installation.Product == (int)ProductType.Salimax)
{ {
//For Salimax, delete the OrderNumber2Installation entries associated with this installation id.
OrderNumber2Installation.Delete(i => i.InstallationId == installation.Id); OrderNumber2Installation.Delete(i => i.InstallationId == installation.Id);
} }
// Clean up AI insight cache entries linked to this installation's reports
var weeklyIds = WeeklyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
var monthlyIds = MonthlyReports.Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
var yearlyIds = YearlyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
foreach (var id in weeklyIds) AiInsightCaches.Delete(c => c.ReportType == "weekly" && c.ReportId == id);
foreach (var id in monthlyIds) AiInsightCaches.Delete(c => c.ReportType == "monthly" && c.ReportId == id);
foreach (var id in yearlyIds) AiInsightCaches.Delete(c => c.ReportType == "yearly" && c.ReportId == id);
// Clean up energy records, report summaries, errors, warnings, and user actions
DailyRecords .Delete(r => r.InstallationId == installation.Id);
HourlyRecords .Delete(r => r.InstallationId == installation.Id);
WeeklyReports .Delete(r => r.InstallationId == installation.Id);
MonthlyReports.Delete(r => r.InstallationId == installation.Id);
YearlyReports .Delete(r => r.InstallationId == installation.Id);
Errors .Delete(e => e.InstallationId == installation.Id);
Warnings .Delete(w => w.InstallationId == installation.Id);
UserActions .Delete(a => a.InstallationId == installation.Id);
return Installations.Delete(i => i.Id == installation.Id) > 0; return Installations.Delete(i => i.Id == installation.Id) > 0;
} }
} }

View File

@ -1,94 +1,157 @@
using System.Diagnostics;
using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.Utils; using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
namespace InnovEnergy.App.Backend.DeleteOldData; namespace InnovEnergy.App.Backend.DeleteOldData;
public class DeleteOldDataFromS3 public static class DeleteOldDataFromS3
{ {
private static Timer? _cleanupTimer;
public static void DeleteFrom(Installation installation, int timestamps_to_delete) public static void StartScheduler()
{ {
var now = DateTime.UtcNow;
string configPath = "/home/ubuntu/.s3cfg"; var next = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc);
string bucketPath = installation.Product ==(int)ProductType.Salidomo ? $"s3://{installation.S3BucketId}-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/{timestamps_to_delete}*" : $"s3://{installation.S3BucketId}-3e5b3069-214a-43ee-8d85-57d72000c19d/{timestamps_to_delete}*" ; if (next <= now) next = next.AddDays(1);
//Console.WriteLine($"Deleting old data from {bucketPath}");
Console.WriteLine("Deleting data for timestamp prefix: " + timestamps_to_delete); _cleanupTimer = new Timer(
_ =>
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{ {
FileName = "s3cmd", try
Arguments = $"--config {configPath} rm {bucketPath}", {
RedirectStandardOutput = true, CleanupAllInstallations().GetAwaiter().GetResult();
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = new Process { StartInfo = startInfo };
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
Console.WriteLine("[s3cmd] " + e.Data);
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
Console.WriteLine("[s3cmd-ERR] " + e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
}
catch (Exception ex)
{
Console.WriteLine("Exception occurred during deletion: " + ex.Message);
}
}
public static async Task DeleteOldData()
{
while (true){
var installations = Db.Installations.ToList();
foreach (var installation in installations){
Console.WriteLine("DELETE S3 DATA FOR INSTALLATION "+installation.Name);
long oneYearAgoTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
Console.WriteLine("delete data before "+oneYearAgoTimestamp);
for (int lastDigit=4;lastDigit>=0; lastDigit--)
{
int timestamps_to_delete = int.Parse(oneYearAgoTimestamp.ToString().Substring(0, lastDigit+1));
timestamps_to_delete--;
Console.WriteLine(timestamps_to_delete);
while (true)
{
if (timestamps_to_delete % 10 == 0)
{
Console.WriteLine("delete " + timestamps_to_delete + "*");
DeleteFrom(installation,timestamps_to_delete);
break;
}
Console.WriteLine("delete " + timestamps_to_delete + "*");
DeleteFrom(installation,timestamps_to_delete);
timestamps_to_delete--;
}
}
} }
Console.WriteLine("FINISHED DELETING S3 DATA FOR ALL INSTALLATIONS\n"); catch (Exception ex)
{
await Task.Delay(TimeSpan.FromDays(1)); Console.Error.WriteLine($"[S3Cleanup] Scheduler error: {ex.Message}");
} }
},
null,
next - now,
TimeSpan.FromDays(1)
);
Console.WriteLine($"[S3Cleanup] Scheduled daily at 03:00 UTC, first run in {(next - now).TotalHours:F1}h");
} }
} private static async Task CleanupAllInstallations()
{
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
var cutoffKey = cutoffTimestamp.ToString();
var installations = Db.Installations.ToList();
Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}");
foreach (var installation in installations)
{
try
{
var s3Region = new S3Region(
$"https://{installation.S3Region}.{installation.S3Provider}",
ExoCmd.S3Credentials
);
var bucket = s3Region.Bucket(installation.BucketName());
Console.WriteLine($"[S3Cleanup] Processing {installation.Name} (bucket: {bucket.Name})");
var deleted = await DeleteObjectsBefore(bucket, cutoffKey);
Console.WriteLine($"[S3Cleanup] {installation.Name}: deleted {deleted} objects");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[S3Cleanup] Failed for {installation.Name}: {ex.Message}");
}
}
Console.WriteLine("[S3Cleanup] Finished cleanup for all installations");
}
public static async Task<string> DryRun(long? installationId = null)
{
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
var cutoffKey = cutoffTimestamp.ToString();
var allInstallations = Db.Installations.ToList();
var installations = installationId.HasValue
? allInstallations.Where(i => i.Id == installationId.Value).ToList()
: allInstallations;
var results = new List<string>();
results.Add($"Cutoff: {cutoffKey} ({DateTimeOffset.FromUnixTimeSeconds(cutoffTimestamp):yyyy-MM-dd HH:mm:ss} UTC)");
results.Add($"Installations: {installations.Count} (of {allInstallations.Count} total)");
results.Add("");
foreach (var installation in installations)
{
try
{
var s3Region = new S3Region(
$"https://{installation.S3Region}.{installation.S3Provider}",
ExoCmd.S3Credentials
);
var bucket = s3Region.Bucket(installation.BucketName());
var sampleKeys = new List<string>();
var hasOldData = false;
await foreach (var obj in bucket.ListObjects())
{
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
break;
hasOldData = true;
if (sampleKeys.Count < 5)
sampleKeys.Add(obj.Path);
else
break; // only need a sample, not full count
}
results.Add($"{installation.Name} (bucket: {bucket.Name})");
results.Add($" Has old data: {(hasOldData ? "YES" : "NO")}");
if (sampleKeys.Count > 0)
results.Add($" Sample keys: {string.Join(", ", sampleKeys)}");
results.Add("");
}
catch (Exception ex)
{
results.Add($"{installation.Name}: ERROR - {ex.Message}");
results.Add("");
}
}
return string.Join("\n", results);
}
private static async Task<int> DeleteObjectsBefore(S3Bucket bucket, string cutoffKey)
{
var totalDeleted = 0;
var keysToDelete = new List<string>();
await foreach (var obj in bucket.ListObjects())
{
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
break;
keysToDelete.Add(obj.Path);
if (keysToDelete.Count >= 1000)
{
if (await bucket.DeleteObjects(keysToDelete))
totalDeleted += keysToDelete.Count;
else
Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}");
keysToDelete.Clear();
}
}
if (keysToDelete.Count > 0)
{
if (await bucket.DeleteObjects(keysToDelete))
totalDeleted += keysToDelete.Count;
else
Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}");
}
return totalDeleted;
}
}

View File

@ -38,7 +38,7 @@ public static class Program
WebsocketManager.MonitorInstallationTable().SupressAwaitWarning(); WebsocketManager.MonitorInstallationTable().SupressAwaitWarning();
// Task.Run(() => DeleteOldDataFromS3.DeleteOldData()); DeleteOldDataFromS3.StartScheduler();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddProblemDetails(setup => builder.Services.AddProblemDetails(setup =>

View File

@ -16,6 +16,7 @@ public class Session : Relation<String, Int64>
public Boolean AccessToSalidomo { get; set; } = false; public Boolean AccessToSalidomo { get; set; } = false;
public Boolean AccessToSodistoreMax { get; set; } = false; public Boolean AccessToSodistoreMax { get; set; } = false;
public Boolean AccessToSodioHome { get; set; } = false; public Boolean AccessToSodioHome { get; set; } = false;
public Boolean AccessToSodistoreGrid { get; set; } = false;
[Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ; [Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ;
// Private backing field // Private backing field
@ -49,7 +50,8 @@ public class Session : Relation<String, Int64>
AccessToSalidomo = user.AccessibleInstallations(product: (int)ProductType.Salidomo).ToList().Count > 0; AccessToSalidomo = user.AccessibleInstallations(product: (int)ProductType.Salidomo).ToList().Count > 0;
AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0; AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0;
AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0; AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0;
AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0;
Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count); Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count);
Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count); Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count);
Console.WriteLine("sodio" + user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count); Console.WriteLine("sodio" + user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count);

View File

@ -100,6 +100,11 @@ public static class RabbitMqManager
monitorLink = monitorLink =
$"https://monitor.inesco.energy/sodistore_installations/list/installation/{installation.S3BucketId}/batteryview"; $"https://monitor.inesco.energy/sodistore_installations/list/installation/{installation.S3BucketId}/batteryview";
} }
else if (installation.Product == (int)ProductType.SodistoreGrid)
{
monitorLink =
$"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview";
}
else else
{ {
monitorLink = monitorLink =
@ -126,7 +131,7 @@ public static class RabbitMqManager
Console.WriteLine("Send replace battery email to the support team for installation "+installationId); Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
string recipient = "support@innov.energy"; string recipient = "support@innov.energy";
string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken"; string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken";
string text = $"Dear InnovEnergy Support Team,\n" + string text = $"Dear inesco Energy Support Team,\n" +
$"\n"+ $"\n"+
$"Installation Name: {installation.InstallationName}\n"+ $"Installation Name: {installation.InstallationName}\n"+
$"\n"+ $"\n"+
@ -138,7 +143,7 @@ public static class RabbitMqManager
$"\n"+ $"\n"+
$"Thank you for your great support:)"; $"Thank you for your great support:)";
//Disable this function now //Disable this function now
//Mailer.Send("InnovEnergy Support Team", recipient, subject, text); //Mailer.Send("inesco Energy Support Team", recipient, subject, text);
} }
//Create a new error and add it to the database //Create a new error and add it to the database
Db.HandleError(newError, installationId); Db.HandleError(newError, installationId);

View File

@ -30,7 +30,8 @@ public static class WebsocketManager
if ((installationConnection.Value.Product == (int)ProductType.Salimax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) || if ((installationConnection.Value.Product == (int)ProductType.Salimax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
(installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) || (installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) ||
(installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) || (installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) ||
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) (installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
(installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2))
) )
{ {
Console.WriteLine("Installation ID is " + installationConnection.Key); Console.WriteLine("Installation ID is " + installationConnection.Key);

View File

@ -201,10 +201,65 @@ public static class S3
return response.HttpStatusCode == HttpStatusCode.OK; return response.HttpStatusCode == HttpStatusCode.OK;
} }
public static async Task<Boolean> PurgeBucket(this S3Bucket bucket)
{
var client = bucket.Region.GetS3Client();
var totalDeleted = 0;
Console.WriteLine($"[PurgeBucket] Starting purge of bucket {bucket.Name}");
while (true)
{
var listResponse = await client.ListObjectsV2Async(new ListObjectsV2Request
{
BucketName = bucket.Name,
MaxKeys = 1000
});
if (listResponse.S3Objects.Count == 0)
{
Console.WriteLine($"[PurgeBucket] Completed purge of bucket {bucket.Name}: {totalDeleted} objects deleted");
return true;
}
var deleteResponse = await client.DeleteObjectsAsync(new DeleteObjectsRequest
{
BucketName = bucket.Name,
Objects = listResponse.S3Objects
.Select(o => new KeyVersion { Key = o.Key })
.ToList()
});
if (deleteResponse.HttpStatusCode != HttpStatusCode.OK)
{
Console.WriteLine($"[PurgeBucket] Failed at batch after {totalDeleted} objects deleted from bucket {bucket.Name}");
return false;
}
totalDeleted += listResponse.S3Objects.Count;
if (totalDeleted % 10000 == 0)
Console.WriteLine($"[PurgeBucket] Progress: {totalDeleted} objects deleted from bucket {bucket.Name}");
}
}
public static async Task<Boolean> DeleteObjects(this S3Bucket bucket, IReadOnlyList<String> keys)
{
if (keys.Count == 0) return true;
var response = await bucket.Region.GetS3Client().DeleteObjectsAsync(new DeleteObjectsRequest
{
BucketName = bucket.Name,
Objects = keys.Select(k => new KeyVersion { Key = k }).ToList()
});
return response.HttpStatusCode == HttpStatusCode.OK;
}
public static async Task<Boolean> DeleteBucket(this S3Region region, String bucketName) public static async Task<Boolean> DeleteBucket(this S3Region region, String bucketName)
{ {
var request = new DeleteBucketRequest { BucketName = bucketName }; var request = new DeleteBucketRequest { BucketName = bucketName };
var response = await region var response = await region
.GetS3Client() .GetS3Client()
.DeleteBucketAsync(request); .DeleteBucketAsync(request);

View File

@ -35,7 +35,8 @@ function App() {
setAccessToSalimax, setAccessToSalimax,
setAccessToSalidomo, setAccessToSalidomo,
setAccessToSodiohome, setAccessToSodiohome,
setAccessToSodistore setAccessToSodistore,
setAccessToSodistoreGrid
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
const [language, setLanguage] = useState<string>( const [language, setLanguage] = useState<string>(
@ -102,12 +103,15 @@ function App() {
setAccessToSalidomo(response.data.accessToSalidomo); setAccessToSalidomo(response.data.accessToSalidomo);
setAccessToSodiohome(response.data.accessToSodioHome); setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax); setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (response.data.accessToSalimax) { if (response.data.accessToSalimax) {
navigate(routes.installations); navigate(routes.installations);
} else if (response.data.accessToSalidomo) { } else if (response.data.accessToSalidomo) {
navigate(routes.salidomo_installations); navigate(routes.salidomo_installations);
} else if (response.data.accessToSodistoreMax) { } else if (response.data.accessToSodistoreMax) {
navigate(routes.sodistore_installations); navigate(routes.sodistore_installations);
} else if (response.data.accessToSodistoreGrid) {
navigate(routes.sodistoregrid_installations);
} else { } else {
navigate(routes.sodiohome_installations); navigate(routes.sodiohome_installations);
} }
@ -215,6 +219,15 @@ function App() {
} }
/> />
<Route
path={routes.sodistoregrid_installations + '*'}
element={
<AccessContextProvider>
<InstallationTabs product={4} />
</AccessContextProvider>
}
/>
<Route path={routes.users + '*'} element={<Users />} /> <Route path={routes.users + '*'} element={<Users />} />
<Route <Route
path={'*'} path={'*'}

View File

@ -4,6 +4,7 @@
"salidomo_installations": "/salidomo_installations/", "salidomo_installations": "/salidomo_installations/",
"sodistore_installations": "/sodistore_installations/", "sodistore_installations": "/sodistore_installations/",
"sodiohome_installations": "/sodiohome_installations/", "sodiohome_installations": "/sodiohome_installations/",
"sodistoregrid_installations": "/sodistoregrid_installations/",
"installation": "installation/", "installation": "installation/",
"login": "/login/", "login": "/login/",
"forgotPassword": "/forgotPassword/", "forgotPassword": "/forgotPassword/",

View File

@ -98,7 +98,7 @@ function Logo() {
const theme = useTheme(); const theme = useTheme();
return ( return (
<TooltipWrapper title="InnovEnergy" arrow> <TooltipWrapper title="inesco Energy" arrow>
<LogoWrapper to="/overview"> <LogoWrapper to="/overview">
<Badge <Badge
sx={{ sx={{

View File

@ -40,7 +40,8 @@ function Login() {
setAccessToSalimax, setAccessToSalimax,
setAccessToSalidomo, setAccessToSalidomo,
setAccessToSodiohome, setAccessToSodiohome,
setAccessToSodistore setAccessToSodistore,
setAccessToSodistoreGrid
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
const navigate = useNavigate(); const navigate = useNavigate();
@ -84,6 +85,7 @@ function Login() {
setAccessToSalidomo(response.data.accessToSalidomo); setAccessToSalidomo(response.data.accessToSalidomo);
setAccessToSodiohome(response.data.accessToSodioHome); setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax); setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (rememberMe) { if (rememberMe) {
cookies.set('rememberedUsername', username, { path: '/' }); cookies.set('rememberedUsername', username, { path: '/' });
@ -95,6 +97,8 @@ function Login() {
navigate(routes.salidomo_installations); navigate(routes.salidomo_installations);
} else if (response.data.accessToSodistoreMax) { } else if (response.data.accessToSodistoreMax) {
navigate(routes.sodistore_installations); navigate(routes.sodistore_installations);
} else if (response.data.accessToSodistoreGrid) {
navigate(routes.sodistoregrid_installations);
} else { } else {
navigate(routes.sodiohome_installations); navigate(routes.sodiohome_installations);
} }

View File

@ -245,7 +245,7 @@ function BatteryView(props: BatteryViewProps) {
) : ( ) : (
<TableCell align="center">Max Cell Voltage</TableCell> <TableCell align="center">Max Cell Voltage</TableCell>
)} )}
{product === 3 && ( {(product === 3 || product === 4) && (
<TableCell align="center">Voltage Difference</TableCell> <TableCell align="center">Voltage Difference</TableCell>
)} )}
</TableRow> </TableRow>
@ -469,7 +469,7 @@ function BatteryView(props: BatteryViewProps) {
</TableCell> </TableCell>
</> </>
)} )}
{product === 3 && ( {(product === 3 || product === 4) && (
<> <>
{(() => { {(() => {
const cellVoltagesString = const cellVoltagesString =
@ -524,7 +524,7 @@ function BatteryView(props: BatteryViewProps) {
})()} })()}
</> </>
)} )}
{product === 3 && ( {(product === 3 || product === 4) && (
<> <>
{(() => { {(() => {
const cellVoltagesString = const cellVoltagesString =

View File

@ -128,7 +128,7 @@ function Information(props: InformationProps) {
top: '50%', top: '50%',
left: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: 350, width: 450,
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 4, borderRadius: 4,
boxShadow: 24, boxShadow: 24,
@ -143,7 +143,37 @@ function Information(props: InformationProps) {
gutterBottom gutterBottom
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 'bold' }}
> >
Do you want to delete this installation? <FormattedMessage
id="confirmDeleteInstallation"
defaultMessage="Do you want to delete this installation?"
/>
</Typography>
<Typography
variant="body2"
sx={{
mt: 1,
p: 1,
bgcolor: '#f5f5f5',
borderRadius: 1,
fontFamily: 'monospace',
wordBreak: 'break-all',
userSelect: 'all',
width: '100%',
textAlign: 'center'
}}
>
{props.s3Credentials.s3Bucket}
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary', textAlign: 'center' }}
>
<FormattedMessage
id="deleteInstallationWarning"
defaultMessage="Please note the bucket name above. Purging S3 data may take several minutes. If deletion fails, you can manually remove the bucket from Exoscale."
/>
</Typography> </Typography>
<div <div
@ -328,9 +358,12 @@ function Information(props: InformationProps) {
label="S3 Bucket Name" label="S3 Bucket Name"
name="s3bucketname" name="s3bucketname"
value={ value={
product === 0 || product == 3 formValues.product === 0 || formValues.product == 3
? formValues.s3BucketId + ? formValues.s3BucketId +
'-3e5b3069-214a-43ee-8d85-57d72000c19d' '-3e5b3069-214a-43ee-8d85-57d72000c19d'
: formValues.product == 4
? formValues.s3BucketId +
'-5109c126-e141-43ab-8658-f3c44c838ae8'
: formValues.s3BucketId + : formValues.s3BucketId +
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa' '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
} }

View File

@ -119,7 +119,7 @@ function InformationSalidomo(props: InformationSalidomoProps) {
top: '50%', top: '50%',
left: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: 350, width: 450,
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 4, borderRadius: 4,
boxShadow: 24, boxShadow: 24,
@ -134,7 +134,37 @@ function InformationSalidomo(props: InformationSalidomoProps) {
gutterBottom gutterBottom
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 'bold' }}
> >
Do you want to delete this installation? <FormattedMessage
id="confirmDeleteInstallation"
defaultMessage="Do you want to delete this installation?"
/>
</Typography>
<Typography
variant="body2"
sx={{
mt: 1,
p: 1,
bgcolor: '#f5f5f5',
borderRadius: 1,
fontFamily: 'monospace',
wordBreak: 'break-all',
userSelect: 'all',
width: '100%',
textAlign: 'center'
}}
>
{props.s3Credentials.s3Bucket}
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary', textAlign: 'center' }}
>
<FormattedMessage
id="deleteInstallationWarning"
defaultMessage="Please note the bucket name above. Purging S3 data may take several minutes. If deletion fails, you can manually remove the bucket from Exoscale."
/>
</Typography> </Typography>
<div <div

View File

@ -193,7 +193,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
top: '50%', top: '50%',
left: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: 350, width: 450,
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 4, borderRadius: 4,
boxShadow: 24, boxShadow: 24,
@ -208,7 +208,37 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
gutterBottom gutterBottom
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 'bold' }}
> >
Do you want to delete this installation? <FormattedMessage
id="confirmDeleteInstallation"
defaultMessage="Do you want to delete this installation?"
/>
</Typography>
<Typography
variant="body2"
sx={{
mt: 1,
p: 1,
bgcolor: '#f5f5f5',
borderRadius: 1,
fontFamily: 'monospace',
wordBreak: 'break-all',
userSelect: 'all',
width: '100%',
textAlign: 'center'
}}
>
{props.s3Credentials.s3Bucket}
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary', textAlign: 'center' }}
>
<FormattedMessage
id="deleteInstallationWarning"
defaultMessage="Please note the bucket name above. Purging S3 data may take several minutes. If deletion fails, you can manually remove the bucket from Exoscale."
/>
</Typography> </Typography>
<div <div

View File

@ -57,9 +57,13 @@ function Installation(props: singleInstallationProps) {
s3BucketId: props.current_installation.s3BucketId s3BucketId: props.current_installation.s3BucketId
}; };
// TODO: SodistoreGrid — uses its own bucket salt
const s3BucketSalt =
props.current_installation.product === 4
? '5109c126-e141-43ab-8658-f3c44c838ae8'
: '3e5b3069-214a-43ee-8d85-57d72000c19d';
const s3Bucket = const s3Bucket =
props.current_installation.s3BucketId.toString() + props.current_installation.s3BucketId.toString() + '-' + s3BucketSalt;
'-3e5b3069-214a-43ee-8d85-57d72000c19d';
const s3Credentials = { s3Bucket, ...S3data }; const s3Credentials = { s3Bucket, ...S3data };
@ -427,12 +431,15 @@ function Installation(props: singleInstallationProps) {
} }
></Route> ></Route>
<Route {/* TODO: SodistoreGrid — PV View excluded, add back when data path is ready */}
path={routes.pvview + '/*'} {props.current_installation.product !== 4 && (
element={ <Route
<PvView values={values} connected={connected}></PvView> path={routes.pvview + '/*'}
} element={
></Route> <PvView values={values} connected={connected}></PvView>
}
></Route>
)}
<Route <Route
path={routes.overview} path={routes.overview}
@ -447,11 +454,28 @@ function Installation(props: singleInstallationProps) {
<Route <Route
path={routes.live} path={routes.live}
element={ element={
<Topology props.current_installation.product === 4 ? (
values={values} // TODO: SodistoreGrid — implement actual topology layout
connected={connected} <Container
loading={loading} maxWidth="xl"
></Topology> sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '40vh'
}}
>
<Typography variant="body1" color="text.secondary">
Live view coming soon
</Typography>
</Container>
) : (
<Topology
values={values}
connected={connected}
loading={loading}
></Topology>
)
} }
/> />
@ -470,10 +494,27 @@ function Installation(props: singleInstallationProps) {
<Route <Route
path={routes.configuration} path={routes.configuration}
element={ element={
<Configuration props.current_installation.product === 4 ? (
values={values} // TODO: SodistoreGrid — implement actual configuration
id={props.current_installation.id} <Container
></Configuration> maxWidth="xl"
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '40vh'
}}
>
<Typography variant="body1" color="text.secondary">
Configuration not yet available
</Typography>
</Container>
) : (
<Configuration
values={values}
id={props.current_installation.id}
></Configuration>
)
} }
/> />
)} )}

View File

@ -39,11 +39,18 @@ function InstallationTabs(props: InstallationTabsProps) {
const { const {
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
sodistoreGridInstallations,
fetchAllInstallations, fetchAllInstallations,
socket, socket,
openSocket, openSocket,
closeSocket closeSocket
} = useContext(InstallationsContext); } = useContext(InstallationsContext);
// Use the correct installations array based on product
const installations =
props.product === 4
? sodistoreGridInstallations
: salimax_or_sodistore_Installations;
const { product, setProduct } = useContext(ProductIdContext); const { product, setProduct } = useContext(ProductIdContext);
useEffect(() => { useEffect(() => {
@ -93,7 +100,10 @@ function InstallationTabs(props: InstallationTabsProps) {
return ret_path; return ret_path;
}; };
const singleInstallationTabs = // TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
const hidePvView = props.product === 4;
const singleInstallationTabs = (
currentUser.userType == UserType.admin currentUser.userType == UserType.admin
? [ ? [
{ {
@ -204,7 +214,8 @@ function InstallationTabs(props: InstallationTabsProps) {
<FormattedMessage id="information" defaultMessage="Information" /> <FormattedMessage id="information" defaultMessage="Information" />
) )
} }
]; ]
).filter((tab) => !(hidePvView && tab.value === 'pvview'));
const tabs = const tabs =
currentTab != 'list' && currentTab != 'list' &&
@ -372,7 +383,12 @@ function InstallationTabs(props: InstallationTabsProps) {
} }
]; ];
return salimax_or_sodistore_Installations.length > 1 ? ( // Filter out PV View for SodistoreGrid
const filteredTabs = hidePvView
? tabs.filter((tab) => tab.value !== 'pvview')
: tabs;
return installations.length > 1 ? (
<> <>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe"> <Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper> <TabsContainerWrapper>
@ -384,7 +400,7 @@ function InstallationTabs(props: InstallationTabsProps) {
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
> >
{tabs.map((tab) => ( {filteredTabs.map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}
@ -415,7 +431,7 @@ function InstallationTabs(props: InstallationTabsProps) {
<Grid item xs={12}> <Grid item xs={12}>
<Box p={4}> <Box p={4}>
<InstallationSearch <InstallationSearch
installations={salimax_or_sodistore_Installations} installations={installations}
/> />
</Box> </Box>
</Grid> </Grid>
@ -428,6 +444,10 @@ function InstallationTabs(props: InstallationTabsProps) {
element={ element={
props.product === 0 ? ( props.product === 0 ? (
<Navigate to={routes.installations + routes.list} /> <Navigate to={routes.installations + routes.list} />
) : props.product === 4 ? (
<Navigate
to={routes.sodistoregrid_installations + routes.list}
/>
) : ( ) : (
<Navigate <Navigate
to={routes.sodistore_installations + routes.list} to={routes.sodistore_installations + routes.list}
@ -441,7 +461,7 @@ function InstallationTabs(props: InstallationTabsProps) {
</Container> </Container>
<Footer /> <Footer />
</> </>
) : salimax_or_sodistore_Installations.length === 1 ? ( ) : installations.length === 1 ? (
<> <>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe"> <Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper> <TabsContainerWrapper>
@ -480,7 +500,7 @@ function InstallationTabs(props: InstallationTabsProps) {
<Box p={4}> <Box p={4}>
<Installation <Installation
current_installation={ current_installation={
salimax_or_sodistore_Installations[0] installations[0]
} }
type="installation" type="installation"
></Installation> ></Installation>

View File

@ -112,11 +112,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
const fetchDataPeriodically = async () => { const fetchDataPeriodically = async () => {
var timeperiodToSearch = 200; var timeperiodToSearch = 350;
let res; let res;
let timestampToFetch; let timestampToFetch;
for (var i = 0; i < timeperiodToSearch; i += 2) { for (var i = 0; i < timeperiodToSearch; i += 30) {
if (!continueFetching.current) { if (!continueFetching.current) {
return false; return false;
} }
@ -184,13 +184,13 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}; };
const fetchDataForOneTime = async () => { const fetchDataForOneTime = async () => {
var timeperiodToSearch = 300; // 5 minutes to cover ~4 upload cycles var timeperiodToSearch = 300; // 5 minutes to cover ~2 upload cycles (150s each)
let res; let res;
let timestampToFetch; let timestampToFetch;
// Search from NOW backward to find the most recent data // Search from NOW backward to find the most recent data
// Step by 10 seconds - balances between finding files quickly and reducing 404s // Step by 50 seconds - data is uploaded every ~150s, so finer steps are wasteful
for (var i = 0; i < timeperiodToSearch; i += 10) { for (var i = 0; i < timeperiodToSearch; i += 50) {
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i)); timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
try { try {
res = await fetchDataJson(timestampToFetch, s3Credentials, false); res = await fetchDataJson(timestampToFetch, s3Credentials, false);
@ -264,7 +264,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
const configRefreshInterval = setInterval(() => { const configRefreshInterval = setInterval(() => {
console.log('Refreshing configuration data from S3...'); console.log('Refreshing configuration data from S3...');
fetchDataForOneTime(); fetchDataForOneTime();
}, 15000); // Refresh every 15 seconds }, 60000); // Refresh every 60 seconds (data uploads every ~150s)
return () => { return () => {
continueFetching.current = false; continueFetching.current = false;

View File

@ -143,9 +143,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const submittedAt = pendingConfig.submittedAt || 0; const submittedAt = pendingConfig.submittedAt || 0;
const timeSinceSubmit = Date.now() - submittedAt; const timeSinceSubmit = Date.now() - submittedAt;
// Within 150 seconds of submit: use localStorage (waiting for S3 sync) // Within 300 seconds of submit: use localStorage (waiting for S3 sync)
// This covers two full S3 upload cycles (75 sec × 2) to ensure new file is available // This covers two full S3 upload cycles (150 sec × 2) to ensure new file is available
if (timeSinceSubmit < 150000) { if (timeSinceSubmit < 300000) {
// Check if S3 now matches - if so, sync is complete // Check if S3 now matches - if so, sync is complete
const s3MatchesPending = const s3MatchesPending =
s3Values.controlPermission === pendingConfig.values.controlPermission && s3Values.controlPermission === pendingConfig.values.controlPermission &&
@ -247,7 +247,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
setLoading(false); setLoading(false);
// Save submitted values to localStorage for optimistic UI update // Save submitted values to localStorage for optimistic UI update
// This ensures the form shows correct values even before S3 syncs (up to 75 sec delay) // This ensures the form shows correct values even before S3 syncs (up to 150 sec delay)
localStorage.setItem(pendingConfigKey, JSON.stringify({ localStorage.setItem(pendingConfigKey, JSON.stringify({
values: formValues, values: formValues,
submittedAt: Date.now() submittedAt: Date.now()

View File

@ -159,7 +159,10 @@ function FormattedBullet({ text }: { text: string }) {
return <>{parts}</>; return <>{parts}</>;
} }
const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; function getMonthName(month: number, locale: string): string {
const date = new Date(2000, month - 1, 1);
return date.toLocaleDateString(locale, { month: 'long' });
}
// ── Email Bar (shared) ────────────────────────────────────────── // ── Email Bar (shared) ──────────────────────────────────────────
@ -545,7 +548,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
</Box> </Box>
{report.dailyData.map((d, i) => { {report.dailyData.map((d, i) => {
const dt = new Date(d.date); const dt = new Date(d.date);
const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); const dayLabel = dt.toLocaleDateString(intl.locale, { weekday: 'short', month: 'short', day: 'numeric' });
const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true; const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true;
return ( return (
<Box key={d.date} sx={{ mb: 1.5, opacity: isCurrentWeek ? 1 : 0.6 }}> <Box key={d.date} sx={{ mb: 1.5, opacity: isCurrentWeek ? 1 : 0.6 }}>
@ -555,7 +558,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
{!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(prev week)" /></span>} {!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(prev week)" /></span>}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}> <Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}>
PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {d.gridImport.toFixed(1)} kWh {intl.formatMessage({ id: 'pvProduction' })} {d.pvProduction.toFixed(1)} | {intl.formatMessage({ id: 'consumption' })} {d.loadConsumption.toFixed(1)} | {intl.formatMessage({ id: 'gridImport' })} {d.gridImport.toFixed(1)} kWh
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}> <Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
@ -737,7 +740,7 @@ function MonthlySection({
? intl.formatMessage({ id: 'generatingMonthly', defaultMessage: 'Generating...' }) ? intl.formatMessage({ id: 'generatingMonthly', defaultMessage: 'Generating...' })
: intl.formatMessage( : intl.formatMessage(
{ id: 'generateMonth', defaultMessage: 'Generate {month} {year} ({count} weeks)' }, { id: 'generateMonth', defaultMessage: 'Generate {month} {year} ({count} weeks)' },
{ month: MONTH_NAMES[p.month], year: p.year, count: p.weekCount } { month: getMonthName(p.month, intl.locale), year: p.year, count: p.weekCount }
) )
} }
</Button> </Button>
@ -752,7 +755,7 @@ function MonthlySection({
<AggregatedSection <AggregatedSection
reports={reports} reports={reports}
type="monthly" type="monthly"
labelFn={(r: MonthlyReport) => `${MONTH_NAMES[r.month]} ${r.year}`} labelFn={(r: MonthlyReport) => `${getMonthName(r.month, intl.locale)} ${r.year}`}
countLabelId="weeksAggregated" countLabelId="weeksAggregated"
countFn={(r: MonthlyReport) => r.weekCount} countFn={(r: MonthlyReport) => r.weekCount}
sendEndpoint="/SendMonthlyReportEmail" sendEndpoint="/SendMonthlyReportEmail"

View File

@ -65,7 +65,12 @@ function TreeInformation(props: TreeInformationProps) {
setProduct(e.target.value); // Directly update the product state setProduct(e.target.value); // Directly update the product state
}; };
const ProductTypes = ['Salimax', 'Salidomo', 'SodistoreHome', 'SodistoreMax']; const ProductTypes = ['Salimax', 'Salidomo', 'SodistoreHome', 'SodistoreMax', 'SodistoreGrid'];
const ProductDisplayNames: Record<string, string> = {
'SodistoreHome': 'Sodistore Home',
'SodistoreMax': 'Sodistore Max',
'SodistoreGrid': 'Sodistore Grid'
};
const isMobile = window.innerWidth <= 1490; const isMobile = window.innerWidth <= 1490;
@ -282,7 +287,7 @@ function TreeInformation(props: TreeInformationProps) {
> >
{ProductTypes.map((type) => ( {ProductTypes.map((type) => (
<MenuItem key={type} value={type}> <MenuItem key={type} value={type}>
{type} {ProductDisplayNames[type] || type}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@ -323,7 +328,8 @@ function TreeInformation(props: TreeInformationProps) {
)} )}
{openModalInstallation && {openModalInstallation &&
(product == 'Salimax' || (product == 'Salimax' ||
product == 'SodistoreMax') && ( product == 'SodistoreMax' ||
product == 'SodistoreGrid') && (
<InstallationForm <InstallationForm
cancel={handleFormCancel} cancel={handleFormCancel}
submit={handleInstallationFormSubmit} submit={handleInstallationFormSubmit}

View File

@ -80,7 +80,7 @@ function InstallationTree() {
key={installation.id} key={installation.id}
path={routes.installation + installation.id + '/*'} path={routes.installation + installation.id + '/*'}
element={ element={
installation.product == 0 || installation.product == 3 ? ( installation.product == 0 || installation.product == 3 || installation.product == 4 ? (
<Installation <Installation
key={installation.id} key={installation.id}
current_installation={installation} current_installation={installation}

View File

@ -115,7 +115,7 @@ function Status500() {
<Container maxWidth="sm"> <Container maxWidth="sm">
<Box textAlign="center"> <Box textAlign="center">
<TypographyPrimary variant="h1" sx={{ my: 2 }}> <TypographyPrimary variant="h1" sx={{ my: 2 }}>
InnovEnergy{' '} inesco Energy{' '}
</TypographyPrimary> </TypographyPrimary>
<TypographySecondary <TypographySecondary
variant="h4" variant="h4"

View File

@ -31,6 +31,9 @@ const InstallationsContextProvider = ({
const [sodiohomeInstallations, setSodiohomeInstallations] = useState< const [sodiohomeInstallations, setSodiohomeInstallations] = useState<
I_Installation[] I_Installation[]
>([]); >([]);
const [sodistoreGridInstallations, setSodistoreGridInstallations] = useState<
I_Installation[]
>([]);
const [foldersAndInstallations, setFoldersAndInstallations] = useState([]); const [foldersAndInstallations, setFoldersAndInstallations] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -89,16 +92,31 @@ const InstallationsContextProvider = ({
: installation; : installation;
}); });
const updatedSodistoreGrid = sodistoreGridInstallations.map(
(installation) => {
const update = pendingUpdates.current[installation.id];
return update
? {
...installation,
status: update.status,
testingMode: update.testingMode
}
: installation;
}
);
setSalidomoInstallations(updatedSalidomo); setSalidomoInstallations(updatedSalidomo);
setSalimax_Or_Sodistore_Installations(updatedSalimax); setSalimax_Or_Sodistore_Installations(updatedSalimax);
setSodiohomeInstallations(updatedSodiohome); setSodiohomeInstallations(updatedSodiohome);
setSodistoreGridInstallations(updatedSodistoreGrid);
// Clear the pending updates after applying // Clear the pending updates after applying
pendingUpdates.current = {}; pendingUpdates.current = {};
}, [ }, [
salidomoInstallations, salidomoInstallations,
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
sodiohomeInstallations sodiohomeInstallations,
sodistoreGridInstallations
]); ]);
useEffect(() => { useEffect(() => {
@ -172,6 +190,8 @@ const InstallationsContextProvider = ({
setSalidomoInstallations(res.data); setSalidomoInstallations(res.data);
} else if (product === 0 || product === 3) { } else if (product === 0 || product === 3) {
setSalimax_Or_Sodistore_Installations(res.data); setSalimax_Or_Sodistore_Installations(res.data);
} else if (product === 4) {
setSodistoreGridInstallations(res.data);
} }
if (open_socket) { if (open_socket) {
@ -390,6 +410,7 @@ const InstallationsContextProvider = ({
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
salidomoInstallations, salidomoInstallations,
sodiohomeInstallations, sodiohomeInstallations,
sodistoreGridInstallations,
foldersAndInstallations, foldersAndInstallations,
fetchAllInstallations, fetchAllInstallations,
fetchAllFoldersAndInstallations, fetchAllFoldersAndInstallations,
@ -416,6 +437,7 @@ const InstallationsContextProvider = ({
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
salidomoInstallations, salidomoInstallations,
sodiohomeInstallations, sodiohomeInstallations,
sodistoreGridInstallations,
foldersAndInstallations, foldersAndInstallations,
loading, loading,
error, error,

View File

@ -9,10 +9,12 @@ interface ProductIdContextType {
accessToSalidomo: boolean; accessToSalidomo: boolean;
accessToSodiohome: boolean; accessToSodiohome: boolean;
accessToSodistore: boolean; accessToSodistore: boolean;
accessToSodistoreGrid: boolean;
setAccessToSalimax: (access: boolean) => void; setAccessToSalimax: (access: boolean) => void;
setAccessToSalidomo: (access: boolean) => void; setAccessToSalidomo: (access: boolean) => void;
setAccessToSodiohome: (access: boolean) => void; setAccessToSodiohome: (access: boolean) => void;
setAccessToSodistore: (access: boolean) => void; setAccessToSodistore: (access: boolean) => void;
setAccessToSodistoreGrid: (access: boolean) => void;
} }
// Create the context. // Create the context.
@ -43,6 +45,10 @@ export const ProductIdContextProvider = ({
const storedValue = localStorage.getItem('accessToSodistore'); const storedValue = localStorage.getItem('accessToSodistore');
return storedValue === 'true'; return storedValue === 'true';
}); });
const [accessToSodistoreGrid, setAccessToSodistoreGrid] = useState(() => {
const storedValue = localStorage.getItem('accessToSodistoreGrid');
return storedValue === 'true';
});
// const [product, setProduct] = useState(location.includes('salidomo') ? 1 : 0); // const [product, setProduct] = useState(location.includes('salidomo') ? 1 : 0);
// const [product, setProduct] = useState<number>( // const [product, setProduct] = useState<number>(
// productMapping[Object.keys(productMapping).find((key) => location.includes(key)) || ''] || -1 // productMapping[Object.keys(productMapping).find((key) => location.includes(key)) || ''] || -1
@ -52,6 +58,8 @@ export const ProductIdContextProvider = ({
return 1; return 1;
} else if (location.includes('sodiohome')) { } else if (location.includes('sodiohome')) {
return 2; return 2;
} else if (location.includes('sodistoregrid')) {
return 4;
} else { } else {
return 0; return 0;
} }
@ -80,6 +88,10 @@ export const ProductIdContextProvider = ({
setAccessToSodistore(access); setAccessToSodistore(access);
localStorage.setItem('accessToSodistore', JSON.stringify(access)); localStorage.setItem('accessToSodistore', JSON.stringify(access));
}; };
const changeAccessSodistoreGrid = (access: boolean) => {
setAccessToSodistoreGrid(access);
localStorage.setItem('accessToSodistoreGrid', JSON.stringify(access));
};
return ( return (
<ProductIdContext.Provider <ProductIdContext.Provider
@ -90,10 +102,12 @@ export const ProductIdContextProvider = ({
accessToSalidomo, accessToSalidomo,
accessToSodiohome, accessToSodiohome,
accessToSodistore, accessToSodistore,
accessToSodistoreGrid,
setAccessToSalimax: changeAccessSalimax, setAccessToSalimax: changeAccessSalimax,
setAccessToSalidomo: changeAccessSalidomo, setAccessToSalidomo: changeAccessSalidomo,
setAccessToSodiohome: changeAccessSodiohome, setAccessToSodiohome: changeAccessSodiohome,
setAccessToSodistore: changeAccessSodistore setAccessToSodistore: changeAccessSodistore,
setAccessToSodistoreGrid: changeAccessSodistoreGrid
}} }}
> >
{children} {children}

View File

@ -89,8 +89,9 @@ export const transformInputToBatteryViewDataJson = async (
const categories = isSodioHome const categories = isSodioHome
? ['Soc', 'Power', 'Voltage', 'Current', 'Soh'] ? ['Soc', 'Power', 'Voltage', 'Current', 'Soh']
: ['Soc', 'Temperature', 'Power', 'Voltage', 'Current']; : ['Soc', 'Temperature', 'Power', 'Voltage', 'Current'];
// TODO: SodistoreGrid — update data paths when installation data format is finalized
const pathCategories = const pathCategories =
product === 3 product === 3 || product === 4
? [ ? [
'.BatteryDeligreenDataRecord.Soc', '.BatteryDeligreenDataRecord.Soc',
'.BatteryDeligreenDataRecord.TemperaturesList.EnvironmentTemperature', '.BatteryDeligreenDataRecord.TemperaturesList.EnvironmentTemperature',
@ -167,7 +168,7 @@ export const transformInputToBatteryViewDataJson = async (
); );
const adjustedTimestamp = const adjustedTimestamp =
product == 0 || product == 2 || product == 3 product == 0 || product == 2 || product == 3 || product == 4
? new Date(timestampArray[i] * 1000) ? new Date(timestampArray[i] * 1000)
: new Date(timestampArray[i] * 100000); : new Date(timestampArray[i] * 100000);
//Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset //Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset
@ -270,7 +271,7 @@ export const transformInputToBatteryViewDataJson = async (
if (battery_nodes.length > old_length) { if (battery_nodes.length > old_length) {
battery_nodes.forEach((node) => { battery_nodes.forEach((node) => {
const node_number = const node_number =
product == 3 ? Number(node) + 1 : Number(node) - 1; product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) { if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number); pathsToSave.push('Node' + node_number);
} }

View File

@ -71,6 +71,9 @@
"status": "Status", "status": "Status",
"live": "Live Daten", "live": "Live Daten",
"deleteInstallation": "Installation löschen", "deleteInstallation": "Installation löschen",
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
"bucketLabel": "Bucket",
"deleteInstallationWarning": "Bitte notieren Sie den Bucket-Namen oben. Das Löschen der S3-Daten kann mehrere Minuten dauern. Überprüfen Sie nach dem Löschen in Exoscale, ob der Bucket entfernt wurde. Falls nicht, leeren und löschen Sie den Bucket manuell.",
"errorOccured": "Ein Fehler ist aufgetreten", "errorOccured": "Ein Fehler ist aufgetreten",
"successfullyUpdated": "Erfolgreich aktualisiert", "successfullyUpdated": "Erfolgreich aktualisiert",
"grantAccess": "Zugriff gewähren", "grantAccess": "Zugriff gewähren",
@ -155,6 +158,7 @@
"generatingMonthly": "Wird generiert...", "generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...", "generatingYearly": "Wird generiert...",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats", "thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
"recentWeeklyReports": "Letzte Wochenberichte",
"ai_analyzing": "KI analysiert...", "ai_analyzing": "KI analysiert...",
"ai_show_details": "Details anzeigen", "ai_show_details": "Details anzeigen",
"ai_show_less": "Weniger anzeigen", "ai_show_less": "Weniger anzeigen",

View File

@ -53,6 +53,9 @@
"status": "Status", "status": "Status",
"live": "Live View", "live": "Live View",
"deleteInstallation": "Delete Installation", "deleteInstallation": "Delete Installation",
"confirmDeleteInstallation": "Do you want to delete this installation?",
"bucketLabel": "Bucket",
"deleteInstallationWarning": "Please note the bucket name above. Purging S3 data may take several minutes. After deletion, verify in Exoscale that the bucket has been removed. If not, purge and delete the bucket manually.",
"errorOccured": "An error has occurred", "errorOccured": "An error has occurred",
"successfullyUpdated": "Successfully updated", "successfullyUpdated": "Successfully updated",
"grantAccess": "Grant Access", "grantAccess": "Grant Access",
@ -137,6 +140,7 @@
"generatingMonthly": "Generating...", "generatingMonthly": "Generating...",
"generatingYearly": "Generating...", "generatingYearly": "Generating...",
"thisMonthWeeklyReports": "This Month's Weekly Reports", "thisMonthWeeklyReports": "This Month's Weekly Reports",
"recentWeeklyReports": "Recent Weekly Reports",
"ai_analyzing": "AI is analyzing...", "ai_analyzing": "AI is analyzing...",
"ai_show_details": "Show details", "ai_show_details": "Show details",
"ai_show_less": "Show less", "ai_show_less": "Show less",

View File

@ -65,6 +65,9 @@
"status": "Statut", "status": "Statut",
"live": "Diffusion en direct", "live": "Diffusion en direct",
"deleteInstallation": "Supprimer l'installation", "deleteInstallation": "Supprimer l'installation",
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
"bucketLabel": "Bucket",
"deleteInstallationWarning": "Veuillez noter le nom du bucket ci-dessus. La purge des données S3 peut prendre plusieurs minutes. Après la suppression, vérifiez dans Exoscale que le bucket a bien été supprimé. Sinon, purgez et supprimez le bucket manuellement.",
"errorOccured": "Une erreur s'est produite", "errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie", "successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès", "grantAccess": "Accorder l'accès",
@ -149,6 +152,7 @@
"generatingMonthly": "Génération en cours...", "generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...", "generatingYearly": "Génération en cours...",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois", "thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
"recentWeeklyReports": "Derniers rapports hebdomadaires",
"ai_analyzing": "L'IA analyse...", "ai_analyzing": "L'IA analyse...",
"ai_show_details": "Afficher les détails", "ai_show_details": "Afficher les détails",
"ai_show_less": "Afficher moins", "ai_show_less": "Afficher moins",

View File

@ -53,6 +53,9 @@
"status": "Stato", "status": "Stato",
"live": "Vista in diretta", "live": "Vista in diretta",
"deleteInstallation": "Elimina installazione", "deleteInstallation": "Elimina installazione",
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
"bucketLabel": "Bucket",
"deleteInstallationWarning": "Prendi nota del nome del bucket qui sopra. L'eliminazione dei dati S3 potrebbe richiedere diversi minuti. Dopo l'eliminazione, verifica in Exoscale che il bucket sia stato rimosso. In caso contrario, svuota ed elimina il bucket manualmente.",
"errorOccured": "Si è verificato un errore", "errorOccured": "Si è verificato un errore",
"successfullyUpdated": "Aggiornamento riuscito", "successfullyUpdated": "Aggiornamento riuscito",
"grantAccess": "Concedi accesso", "grantAccess": "Concedi accesso",
@ -160,6 +163,7 @@
"generatingMonthly": "Generazione in corso...", "generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...", "generatingYearly": "Generazione in corso...",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese", "thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
"recentWeeklyReports": "Ultimi rapporti settimanali",
"ai_analyzing": "L'IA sta analizzando...", "ai_analyzing": "L'IA sta analizzando...",
"ai_show_details": "Mostra dettagli", "ai_show_details": "Mostra dettagli",
"ai_show_less": "Mostra meno", "ai_show_less": "Mostra meno",

View File

@ -168,7 +168,8 @@ function SidebarMenu() {
accessToSalimax, accessToSalimax,
accessToSodistore, accessToSodistore,
accessToSalidomo, accessToSalidomo,
accessToSodiohome accessToSodiohome,
accessToSodistoreGrid
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
return ( return (
@ -213,7 +214,7 @@ function SidebarMenu() {
<Box sx={{ marginTop: '3px' }}> <Box sx={{ marginTop: '3px' }}>
<FormattedMessage <FormattedMessage
id="sodistore" id="sodistore"
defaultMessage="SodistoreMax" defaultMessage="Sodistore Max"
/> />
</Box> </Box>
</Button> </Button>
@ -242,6 +243,27 @@ function SidebarMenu() {
</List> </List>
)} )}
{accessToSodistoreGrid && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/sodistoregrid_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="sodistoregrid"
defaultMessage="Sodistore Grid"
/>
</Box>
</Button>
</ListItem>
</List>
)}
{accessToSodiohome && ( {accessToSodiohome && (
<List component="div"> <List component="div">
<ListItem component="div"> <ListItem component="div">