From e209c3891ebd58341964503fc262ddcc77153486 Mon Sep 17 00:00:00 2001 From: rex-bot Date: Mon, 27 Apr 2026 18:16:47 -0400 Subject: [PATCH] merge(dev): Re-apply changes after conflict resolution --- .../API/Controllers/CostAnalysisController.cs | 108 ------ .../API/DTOs/PrintJobs/CostPerPrintDtos.cs | 99 ------ backend/API/Jobs/FilamentUsageSyncJob.cs | 79 ----- backend/Dockerfile | 3 - .../Domain/Interfaces/ICostPerPrintService.cs | 76 ----- .../Interfaces/IFilamentUsageSyncService.cs | 19 -- backend/Domain/Interfaces/IMoonrakerClient.cs | 75 +++-- .../Configuration/FilamentUsageSyncOptions.cs | 33 -- .../Services/CostPerPrintService.cs | 158 --------- .../Services/FilamentUsageSyncService.cs | 139 -------- .../Services/MoonrakerClient.cs | 307 ++++++++++++++---- backend/Program.cs | 31 +- backend/appsettings.Development.json | 5 - backend/appsettings.json | 8 +- deploy.sh | 5 +- docker-compose.dev.yml | 40 +-- frontend/package-lock.json | 16 + frontend/package.json | 1 + frontend/src/app/app.config.ts | 8 +- .../delete-filament-dialog.component.html | 78 +++++ .../delete-filament-dialog.component.scss | 150 +++++++++ .../delete-filament-dialog.component.ts | 68 ++++ .../filament-filter.component.html | 76 ----- .../filament-filter.component.scss | 134 -------- .../filament-filter.component.ts | 158 --------- .../filament-table.component.html | 44 ++- .../filament-table.component.scss | 14 + .../filament-table.component.ts | 120 +++---- frontend/src/app/services/filament.service.ts | 37 +++ 29 files changed, 784 insertions(+), 1305 deletions(-) delete mode 100644 backend/API/Controllers/CostAnalysisController.cs delete mode 100644 backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs delete mode 100644 backend/API/Jobs/FilamentUsageSyncJob.cs delete mode 100644 backend/Domain/Interfaces/ICostPerPrintService.cs delete mode 100644 backend/Domain/Interfaces/IFilamentUsageSyncService.cs delete mode 100644 backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs delete mode 100644 backend/Infrastructure/Services/CostPerPrintService.cs delete mode 100644 backend/Infrastructure/Services/FilamentUsageSyncService.cs create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.html delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.scss delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.ts create mode 100644 frontend/src/app/services/filament.service.ts diff --git a/backend/API/Controllers/CostAnalysisController.cs b/backend/API/Controllers/CostAnalysisController.cs deleted file mode 100644 index 87e94b0..0000000 --- a/backend/API/Controllers/CostAnalysisController.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Extrudex.API.DTOs.PrintJobs; -using Extrudex.Domain.Interfaces; -using Microsoft.AspNetCore.Mvc; - -namespace Extrudex.API.Controllers; - -/// -/// Controller for cost analysis endpoints. Provides spool-level -/// cost breakdowns and aggregated COGS reporting. -/// -[ApiController] -[Route("api/cost-analysis")] -public class CostAnalysisController : ControllerBase -{ - private readonly ICostPerPrintService _costService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The cost-per-print calculation service. - /// The logger for diagnostic output. - public CostAnalysisController( - ICostPerPrintService costService, - ILogger logger) - { - _costService = costService; - _logger = logger; - } - - // ── POST /api/cost-analysis/spool ──────────────────────────── - - /// - /// Calculates cost breakdowns for all print jobs associated with a specific spool. - /// Returns per-job costs plus an aggregated total. Jobs with missing cost data - /// include warnings and null cost fields — the endpoint never throws for missing data. - /// - /// The request containing the spool identifier. - /// A spool-level cost summary with per-job breakdowns. - /// Returns the spool cost breakdown with per-job details. - /// If the spool has no print jobs. - [HttpPost("spool")] - [ProducesResponseType(typeof(SpoolCostResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> CalculateSpoolCost([FromBody] SpoolCostRequest request) - { - _logger.LogDebug("Calculating cost breakdown for spool {SpoolId}", request.SpoolId); - - var results = await _costService.CalculateBySpoolAsync(request.SpoolId); - - if (results.Count == 0) - { - return NotFound(new { error = $"No print jobs found for spool with ID '{request.SpoolId}'." }); - } - - // Build the spool-level summary - var firstResult = results[0]; - var jobResponses = results.Select(MapCostToResponse).ToList(); - - // Aggregate total cost and grams — only include jobs that have a valid cost - var calculableJobs = results.Where(r => r.CostPerPrint.HasValue).ToList(); - var totalCost = calculableJobs.Count == results.Count - ? Math.Round(calculableJobs.Sum(r => r.CostPerPrint!.Value), 4) - : (decimal?)null; - - var aggregateWarnings = new List(); - if (calculableJobs.Count < results.Count) - { - aggregateWarnings.Add( - $"{results.Count - calculableJobs.Count} of {results.Count} print jobs have missing cost data. " + - "Total cost reflects only jobs with complete data."); - } - - var response = new SpoolCostResponse - { - SpoolId = request.SpoolId, - SpoolSerial = firstResult.SpoolSerial, - PurchasePrice = firstResult.PurchasePrice, - WeightTotalGrams = firstResult.WeightTotalGrams, - CostPerGram = firstResult.CostPerGram, - TotalGramsConsumed = results.Sum(r => r.GramsDerived), - TotalCost = totalCost, - JobCount = results.Count, - Jobs = jobResponses, - Warnings = aggregateWarnings - }; - - return Ok(response); - } - - /// - /// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO. - /// - private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new() - { - PrintJobId = r.PrintJobId, - PrintName = r.PrintName, - SpoolId = r.SpoolId, - SpoolSerial = r.SpoolSerial, - MmExtruded = r.MmExtruded, - GramsDerived = r.GramsDerived, - PurchasePrice = r.PurchasePrice, - WeightTotalGrams = r.WeightTotalGrams, - CostPerGram = r.CostPerGram, - CostPerPrint = r.CostPerPrint, - Warnings = r.Warnings - }; -} \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs deleted file mode 100644 index e82e6b3..0000000 --- a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Extrudex.API.DTOs.PrintJobs; - -/// -/// Response DTO for cost-per-print calculation. Contains the full cost -/// breakdown and any warnings about missing or incomplete data. -/// -public class CostPerPrintResponse -{ - /// The print job identifier this result belongs to. - public Guid PrintJobId { get; set; } - - /// Human-readable name of the print job. - public string PrintName { get; set; } = string.Empty; - - /// The spool identifier that provided filament. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// Total millimeters of filament extruded. - public decimal MmExtruded { get; set; } - - /// Derived grams consumed for this print. - public decimal GramsDerived { get; set; } - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if purchase price or total weight is missing. - public decimal? CostPerGram { get; set; } - - /// Calculated cost of this print job. Null if cost data is incomplete. - public decimal? CostPerPrint { get; set; } - - /// - /// Warnings about missing or incomplete data. Empty when all data is available - /// and the calculation succeeded. - /// - public List Warnings { get; set; } = new(); -} - -/// -/// Request DTO for batch cost calculation by spool. Returns cost breakdowns -/// for all print jobs associated with the specified spool. -/// -public class SpoolCostRequest -{ - /// The unique identifier of the spool to calculate costs for. - [Required(ErrorMessage = "SpoolId is required.")] - public Guid SpoolId { get; set; } -} - -/// -/// Response DTO for spool-level cost calculation. Contains cost breakdowns -/// for all print jobs on the spool, plus a total cost summary. -/// -public class SpoolCostResponse -{ - /// The spool identifier. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if cost data is incomplete. - public decimal? CostPerGram { get; set; } - - /// Total grams consumed across all print jobs on this spool. - public decimal TotalGramsConsumed { get; set; } - - /// Total calculated cost across all print jobs. Null if any job has missing data. - public decimal? TotalCost { get; set; } - - /// Number of print jobs included in this calculation. - public int JobCount { get; set; } - - /// - /// Individual cost breakdowns per print job. Jobs with missing data - /// will have null cost fields and populated warnings. - /// - public List Jobs { get; set; } = new(); - - /// - /// Aggregate warnings about missing data across all jobs. - /// - public List Warnings { get; set; } = new(); -} \ No newline at end of file diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs deleted file mode 100644 index 19c991d..0000000 --- a/backend/API/Jobs/FilamentUsageSyncJob.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Extrudex.API.Jobs; - -/// -/// Background job that periodically syncs filament usage data from -/// Moonraker printers. Runs as a hosted service and polls all active -/// Moonraker printers on a configurable interval to persist usage -/// data to the Extrudex database. -/// -/// Configuration is bound from the "FilamentUsageSync" section in -/// appsettings.json. Set Enabled=false to disable without removing -/// the service registration. -/// -public class FilamentUsageSyncJob : BackgroundService -{ - private readonly IFilamentUsageSyncService _syncService; - private readonly FilamentUsageSyncOptions _options; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncJob. - /// - /// The service that performs the actual sync logic. - /// Configuration options for polling interval and timeouts. - /// Logger for diagnostic output. - public FilamentUsageSyncJob( - IFilamentUsageSyncService syncService, - IOptions options, - ILogger logger) - { - _syncService = syncService; - _options = options.Value; - _logger = logger; - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_options.Enabled) - { - _logger.LogInformation("Filament usage sync job is disabled via configuration — exiting"); - return; - } - - _logger.LogInformation( - "Filament usage sync job starting — polling every {Interval}", - _options.PollingInterval); - - // Delay briefly on startup to allow the web host to fully initialize - await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - var syncedCount = await _syncService.SyncAllAsync(stoppingToken); - - _logger.LogInformation( - "Filament usage sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}", - syncedCount, _options.PollingInterval); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, - "Error during filament usage sync cycle — will retry in {Interval}", - _options.PollingInterval); - } - - await Task.Delay(_options.PollingInterval, stoppingToken); - } - - _logger.LogInformation("Filament usage sync job shutting down"); - } -} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 23aacef..b604978 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,9 +17,6 @@ RUN dotnet publish Extrudex.csproj \ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install curl for health check (not included in aspnet base image) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - # Non-root user for security RUN adduser --disabled-password --gecos "" appuser USER appuser diff --git a/backend/Domain/Interfaces/ICostPerPrintService.cs b/backend/Domain/Interfaces/ICostPerPrintService.cs deleted file mode 100644 index 041736a..0000000 --- a/backend/Domain/Interfaces/ICostPerPrintService.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Service interface for calculating the cost of goods sold (COGS) per print job. -/// Uses the spool's purchase price and the print job's derived grams consumed -/// to produce a cost breakdown. Handles missing cost data gracefully by returning -/// warnings rather than throwing exceptions. -/// -public interface ICostPerPrintService -{ - /// - /// Calculates the cost per print for a specific print job. - /// - /// The unique identifier of the print job. - /// Optional cancellation token. - /// - /// A containing the cost breakdown, - /// or warnings if cost data is missing or incomplete. - /// - Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default); - - /// - /// Calculates cost breakdowns for all print jobs associated with a specific spool. - /// Useful for spool-level COGS reporting. - /// - /// The unique identifier of the spool. - /// Optional cancellation token. - /// - /// A list of for each print job on the spool. - /// Jobs with missing cost data will include warnings. - /// - Task> CalculateBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default); -} - -/// -/// Result of a cost-per-print calculation. Contains the cost breakdown -/// and any warnings about missing or incomplete cost data. -/// -public class CostPerPrintResult -{ - /// The print job identifier this result belongs to. - public Guid PrintJobId { get; set; } - - /// Human-readable name of the print job. - public string PrintName { get; set; } = string.Empty; - - /// The spool identifier that provided filament. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// Total millimeters of filament extruded. - public decimal MmExtruded { get; set; } - - /// Derived grams consumed for this print. - public decimal GramsDerived { get; set; } - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if purchase price or total weight is missing. - public decimal? CostPerGram { get; set; } - - /// Calculated cost of this print job. Null if cost data is incomplete. - public decimal? CostPerPrint { get; set; } - - /// - /// Warnings about missing or incomplete data that prevented a full calculation. - /// Empty when all data is available and the calculation succeeded. - /// - public List Warnings { get; set; } = new(); -} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs deleted file mode 100644 index 951f80d..0000000 --- a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Service interface for syncing filament usage data from printers -/// into the Extrudex database. Handles querying Moonraker printers, -/// computing derived usage metrics, and persisting updates to spools -/// and print job records. -/// -public interface IFilamentUsageSyncService -{ - /// - /// Performs a single sync cycle: queries all active Moonraker printers, - /// fetches their current filament usage data, and persists updates to - /// the database. - /// - /// Cancellation token for graceful shutdown. - /// The number of printers successfully synced. - Task SyncAllAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs index d4599f2..6feff80 100644 --- a/backend/Domain/Interfaces/IMoonrakerClient.cs +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -1,39 +1,76 @@ namespace Extrudex.Domain.Interfaces; /// -/// Client interface for communicating with Moonraker REST API endpoints -/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). -/// Used to retrieve filament usage data, print job status, and -/// remaining spool weight from the printer. +/// Client for communicating with Moonraker REST API on Klipper-based printers +/// (e.g., Elegoo Centauri Carbon). Retrieves print job metadata including +/// filament usage data. /// public interface IMoonrakerClient { /// - /// Fetches the current filament usage data from the Moonraker server. - /// Returns a dictionary of usage metrics reported by the printer. + /// Retrieves the current printer status from Moonraker. /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// A dictionary of usage metric names to their decimal values. - Task> GetFilamentUsageAsync( + /// Cancellation token. + /// The printer status string (e.g., "idle", "printing", "paused", "error"). + Task GetPrinterStatusAsync( string hostnameOrIp, int port, - string? apiKey, + string? apiKey = null, CancellationToken cancellationToken = default); /// - /// Checks whether the Moonraker server is reachable and responding. + /// Retrieves filament usage data from the current or most recent print job. + /// Moonraker exposes this via the /api/objects endpoint querying + /// "history" and "print_stats" objects. /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// true if the server responded successfully; otherwise false. - Task IsReachableAsync( + /// Cancellation token. + /// Filament usage data from Moonraker, or null if unavailable. + Task GetFilamentUsageAsync( string hostnameOrIp, int port, - string? apiKey, + string? apiKey = null, CancellationToken cancellationToken = default); +} + +/// +/// Represents filament usage data retrieved from a Moonraker-equipped printer. +/// Maps to Moonraker's print_stats and history objects. +/// +public class MoonrakerFilamentUsage +{ + /// + /// Millimeters of filament extruded during the print job. + /// + public decimal MmExtruded { get; set; } + + /// + /// The filename of the G-code file being or recently printed. + /// + public string? GcodeFileName { get; set; } + + /// + /// Current print state from Moonraker (e.g., "printing", "complete", "error"). + /// + public string PrintState { get; set; } = string.Empty; + + /// + /// Total print time in seconds, if available from Moonraker. + /// + public double? PrintDurationSeconds { get; set; } + + /// + /// Timestamp (UTC) when the print job was started, if available. + /// + public DateTime? StartedAt { get; set; } + + /// + /// Timestamp (UTC) when the print job completed, if available. + /// + public DateTime? CompletedAt { get; set; } } \ No newline at end of file diff --git a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs deleted file mode 100644 index 29f95b2..0000000 --- a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Extrudex.Infrastructure.Configuration; - -/// -/// Configuration options for the FilamentUsageSync background job. -/// Bound from appsettings.json under the "FilamentUsageSync" section. -/// Controls polling interval and per-printer timeout settings. -/// -public class FilamentUsageSyncOptions -{ - /// - /// The section name in appsettings.json where these options are bound. - /// - public const string SectionName = "FilamentUsageSync"; - - /// - /// How often the background job polls printers for usage data. - /// Default: 5 minutes. Minimum recommended: 1 minute. - /// - public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Timeout for individual HTTP requests to a Moonraker printer. - /// Default: 30 seconds. - /// - public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Whether the sync job is enabled. Set to false to disable - /// the background job without removing its registration. - /// Default: true. - /// - public bool Enabled { get; set; } = true; -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/CostPerPrintService.cs b/backend/Infrastructure/Services/CostPerPrintService.cs deleted file mode 100644 index e4b2593..0000000 --- a/backend/Infrastructure/Services/CostPerPrintService.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Extrudex.Infrastructure.Services; - -/// -/// Calculates the cost of goods sold (COGS) per print job using the spool's -/// purchase price and the print job's derived grams consumed. -/// -/// Formula: -/// cost_per_gram = purchase_price / weight_total_grams -/// cost_per_print = grams_derived × cost_per_gram -/// -/// Handles missing data gracefully — if the spool has no purchase price or -/// weight recorded, the result includes warnings and null cost fields -/// instead of throwing exceptions. -/// -public class CostPerPrintService : ICostPerPrintService -{ - private readonly ExtrudexDbContext _dbContext; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The database context for data access. - /// The logger for diagnostic output. - public CostPerPrintService(ExtrudexDbContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - /// - public async Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default) - { - _logger.LogDebug("Calculating cost per print for job {PrintJobId}", printJobId); - - var job = await _dbContext.PrintJobs - .Include(j => j.Spool) - .ThenInclude(s => s!.MaterialBase) - .FirstOrDefaultAsync(j => j.Id == printJobId, cancellationToken); - - if (job is null) - { - _logger.LogWarning("Print job {PrintJobId} not found for cost calculation", printJobId); - return new CostPerPrintResult - { - PrintJobId = printJobId, - Warnings = new List { $"Print job with ID '{printJobId}' not found." } - }; - } - - return BuildResult(job); - } - - /// - public async Task> CalculateBySpoolAsync( - Guid spoolId, CancellationToken cancellationToken = default) - { - _logger.LogDebug("Calculating cost per print for all jobs on spool {SpoolId}", spoolId); - - var jobs = await _dbContext.PrintJobs - .Include(j => j.Spool) - .ThenInclude(s => s!.MaterialBase) - .Where(j => j.SpoolId == spoolId) - .OrderByDescending(j => j.CreatedAt) - .ToListAsync(cancellationToken); - - if (jobs.Count == 0) - { - _logger.LogDebug("No print jobs found for spool {SpoolId}", spoolId); - return Array.Empty(); - } - - return jobs.Select(BuildResult).ToList(); - } - - /// - /// Builds a from a print job entity. - /// Computes cost_per_gram and cost_per_print when all required data is available. - /// Populates warnings when data is missing or incomplete. - /// - /// The print job entity with Spool navigation loaded. - /// A cost calculation result with breakdown and any warnings. - private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job) - { - var warnings = new List(); - var spool = job.Spool; - - // Map what we always have - var result = new CostPerPrintResult - { - PrintJobId = job.Id, - PrintName = job.PrintName, - SpoolId = job.SpoolId, - SpoolSerial = spool?.SpoolSerial ?? string.Empty, - MmExtruded = job.MmExtruded, - GramsDerived = job.GramsDerived, - }; - - // Guard: spool must be loaded - if (spool is null) - { - warnings.Add("Spool data is not available for this print job."); - result.Warnings = warnings; - return result; - } - - // Capture purchase price - result.PurchasePrice = spool.PurchasePrice; - result.WeightTotalGrams = spool.WeightTotalGrams; - - // Check for missing purchase price - if (!spool.PurchasePrice.HasValue) - { - warnings.Add( - "Spool purchase price is not recorded. Cost calculation requires a purchase price on the spool."); - } - - // Check for zero or negative weight — prevents division by zero - if (spool.WeightTotalGrams <= 0) - { - warnings.Add( - "Spool total weight is zero or not recorded. Cost calculation requires a positive weight_total_grams on the spool."); - } - - // Check for zero grams derived - if (job.GramsDerived <= 0) - { - warnings.Add( - "Derived grams consumed is zero. Ensure mm_extruded, filament diameter, and material density are recorded for this print job."); - } - - // If all data is present and valid, compute the cost - if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0 && job.GramsDerived > 0) - { - var costPerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; - result.CostPerGram = Math.Round(costPerGram, 6); - result.CostPerPrint = Math.Round(job.GramsDerived * costPerGram, 4); - - _logger.LogDebug( - "Cost calculated for job {PrintJobId}: {GramsDerived}g × {CostPerGram:C}/g = {CostPerPrint:C}", - job.Id, job.GramsDerived, result.CostPerGram, result.CostPerPrint); - } - else - { - _logger.LogDebug( - "Cost calculation incomplete for job {PrintJobId}: missing data (warnings: {WarningCount})", - job.Id, warnings.Count); - } - - result.Warnings = warnings; - return result; - } -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs deleted file mode 100644 index c2e305b..0000000 --- a/backend/Infrastructure/Services/FilamentUsageSyncService.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Extrudex.Domain.Enums; -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Extrudex.Infrastructure.Configuration; - -/// -/// Service that syncs filament usage data from Moonraker printers into the -/// Extrudex database. Queries all active Moonraker printers, fetches their -/// current filament usage metrics, and updates spool remaining weights and -/// print job records. -/// -public class FilamentUsageSyncService : IFilamentUsageSyncService -{ - private readonly ExtrudexDbContext _dbContext; - private readonly IMoonrakerClient _moonrakerClient; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncService. - /// - /// The EF Core database context for persisting updates. - /// The Moonraker HTTP client for fetching printer data. - /// Logger for diagnostic output. - public FilamentUsageSyncService( - ExtrudexDbContext dbContext, - IMoonrakerClient moonrakerClient, - ILogger logger) - { - _dbContext = dbContext; - _moonrakerClient = moonrakerClient; - _logger = logger; - } - - /// - public async Task SyncAllAsync(CancellationToken cancellationToken = default) - { - _logger.LogInformation("Starting filament usage sync cycle"); - - var printers = await _dbContext.Printers - .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) - .Include(p => p.AmsUnits) - .ThenInclude(u => u.Slots) - .ThenInclude(s => s.Spool) - .ToListAsync(cancellationToken); - - if (printers.Count == 0) - { - _logger.LogInformation("No active Moonraker printers found — skipping sync"); - return 0; - } - - _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count); - - var syncedCount = 0; - - foreach (var printer in printers) - { - try - { - var usageData = await _moonrakerClient.GetFilamentUsageAsync( - printer.HostnameOrIp, - printer.Port, - printer.ApiKey, - cancellationToken); - - if (usageData.Count == 0) - { - _logger.LogWarning( - "No usage data returned from printer {PrinterName} ({Host}:{Port})", - printer.Name, printer.HostnameOrIp, printer.Port); - continue; - } - - // Update spool remaining weights from AMS data - UpdateSpoolWeights(printer, usageData); - - // Mark printer as seen and idle (reachable = idle, not printing) - printer.LastSeenAt = DateTime.UtcNow; - printer.Status = PrinterStatus.Idle; - - syncedCount++; - _logger.LogInformation( - "Successfully synced filament usage from printer {PrinterName}", - printer.Name); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error syncing filament usage from printer {PrinterName} ({Host}:{Port})", - printer.Name, printer.HostnameOrIp, printer.Port); - } - } - - await _dbContext.SaveChangesAsync(cancellationToken); - - _logger.LogInformation( - "Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced", - syncedCount, printers.Count); - - return syncedCount; - } - - /// - /// Updates spool remaining weights based on usage data received from Moonraker. - /// For printers with AMS units, updates the remaining weight on each slot's spool. - /// - private void UpdateSpoolWeights( - Domain.Entities.Printer printer, - Dictionary usageData) - { - // Update AMS slot remaining weights if available - foreach (var amsUnit in printer.AmsUnits) - { - foreach (var slot in amsUnit.Slots) - { - if (slot.Spool != null && slot.RemainingWeightG.HasValue) - { - // Sync the AMS-reported remaining weight to the spool - slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value; - - _logger.LogDebug( - "Updated spool {SpoolSerial} remaining weight to {Weight}g", - slot.Spool.SpoolSerial, slot.RemainingWeightG.Value); - } - } - } - - // If usage data contains extruded mm, log it for observability - if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0) - { - _logger.LogInformation( - "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job", - printer.Name, mmExtruded); - } - } -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs index 1666dcf..beb4ec3 100644 --- a/backend/Infrastructure/Services/MoonrakerClient.cs +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -1,15 +1,20 @@ -using System.Net.Http.Json; +using System.Globalization; +using System.Net.Http.Headers; using System.Text.Json; using Extrudex.Domain.Interfaces; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Extrudex.Infrastructure.Configuration; +namespace Extrudex.Infrastructure.Services; /// -/// HTTP client for communicating with Moonraker REST API endpoints -/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). -/// Retrieves filament usage data and printer status information. +/// HTTP client for communicating with the Moonraker REST API on +/// Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// +/// Moonraker endpoints used: +/// - GET /api/objects?print_stats — current print stats including filament used +/// - GET /api/objects?history — job history with filament usage per job +/// +/// Authentication: optional X-Api-Key header when API key is configured. /// public class MoonrakerClient : IMoonrakerClient { @@ -17,9 +22,9 @@ public class MoonrakerClient : IMoonrakerClient private readonly ILogger _logger; /// - /// Creates a new MoonrakerClient with the configured HTTP client and logger. + /// Creates a new MoonrakerClient with the specified HTTP client and logger. /// - /// The HTTP client for making requests to Moonraker endpoints. + /// The HTTP client used for API calls. /// Logger for diagnostic output. public MoonrakerClient(HttpClient httpClient, ILogger logger) { @@ -28,103 +33,271 @@ public class MoonrakerClient : IMoonrakerClient } /// - public async Task> GetFilamentUsageAsync( + public async Task GetPrinterStatusAsync( string hostnameOrIp, int port, - string? apiKey, + string? apiKey = null, CancellationToken cancellationToken = default) { var baseUrl = BuildBaseUrl(hostnameOrIp, port); - var result = new Dictionary(); + var requestUrl = $"{baseUrl}/api/objects?print_stats"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); try { - // Query Moonraker server info endpoint for filament usage data - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/history/items?limit=1"); - if (!string.IsNullOrEmpty(apiKey)) - { - request.Headers.Add("X-Api-Key", apiKey); - } - using var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); - // Extract filament usage from the response - // Moonraker returns job history with filament_used and similar fields - if (json.TryGetProperty("result", out var resultElement)) - { - if (resultElement.TryGetProperty("items", out var items) && items.GetArrayLength() > 0) - { - var job = items[0]; + // Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } } + var state = doc.RootElement + .GetProperty("result") + .GetProperty("print_stats") + .GetProperty("state") + .GetString(); - // Moonraker tracks filament_used in millimeters - if (job.TryGetProperty("filament_used", out var filamentUsed)) - { - result["mm_extruded"] = filamentUsed.GetDecimal(); - } - - // Total duration in seconds - if (job.TryGetProperty("print_duration", out var duration)) - { - result["print_duration_seconds"] = duration.GetDecimal(); - } - } - } - - _logger.LogDebug( - "Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics", - hostnameOrIp, port, result.Count); + return state ?? "unknown"; } catch (HttpRequestException ex) { _logger.LogWarning(ex, - "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", + "Moonraker printer status request failed for {Host}:{Port}", + hostnameOrIp, port); + return "offline"; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Malformed Moonraker response from {Host}:{Port}", + hostnameOrIp, port); + return "error"; + } + } + + /// + public async Task GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey = null, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + // Fetch current print_stats (has live filament_used for active/recent job) + PrintStatsResult? printStats = null; + try + { + printStats = await FetchPrintStatsAsync(baseUrl, apiKey, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Moonraker print_stats request failed for {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Malformed Moonraker print_stats response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + + if (printStats is null) + return null; + + // Attempt to enrich with history data for timing info + HistoryResult? history = null; + try + { + history = await FetchHistoryAsync(baseUrl, apiKey, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Moonraker history request failed for {Host}:{Port}", hostnameOrIp, port); } catch (JsonException ex) { _logger.LogWarning(ex, - "Failed to parse Moonraker response from {Host}:{Port}", + "Malformed Moonraker history response from {Host}:{Port}", hostnameOrIp, port); } - return result; + DateTime? startedAt = null; + DateTime? completedAt = null; + double? printDurationSeconds = null; + + if (history is not null) + { + startedAt = history.StartTime; + completedAt = history.EndTime; + printDurationSeconds = history.PrintDuration; + } + + return new MoonrakerFilamentUsage + { + MmExtruded = printStats.FilamentUsedMm ?? 0m, + GcodeFileName = printStats.FileName, + PrintState = printStats.State ?? "unknown", + PrintDurationSeconds = printDurationSeconds, + StartedAt = startedAt, + CompletedAt = completedAt + }; } - /// - public async Task IsReachableAsync( - string hostnameOrIp, - int port, + /// + /// Fetches and parses print_stats from the Moonraker API. + /// + private async Task FetchPrintStatsAsync( + string baseUrl, string? apiKey, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { - var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var requestUrl = $"{baseUrl}/api/objects?print_stats"; - try + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("print_stats", out var stats)) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info"); - if (!string.IsNullOrEmpty(apiKey)) - { - request.Headers.Add("X-Api-Key", apiKey); - } - - using var response = await _httpClient.SendAsync(request, cancellationToken); - return response.IsSuccessStatusCode; + _logger.LogWarning("Moonraker response missing 'print_stats' object"); + return null; } - catch (HttpRequestException) + + return new PrintStatsResult { - _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); - return false; + State = stats.TryGetProperty("state", out var stateEl) ? stateEl.GetString() : null, + FilamentUsedMm = stats.TryGetProperty("filament_used", out var filamentEl) && + filamentEl.ValueKind == JsonValueKind.Number + ? filamentEl.GetDecimal() : (decimal?)null, + FileName = stats.TryGetProperty("filename", out var fileEl) ? fileEl.GetString() : null + }; + } + + /// + /// Fetches and parses history (last job) from the Moonraker API. + /// + private async Task FetchHistoryAsync( + string baseUrl, + string? apiKey, + CancellationToken cancellationToken) + { + var requestUrl = $"{baseUrl}/api/objects?history"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("history", out var history)) + { + _logger.LogWarning("Moonraker response missing 'history' object"); + return null; + } + + // Try last_job first, then job + JsonElement jobEl; + if (!history.TryGetProperty("last_job", out jobEl) && + !history.TryGetProperty("job", out jobEl)) + { + _logger.LogDebug("Moonraker history has no 'last_job' or 'job' entry"); + return null; + } + + return new HistoryResult + { + StartTime = ParseDateTimeProperty(jobEl, "start_time"), + EndTime = ParseDateTimeProperty(jobEl, "end_time"), + PrintDuration = jobEl.TryGetProperty("print_duration", out var durEl) && + durEl.ValueKind == JsonValueKind.Number + ? durEl.GetDouble() : (double?)null + }; + } + + /// + /// Parses a Moonraker timestamp property (Unix epoch seconds or ISO 8601 string). + /// + private static DateTime? ParseDateTimeProperty(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + // Moonraker returns Unix epoch seconds as a number + if (prop.ValueKind == JsonValueKind.Number) + { + var epochSeconds = prop.GetDouble(); + return DateTime.UnixEpoch.AddSeconds(epochSeconds); + } + + // Fallback: try parsing as ISO 8601 string + if (prop.ValueKind == JsonValueKind.String) + { + var str = prop.GetString(); + if (str is not null && + DateTime.TryParse(str, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var dt)) + { + return dt.ToUniversalTime(); + } + } + + return null; + } + + /// + /// Builds the base URL for Moonraker API calls. + /// + private static string BuildBaseUrl(string hostnameOrIp, int port) => + $"http://{hostnameOrIp}:{port}"; + + /// + /// Applies the Moonraker API key to the request header if provided. + /// + private static void ApplyApiKey(HttpRequestMessage request, string? apiKey) + { + if (!string.IsNullOrWhiteSpace(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); } } /// - /// Builds the base URL for Moonraker API calls from hostname and port. + /// Parsed result from Moonraker's print_stats object. + /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues. /// - private static string BuildBaseUrl(string hostnameOrIp, int port) + private sealed class PrintStatsResult { - return $"http://{hostnameOrIp}:{port}"; + public string? State { get; set; } + public decimal? FilamentUsedMm { get; set; } + public string? FileName { get; set; } + } + + /// + /// Parsed result from Moonraker's history/last_job object. + /// + private sealed class HistoryResult + { + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public double? PrintDuration { get; set; } } } \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index c2bdcc9..992585e 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,9 +1,8 @@ using System.Reflection; +using System.Net.Http.Headers; using Extrudex.API.Filters; using Extrudex.API.Hubs; -using Extrudex.API.Jobs; using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Configuration; using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Services; using FluentValidation; @@ -52,8 +51,22 @@ builder.Services.AddSwaggerGen(c => // ── QR Code Generation ────────────────────────────────────── builder.Services.AddSingleton(); -// ── Cost Per Print Calculation ───────────────────────────── -builder.Services.AddScoped(); +// ── Filament Usage Service ────────────────────────────────── +builder.Services.AddScoped(); + +// ── Moonraker Client ─────────────────────────────────────── +// Named HttpClient for Moonraker API calls with configurable timeout. +// Poller timeout is driven by MoonrakerPollerOptions.RequestTimeout. +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); +}); + +// ── Moonraker Usage Poller (Background Service) ───────────── +builder.Services.Configure( + builder.Configuration.GetSection("MoonrakerPoller")); +builder.Services.AddHostedService(); // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. @@ -82,16 +95,6 @@ builder.Services.AddCors(options => // ── SignalR (real-time printer updates) ──────────────────── builder.Services.AddSignalR(); -// ── Filament Usage Sync (Background Job) ────────────────── -builder.Services.Configure( - builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName)); -builder.Services.AddHttpClient(client => -{ - client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0"); -}); -builder.Services.AddScoped(); -builder.Services.AddHostedService(); - // ── Health Checks ─────────────────────────────────────────── builder.Services.AddHealthChecks() .AddNpgSql(connectionString); diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 06130e3..8edfdd9 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -8,10 +8,5 @@ }, "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme" - }, - "FilamentUsageSync": { - "PollingInterval": "00:01:00", - "RequestTimeout": "00:00:30", - "Enabled": true } } \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index e5c747f..4f8a225 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -10,9 +10,9 @@ "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" }, - "FilamentUsageSync": { - "PollingInterval": "00:05:00", - "RequestTimeout": "00:00:30", - "Enabled": true + "MoonrakerPoller": { + "Enabled": true, + "PollInterval": "00:00:30", + "RequestTimeout": "00:00:10" } } \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index d00c1e6..d17960c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,14 +18,13 @@ echo "📦 Building and starting services..." $COMPOSE_CMD -f docker-compose.dev.yml up -d --build echo "⏳ Waiting for services to become healthy..." -sleep 15 +sleep 10 echo "✅ Deployment complete!" echo "" echo "Services running:" -echo " • PostgreSQL: localhost:5433" echo " • Extrudex API: http://localhost:5080" -echo " • Extrudex Web: http://localhost:5081" +echo " • Control Center Web: http://localhost:5081" echo "" echo "To view logs:" echo " $COMPOSE_CMD -f docker-compose.dev.yml logs -f" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0a3d49..2859dff 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,25 +1,6 @@ -services: - extrudex-db: - image: postgres:16-alpine - container_name: extrudex-db - environment: - POSTGRES_USER: extrudex - POSTGRES_PASSWORD: changeme - POSTGRES_DB: extrudex - ports: - - "5433:5432" - volumes: - - extrudex-db-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U extrudex"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - restart: unless-stopped - networks: - - extrudex-network +version: '3.8' +services: extrudex-api: build: context: ./backend @@ -30,14 +11,6 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 - - EXTRUDEX_DB_HOST=extrudex-db - - EXTRUDEX_DB_PORT=5432 - - EXTRUDEX_DB_NAME=extrudex - - EXTRUDEX_DB_USER=extrudex - - EXTRUDEX_DB_PASSWORD=changeme - depends_on: - extrudex-db: - condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] @@ -48,11 +21,11 @@ services: networks: - extrudex-network - extrudex-web: + control-center-web: build: - context: ./frontend + context: ../Control-Center/frontend dockerfile: Dockerfile - container_name: extrudex-web + container_name: control-center-web ports: - "5081:80" depends_on: @@ -62,9 +35,6 @@ services: networks: - extrudex-network -volumes: - extrudex-db-data: - networks: extrudex-network: driver: bridge \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b31e340..d0614db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -326,6 +327,21 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.10.tgz", + "integrity": "sha512-sIzAcxwtRCJ/fu0tK4mo1ooiEaDxJ+Nl6s9nK1D1NP1em12VX03Jx8CMixp/kVtgh4mZnm1x6psBB0FUz3U3Ug==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.10" + } + }, "node_modules/@angular/build": { "version": "21.2.8", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e2cedb..02eaba4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "private": true, "packageManager": "npm@11.11.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..e66aad8 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,15 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) + provideRouter(routes), + provideHttpClient(withFetch()), + provideAnimationsAsync(), ] -}; +}; \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html new file mode 100644 index 0000000..71fe1c6 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html @@ -0,0 +1,78 @@ + +

