diff --git a/backend/Controllers/SystemController.cs b/backend/Controllers/SystemController.cs new file mode 100644 index 0000000..9d052a4 --- /dev/null +++ b/backend/Controllers/SystemController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using TarninaHub.Models; +using TarninaHub.Services; + +namespace TarninaHub.Controllers; + +[ApiController] +[Route("api/system")] +public class SystemController : ControllerBase +{ + private readonly IStorageService _storageService; + + public SystemController(IStorageService storageService) + { + _storageService = storageService; + } + + [HttpGet("storage")] + public async Task> GetStorage(CancellationToken cancellationToken) + { + var info = _storageService.GetInfo(); + var probeCount = await _storageService.GetProbeCountAsync(cancellationToken); + + return Ok(new StorageStatusResponse( + info.Engine, + info.DataDirectory, + info.Namespace, + info.Database, + probeCount)); + } + + [HttpPost("storage/probes")] + public async Task> CreateProbe(CancellationToken cancellationToken) + { + var probe = await _storageService.AddProbeAsync(cancellationToken); + + return Ok(new StorageProbeResponse(probe.Label, probe.CreatedAt)); + } +} diff --git a/backend/Models/StorageModels.cs b/backend/Models/StorageModels.cs new file mode 100644 index 0000000..00ceb4b --- /dev/null +++ b/backend/Models/StorageModels.cs @@ -0,0 +1,25 @@ +namespace TarninaHub.Models; + +public sealed class StorageProbe +{ + public string Label { get; set; } = "probe"; + + public DateTimeOffset CreatedAt { get; set; } +} + +public sealed record StorageInfo( + string Engine, + string DataDirectory, + string Namespace, + string Database); + +public sealed record StorageStatusResponse( + string Engine, + string DataDirectory, + string Namespace, + string Database, + int ProbeCount); + +public sealed record StorageProbeResponse( + string Label, + DateTimeOffset CreatedAt); diff --git a/backend/Program.cs b/backend/Program.cs index f28b2c2..1206fc1 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,12 +1,55 @@ +using TarninaHub.Services; +using TarninaHub.Settings; + var builder = WebApplication.CreateBuilder(args); +var surrealSettings = builder.Configuration.GetSection("SurrealDb").Get() + ?? new SurrealDbSettings(); + +builder.Services.Configure(builder.Configuration.GetSection("SurrealDb")); + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); builder.Services.AddControllers(); +builder.Services.AddSingleton(); + +var surrealOptions = new SurrealDbOptions +{ + Endpoint = BuildEndpoint(surrealSettings), + Namespace = surrealSettings.Namespace, + Database = surrealSettings.Database, +}; + +if (string.IsNullOrWhiteSpace(surrealSettings.RemoteUrl)) +{ + // Embedded RocksDB, persisted next to the running app (./data), never in memory. + var dataDirectory = Path.Combine(AppContext.BaseDirectory, surrealSettings.DataDirectory); + Directory.CreateDirectory(dataDirectory); + + builder.Services + .AddSurreal(surrealOptions, ServiceLifetime.Singleton) + .AddRocksDbProvider(); +} +else +{ + // Remote SurrealDB server on its own dedicated port (used for sync with the frontend). + surrealOptions.Username = surrealSettings.Username; + surrealOptions.Password = surrealSettings.Password; + + builder.Services.AddSurreal(surrealOptions, ServiceLifetime.Singleton); +} + var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var storageService = scope.ServiceProvider.GetRequiredService(); + + await storageService.InitializeAsync(CancellationToken.None); +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -25,3 +68,15 @@ app.MapControllers(); app.MapFallbackToFile("index.html"); app.Run(); + +static string BuildEndpoint(SurrealDbSettings settings) +{ + if (!string.IsNullOrWhiteSpace(settings.RemoteUrl)) + { + return settings.RemoteUrl; + } + + var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory); + + return $"rocksdb://{dataDirectory.Replace('\\', '/')}"; +} diff --git a/backend/Services/IStorageService.cs b/backend/Services/IStorageService.cs new file mode 100644 index 0000000..cf170f7 --- /dev/null +++ b/backend/Services/IStorageService.cs @@ -0,0 +1,14 @@ +using TarninaHub.Models; + +namespace TarninaHub.Services; + +public interface IStorageService +{ + Task InitializeAsync(CancellationToken cancellationToken); + + StorageInfo GetInfo(); + + Task GetProbeCountAsync(CancellationToken cancellationToken); + + Task AddProbeAsync(CancellationToken cancellationToken); +} diff --git a/backend/Services/StorageService.cs b/backend/Services/StorageService.cs new file mode 100644 index 0000000..40c6cfc --- /dev/null +++ b/backend/Services/StorageService.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Options; +using SurrealDb.Net; +using TarninaHub.Models; +using TarninaHub.Settings; + +namespace TarninaHub.Services; + +public sealed class StorageService : IStorageService +{ + private const string ProbeTable = "storage_probe"; + + private readonly ISurrealDbClient _client; + private readonly SurrealDbSettings _settings; + private readonly string _dataDirectory; + + public StorageService(ISurrealDbClient client, IOptions settings) + { + _client = client; + _settings = settings.Value; + _dataDirectory = Path.Combine(AppContext.BaseDirectory, _settings.DataDirectory); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await _client.RawQuery( + $"DEFINE TABLE IF NOT EXISTS {ProbeTable} SCHEMALESS;", + cancellationToken: cancellationToken); + } + + public StorageInfo GetInfo() + { + var isRemote = !string.IsNullOrWhiteSpace(_settings.RemoteUrl); + + return new StorageInfo( + isRemote ? "remote" : "rocksdb", + isRemote ? _settings.RemoteUrl! : _dataDirectory, + _settings.Namespace, + _settings.Database); + } + + public async Task GetProbeCountAsync(CancellationToken cancellationToken) + { + var probes = await _client.Select(ProbeTable, cancellationToken); + + return probes.Count(); + } + + public Task AddProbeAsync(CancellationToken cancellationToken) + { + var probe = new StorageProbe + { + CreatedAt = DateTimeOffset.UtcNow, + }; + + return _client.Create(ProbeTable, probe, cancellationToken); + } +} diff --git a/backend/Settings/SurrealDbSettings.cs b/backend/Settings/SurrealDbSettings.cs new file mode 100644 index 0000000..2295891 --- /dev/null +++ b/backend/Settings/SurrealDbSettings.cs @@ -0,0 +1,21 @@ +namespace TarninaHub.Settings; + +public sealed class SurrealDbSettings +{ + public string Namespace { get; set; } = "hubmi"; + + public string Database { get; set; } = "hubmi"; + + public string DataDirectory { get; set; } = "data"; + + /// + /// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a + /// dedicated SurrealDB server instead of the embedded RocksDB engine. + /// The server must persist to ./data, not to memory. + /// + public string? RemoteUrl { get; set; } + + public string? Username { get; set; } + + public string? Password { get; set; } +} diff --git a/backend/TarninaHub.csproj b/backend/TarninaHub.csproj index 0268d75..77db9f4 100644 --- a/backend/TarninaHub.csproj +++ b/backend/TarninaHub.csproj @@ -4,10 +4,14 @@ net10.0 enable enable + 0.0.1 + 0.0.1 + + diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 0c208ae..3f9ebab 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -4,5 +4,13 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "SurrealDb": { + "Namespace": "hubmi", + "Database": "hubmi", + "DataDirectory": "data", + "RemoteUrl": "ws://127.0.0.1:8010/rpc", + "Username": "root", + "Password": "root" } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 10f68b8..8a8ce34 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "SurrealDb": { + "Namespace": "hubmi", + "Database": "hubmi", + "DataDirectory": "data" + } } diff --git a/scripts/surrealdb-start.ps1 b/scripts/surrealdb-start.ps1 new file mode 100644 index 0000000..c9fbc5c --- /dev/null +++ b/scripts/surrealdb-start.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Uruchamia SurrealDB jako serwer na dedykowanym porcie (tylko dla bazy), + z trwałym magazynem RocksDB w katalogu ./data (nie w pamięci). + +.DESCRIPTION + Port (domyślnie 8010) służy wyłącznie SurrealDB i jest używany zarówno przez + backend (HubMi.Api), jak i przez frontend do synchronizacji (WebSocket /rpc). + Dane trwale zapisywane są w backend/data (RocksDB). +#> +param( + [int]$Port = 8010, + [string]$Bind = "127.0.0.1", + [string]$Username = "root", + [string]$Password = "root", + [string]$DataPath = (Join-Path $PSScriptRoot "..\backend\data") +) + +$ErrorActionPreference = "Stop" + +$surreal = (Get-Command surreal -ErrorAction SilentlyContinue)?.Source +if (-not $surreal) { + throw "Nie znaleziono 'surreal' w PATH. Zainstaluj SurrealDB: https://surrealdb.com/install" +} + +$resolvedData = [System.IO.Path]::GetFullPath($DataPath) +New-Item -ItemType Directory -Force -Path $resolvedData | Out-Null + +Write-Host "SurrealDB: bind $Bind`:$Port, rocksdb:$resolvedData" + +& $surreal start ` + --user $Username ` + --pass $Password ` + --bind "${Bind}:${Port}" ` + --log info ` + "rocksdb:$resolvedData"