+ + Delete Filament Spool? +

+ + +

+ You are about to permanently remove this filament spool from inventory. +

+ + +
+
+ Material + {{ filament.materialBaseName }}{{ filament.materialFinishName ? ' — ' + filament.materialFinishName : '' }}{{ filament.materialModifierName ? ' (' + filament.materialModifierName + ')' : '' }} +
+ +
+ Brand + {{ filament.brand }} +
+ +
+ Color + + + + {{ filament.colorName }} + +
+ +
+ Serial + {{ filament.spoolSerial }} +
+ +
+ Remaining + {{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }} +
+ +
+ Status + + + {{ filament.isActive ? 'Active' : 'Inactive' }} + + +
+
+ +

+ + This action cannot be undone. +

+
+ + + + + \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss new file mode 100644 index 0000000..6789bf4 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss @@ -0,0 +1,150 @@ +/** + * Delete Filament Dialog Styles + * Touch-optimized confirmation dialog for spool removal + */ + +$spacing-unit: 8px; +$color-critical: #ef4444; +$color-inactive: #94a3b8; +$color-active: #22c55e; + +// Dialog title +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: $spacing-unit; + color: $color-critical; + + mat-icon { + font-size: 24px !important; + width: 24px !important; + height: 24px !important; + } +} + +// Description text +.dialog-description { + margin: 0 0 $spacing-unit * 2; + font-size: 14px; + line-height: 1.5; + color: var(--mat-sys-on-surface); +} + +// Spool details card +.spool-details { + display: flex; + flex-direction: column; + gap: $spacing-unit; + padding: $spacing-unit * 1.5; + background-color: var(--mat-sys-surface-container); + border-radius: 8px; + margin-bottom: $spacing-unit * 2; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-unit * 0.5 0; + font-size: 14px; + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-outline-variant); + padding-bottom: $spacing-unit; + } +} + +.detail-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; +} + +.detail-value { + font-weight: 400; + color: var(--mat-sys-on-surface); + text-align: right; +} + +// Color swatch inline +.color-swatch-inline { + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid rgba(0, 0, 0, 0.12); + vertical-align: middle; + margin-right: 4px; +} + +.color-value { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +// Serial value — monospace +.serial-value { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 13px; + letter-spacing: 0.02em; +} + +// Status badge — matches filament table styling +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + + &.active { + background-color: rgba($color-active, 0.12); + color: $color-active; + } + + &.inactive { + background-color: rgba($color-inactive, 0.12); + color: $color-inactive; + } +} + +// Warning text +.dialog-warning { + display: flex; + align-items: center; + gap: $spacing-unit; + margin: 0; + font-size: 13px; + color: $color-critical; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } +} + +// Dialog action buttons +mat-dialog-actions { + padding-top: $spacing-unit * 2; + + .cancel-button { + min-width: 80px; + } + + .confirm-button { + min-width: 120px; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + margin-right: 4px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts new file mode 100644 index 0000000..d16a87a --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogModule, +} from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; + +import { Filament } from '../../models/filament.model'; + +/** + * Data passed into the delete confirmation dialog. + */ +export interface DeleteFilamentDialogData { + filament: Filament; +} + +/** + * Delete confirmation dialog for filament spool removal. + * + * Displays spool details (material, brand, color, serial, remaining weight) + * and requires the user to confirm before deletion proceeds. + * Cancel dismisses the dialog with no action. + */ +@Component({ + selector: 'app-delete-filament-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + ], + templateUrl: './delete-filament-dialog.component.html', + styleUrl: './delete-filament-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteFilamentDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data: DeleteFilamentDialogData = inject(MAT_DIALOG_DATA); + + /** The filament spool being considered for deletion */ + readonly filament = this.data.filament; + + /** Format weight for display in dialog */ + formatWeight(grams: number): string { + if (grams >= 1000) { + return `${(grams / 1000).toFixed(1)}kg`; + } + return `${Math.round(grams)}g`; + } + + /** Cancel — close dialog with false (no deletion) */ + onCancel(): void { + this.dialogRef.close(false); + } + + /** Confirm — close dialog with true (proceed with deletion) */ + onConfirm(): void { + this.dialogRef.close(true); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.html b/frontend/src/app/components/filament-filter/filament-filter.component.html deleted file mode 100644 index fd93087..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.html +++ /dev/null @@ -1,76 +0,0 @@ - - \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.scss b/frontend/src/app/components/filament-filter/filament-filter.component.scss deleted file mode 100644 index 8ebbaaf..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.scss +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Filament Filter Bar Styles - * Responsive filter layout for kiosk and mobile - */ - -$spacing-unit: 8px; - -.filament-filter-bar { - display: flex; - align-items: center; - gap: $spacing-unit * 2; - flex-wrap: wrap; - padding: $spacing-unit * 2 0; - margin-bottom: $spacing-unit * 2; -} - -// Form field sizing -.filter-field { - flex: 0 1 auto; - min-width: 160px; - - &.material-filter { - min-width: 200px; - } - - &.color-filter { - min-width: 180px; - } - - // Reduce vertical spacing inside filter fields - .mat-mdc-form-field-subscript-wrapper { - display: none; // No hint/error text needed for filters - } -} - -// Selected material chips -.selected-chips { - flex-wrap: wrap; - gap: 4px; -} - -.filter-chip { - font-size: 12px !important; - min-height: 24px !important; - - mat-icon { - font-size: 14px !important; - width: 14px !important; - height: 14px !important; - } -} - -// Active filter icon -.filter-active-icon { - color: var(--mat-sys-primary); - font-size: 18px !important; - width: 18px !important; - height: 18px !important; -} - -// Checkbox styling -.filter-checkbox { - display: flex; - align-items: center; - gap: 4px; - white-space: nowrap; - user-select: none; - touch-action: manipulation; // Prevent zoom on double-tap - - .checkbox-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - color: var(--mat-sys-on-surface-variant); - transition: color 0.2s ease; - - &.active { - color: var(--mat-sys-primary); - } - } -} - -// Clear filters button -.clear-filters-btn { - display: flex; - align-items: center; - gap: 4px; - font-size: 13px; - - mat-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - } -} - -// Responsive: stack filters vertically on small screens -@media (max-width: 768px) { - .filament-filter-bar { - flex-direction: column; - align-items: stretch; - gap: $spacing-unit; - } - - .filter-field { - width: 100%; - min-width: unset; - - &.material-filter, - &.color-filter { - min-width: unset; - } - } - - .filter-checkbox { - padding: $spacing-unit 0; - } - - .clear-filters-btn { - align-self: flex-start; - } -} - -// Extra-small screens (phone portrait) -@media (max-width: 480px) { - .filament-filter-bar { - padding: $spacing-unit 0; - margin-bottom: $spacing-unit; - } - - .filter-checkbox { - font-size: 13px; - } -} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.ts b/frontend/src/app/components/filament-filter/filament-filter.component.ts deleted file mode 100644 index 7559afc..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - computed, - signal, -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatInputModule } from '@angular/material/input'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatIconModule } from '@angular/material/icon'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatButtonModule } from '@angular/material/button'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { - Filament, - StockLevel, - classifyStockLevel, -} from '../../models/filament.model'; - -/** Filter state emitted by the filament filter component */ -export interface FilamentFilterState { - /** Selected material base names — empty means all */ - materialBaseNames: string[]; - - /** Color search text — empty string means all */ - colorSearch: string; - - /** Whether to show only low/critical stock */ - lowStockOnly: boolean; - - /** Whether to show only active spools */ - activeOnly: boolean; -} - -/** - * FilamentFilterComponent — Filter bar for the filament inventory list. - * - * Provides: - * - Material type multi-select filter - * - Color name text search - * - Low stock toggle (shows only critical/low spools) - * - Active-only toggle - * - Clear all filters action - */ -@Component({ - selector: 'app-filament-filter', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule, - MatCheckboxModule, - MatIconModule, - MatChipsModule, - MatButtonModule, - MatTooltipModule, - ], - templateUrl: './filament-filter.component.html', - styleUrl: './filament-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class FilamentFilterComponent { - /** Filament data input — used to derive material options */ - @Input() set filaments(value: Filament[]) { - this._filaments.set(value); - const materials = [...new Set(value.map((f) => f.materialBaseName))].sort(); - this.materialOptions.set(materials); - } - get filaments(): Filament[] { - return this._filaments(); - } - private readonly _filaments = signal([]); - - /** Available material base names derived from filament data */ - readonly materialOptions = signal([]); - - /** Selected material base names */ - readonly selectedMaterials = signal([]); - - /** Color search text */ - readonly colorSearch = signal(''); - - /** Low stock only toggle */ - readonly lowStockOnly = signal(false); - - /** Active only toggle */ - readonly activeOnly = signal(false); - - /** Computed: whether any filters are active */ - readonly hasActiveFilters = computed( - () => - this.selectedMaterials().length > 0 || - this.colorSearch().trim().length > 0 || - this.lowStockOnly() || - this.activeOnly() - ); - - /** Emits the current filter state whenever filters change */ - @Output() readonly filterChange = new EventEmitter(); - - /** Handle material selection change */ - onMaterialChange(selected: string[]): void { - this.selectedMaterials.set(selected); - this.emitFilterState(); - } - - /** Handle color search input */ - onColorSearchChange(value: string): void { - this.colorSearch.set(value); - this.emitFilterState(); - } - - /** Handle low stock toggle */ - onLowStockToggle(checked: boolean): void { - this.lowStockOnly.set(checked); - this.emitFilterState(); - } - - /** Handle active-only toggle */ - onActiveOnlyToggle(checked: boolean): void { - this.activeOnly.set(checked); - this.emitFilterState(); - } - - /** Remove a single material chip */ - removeMaterial(material: string): void { - const updated = this.selectedMaterials().filter((m) => m !== material); - this.selectedMaterials.set(updated); - this.emitFilterState(); - } - - /** Clear all filters */ - clearAll(): void { - this.selectedMaterials.set([]); - this.colorSearch.set(''); - this.lowStockOnly.set(false); - this.activeOnly.set(false); - this.emitFilterState(); - } - - /** Emit the current filter state */ - private emitFilterState(): void { - this.filterChange.emit({ - materialBaseNames: this.selectedMaterials(), - colorSearch: this.colorSearch().trim().toLowerCase(), - lowStockOnly: this.lowStockOnly(), - activeOnly: this.activeOnly(), - }); - } -} \ No newline at end of file diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 5beccd4..e0aa433 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,12 +1,6 @@ - +
- - - @if (criticalCount() > 0) {