diff --git a/agent/Meezi.PrintAgent/NetworkScanner.cs b/agent/Meezi.PrintAgent/NetworkScanner.cs new file mode 100644 index 0000000..de091a2 --- /dev/null +++ b/agent/Meezi.PrintAgent/NetworkScanner.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Meezi.PrintAgent; + +/// A host on the café LAN answering on a probed port. Property names match +/// the cloud's DiscoveredDevice record so SignalR maps them across. +public record ScannedDevice(string Ip, int Port, string Kind); + +/// +/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP +/// ports — used to auto-find network printers (:9100) and card terminals (:8088) +/// so the café owner doesn't have to type IP addresses. +/// +public static class NetworkScanner +{ + private const int MaxConcurrency = 128; + private const int ConnectTimeoutMs = 300; + + public static async Task> ScanAsync(string portsCsv, CancellationToken ct) + { + var ports = portsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => int.TryParse(p, out var n) ? n : 0) + .Where(n => n is > 0 and <= 65535) + .Distinct() + .ToList(); + if (ports.Count == 0) ports = [9100, 8088]; + + var results = new ConcurrentBag(); + using var gate = new SemaphoreSlim(MaxConcurrency); + var tasks = new List(); + + foreach (var prefix in LocalSubnets()) + { + for (var host = 1; host <= 254; host++) + { + var ip = $"{prefix}.{host}"; + foreach (var port in ports) + { + await gate.WaitAsync(ct); + tasks.Add(Task.Run(async () => + { + try + { + if (await CanConnectAsync(ip, port)) + results.Add(new ScannedDevice(ip, port, Classify(port))); + } + finally { gate.Release(); } + }, ct)); + } + } + } + + await Task.WhenAll(tasks); + return results + .DistinctBy(d => $"{d.Ip}:{d.Port}") + .OrderBy(d => d.Ip) + .ThenBy(d => d.Port) + .ToList(); + } + + /// Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces. + private static IEnumerable LocalSubnets() + { + var seen = new HashSet(); + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + foreach (var ua in ni.GetIPProperties().UnicastAddresses) + { + if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; + if (IPAddress.IsLoopback(ua.Address)) continue; + var b = ua.Address.GetAddressBytes(); + var prefix = $"{b[0]}.{b[1]}.{b[2]}"; + if (seen.Add(prefix)) yield return prefix; + } + } + } + + private static async Task CanConnectAsync(string ip, int port) + { + try + { + using var client = new TcpClient(); + var connect = client.ConnectAsync(ip, port); + var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs)); + if (done != connect) return false; + await connect; // observe exceptions + return client.Connected; + } + catch + { + return false; + } + } + + private static string Classify(int port) => port switch + { + 9100 => "network-printer", + 8088 => "pos-terminal", + _ => "other", + }; +} diff --git a/agent/Meezi.PrintAgent/PosTerminal.cs b/agent/Meezi.PrintAgent/PosTerminal.cs new file mode 100644 index 0000000..e64210c --- /dev/null +++ b/agent/Meezi.PrintAgent/PosTerminal.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Json; + +namespace Meezi.PrintAgent; + +/// +/// Relays a card-terminal payment on the café LAN. The cloud can't reach the +/// terminal's private IP, so it hands the agent the amount and the terminal's +/// ip:port; the agent POSTs to the terminal's local HTTP /pay endpoint and +/// reports back whether it was approved. +/// +public static class PosTerminal +{ + // A card payment blocks on the customer inserting/approving — allow plenty. + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) }; + + public static async Task<(bool Ok, string? Error)> SendPaymentAsync( + string ip, int port, long amount, string orderId, CancellationToken ct) + { + var url = $"http://{ip}:{port}/pay"; + try + { + using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct); + if (resp.IsSuccessStatusCode) return (true, null); + return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}"); + } + catch (TaskCanceledException) + { + return (false, "POS_DEVICE_TIMEOUT"); + } + catch (Exception ex) + { + return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}"); + } + } +} diff --git a/agent/Meezi.PrintAgent/Program.cs b/agent/Meezi.PrintAgent/Program.cs index 7dfa2a1..2f396aa 100644 --- a/agent/Meezi.PrintAgent/Program.cs +++ b/agent/Meezi.PrintAgent/Program.cs @@ -73,6 +73,27 @@ static async Task RunAsync(AgentConfig config) try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ } }); + // Cloud → agent: relay a card-terminal payment to the terminal on the LAN. + connection.On("PaymentRequest", async (requestId, ip, port, amount, orderId) => + { + var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None); + Console.WriteLine(ok + ? $"[pay] {amount} → {ip}:{port} ✓" + : $"[pay] {ip}:{port} ✗ {err}"); + try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ } + }); + + // Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088). + connection.On("ScanNetwork", async (requestId, ports) => + { + List found; + try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); } + catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; } + Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " + + string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}"))); + try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ } + }); + connection.Reconnected += async _ => { Console.WriteLine("[hub] reconnected"); diff --git a/src/Meezi.API/Controllers/PrintAgentsController.cs b/src/Meezi.API/Controllers/PrintAgentsController.cs index 941fdb2..ef44f56 100644 --- a/src/Meezi.API/Controllers/PrintAgentsController.cs +++ b/src/Meezi.API/Controllers/PrintAgentsController.cs @@ -117,6 +117,36 @@ public class PrintAgentsController : CafeApiControllerBase new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed."))); } + /// Ask the café's online agents to scan their LAN for devices (network + /// printers on :9100, card terminals on :8088) so the owner can pick instead of + /// typing an IP. Merges results across agents. + [HttpPost("scan")] + public async Task Scan( + string cafeId, + [FromBody] ScanRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied; + + var online = _registry.OnlineAgentIdsForCafe(cafeId); + if (online.Count == 0) + return BadRequest(new ApiResponse( + false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network."))); + + var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim(); + var merged = new Dictionary(); + foreach (var agentId in online) + { + foreach (var d in await _registry.ScanAsync(agentId, ports, ct)) + merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind); + } + + var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList(); + return Ok(new ApiResponse>(true, dtos)); + } + private async Task GenerateUniqueCodeAsync(CancellationToken ct) { for (var attempt = 0; attempt < 8; attempt++) diff --git a/src/Meezi.API/Hubs/PrintAgentHub.cs b/src/Meezi.API/Hubs/PrintAgentHub.cs index 4dc27fa..aaf0fdf 100644 --- a/src/Meezi.API/Hubs/PrintAgentHub.cs +++ b/src/Meezi.API/Hubs/PrintAgentHub.cs @@ -99,6 +99,14 @@ public class PrintAgentHub : Hub public void JobResult(string jobId, bool success, string? error) => _registry.CompleteJob(jobId, success, error); + /// Agent → cloud: result of a relayed card-terminal payment. + public void PaymentResult(string requestId, bool success, string? error) => + _registry.CompleteJob(requestId, success, error); + + /// Agent → cloud: hosts found by a LAN scan (network printers, card terminals). + public void ReportScan(string requestId, IReadOnlyList devices) => + _registry.CompleteScan(requestId, devices ?? []); + /// Agent → cloud: keep-alive so the dashboard can show an accurate "last seen". public async Task Heartbeat() { diff --git a/src/Meezi.API/Models/Printing/PrintAgentDtos.cs b/src/Meezi.API/Models/Printing/PrintAgentDtos.cs index c757a91..07d4293 100644 --- a/src/Meezi.API/Models/Printing/PrintAgentDtos.cs +++ b/src/Meezi.API/Models/Printing/PrintAgentDtos.cs @@ -24,3 +24,8 @@ public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresA public record ClaimAgentRequest(string Code, string? Name, string? MachineName); public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName); + +/// Ask online agents to scan the LAN for the given comma-separated TCP ports. +public record ScanRequest(string? Ports); + +public record ScannedDeviceDto(string Ip, int Port, string Kind); diff --git a/src/Meezi.API/Services/PosDeviceService.cs b/src/Meezi.API/Services/PosDeviceService.cs index f62816f..d219a59 100644 --- a/src/Meezi.API/Services/PosDeviceService.cs +++ b/src/Meezi.API/Services/PosDeviceService.cs @@ -1,6 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using Meezi.API.Models.Printing; +using Meezi.API.Services.Printing; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService private readonly AppDbContext _db; private readonly IHttpClientFactory _httpClientFactory; + private readonly IPrintAgentRegistry _agents; private readonly ILogger _logger; public PosDeviceService( AppDbContext db, IHttpClientFactory httpClientFactory, + IPrintAgentRegistry agents, ILogger logger) { _db = db; _httpClientFactory = httpClientFactory; + _agents = agents; _logger = logger; } @@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService if (order is null) return PosDeviceResult.Fail("ORDER_NOT_FOUND"); + var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero); + var ip = branch.PosDeviceIp!.Trim(); + + // Prefer relaying through a local print agent on the café LAN — the cloud + // can't reach the terminal's private IP directly (same reason the agent + // exists for printers). Fall back to a direct call only on-prem / when no + // agent is connected. + var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct); + if (agentId is not null) + { + var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct); + if (outcome.Success) + return PosDeviceResult.Ok(); + _logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error); + return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error); + } + var payload = new { - amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero), + amount, orderId = request.OrderId, branchId, }; - var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay"; + var url = $"http://{ip}:{port}/pay"; try { @@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message); } } + + /// The online agent best placed to reach this branch's terminal — one + /// bound to the branch if present, else any online agent of the café. + private async Task ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct) + { + var online = _agents.OnlineAgentIdsForCafe(cafeId); + if (online.Count == 0) return null; + + var agents = await _db.PrintAgents + .AsNoTracking() + .Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null) + .ToListAsync(ct); + + return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id + ?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id; + } + + /// Normalize an agent-relay error string back to a POS_DEVICE_* code. + private static string MapAgentError(string? error) => error switch + { + null or "" => "POS_DEVICE_FAILED", + var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT", + var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0], + var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED", + var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED", + _ => "POS_DEVICE_CONNECTION_FAILED", + }; } diff --git a/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs b/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs index df5f5b6..a27ee9c 100644 --- a/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs +++ b/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs @@ -7,6 +7,10 @@ namespace Meezi.API.Services.Printing; public record PrintJobRequest(string PrinterSystemName, byte[] Payload); public record PrintJobOutcome(bool Success, string? Error); +/// A host the agent found on the café LAN responding on a probed port +/// (a network printer on :9100, a card terminal on :8088, …). +public record DiscoveredDevice(string Ip, int Port, string Kind); + /// /// Tracks which print agents are currently connected (by SignalR connection) and /// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory: @@ -19,8 +23,19 @@ public interface IPrintAgentRegistry (string AgentId, string CafeId)? Resolve(string connectionId); bool IsOnline(string agentId); IReadOnlySet OnlineAgentIds(); + /// Online agents belonging to a café — used to pick a LAN bridge for a + /// card-terminal payment or a network scan. + IReadOnlySet OnlineAgentIdsForCafe(string cafeId); Task SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default); void CompleteJob(string jobId, bool success, string? error); + /// Relay a card-terminal payment through the agent on the café LAN; it + /// POSTs the amount to the terminal at ip:port and acks the approval result. + Task SendPaymentAsync( + string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default); + /// Ask the agent to scan its LAN for hosts answering on the given ports. + Task> ScanAsync( + string agentId, string ports, CancellationToken ct = default); + void CompleteScan(string requestId, IReadOnlyList devices); } public class PrintAgentRegistry : IPrintAgentRegistry @@ -29,6 +44,7 @@ public class PrintAgentRegistry : IPrintAgentRegistry private readonly ConcurrentDictionary _byConnection = new(); private readonly ConcurrentDictionary _agentConnection = new(); // agentId -> connectionId private readonly ConcurrentDictionary> _pending = new(); + private readonly ConcurrentDictionary>> _pendingScans = new(); public PrintAgentRegistry(IHubContext hub) => _hub = hub; @@ -54,6 +70,12 @@ public class PrintAgentRegistry : IPrintAgentRegistry public IReadOnlySet OnlineAgentIds() => _agentConnection.Keys.ToHashSet(); + public IReadOnlySet OnlineAgentIdsForCafe(string cafeId) => + _byConnection.Values + .Where(v => v.CafeId == cafeId) + .Select(v => v.AgentId) + .ToHashSet(); + public async Task SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default) { if (!_agentConnection.TryGetValue(agentId, out var connectionId)) @@ -87,4 +109,69 @@ public class PrintAgentRegistry : IPrintAgentRegistry if (_pending.TryGetValue(jobId, out var tcs)) tcs.TrySetResult(new PrintJobOutcome(success, error)); } + + public async Task SendPaymentAsync( + string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default) + { + if (!_agentConnection.TryGetValue(agentId, out var connectionId)) + return new PrintJobOutcome(false, "AGENT_OFFLINE"); + + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[requestId] = tcs; + try + { + await _hub.Clients.Client(connectionId).SendAsync( + "PaymentRequest", requestId, ip, port, amount, orderId, ct); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + // Card payment waits on the customer at the terminal — give it the same + // headroom the direct path uses. + timeout.CancelAfter(TimeSpan.FromSeconds(95)); + using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT"))); + return await tcs.Task; + } + catch (Exception ex) + { + return new PrintJobOutcome(false, ex.Message); + } + finally + { + _pending.TryRemove(requestId, out _); + } + } + + public async Task> ScanAsync( + string agentId, string ports, CancellationToken ct = default) + { + if (!_agentConnection.TryGetValue(agentId, out var connectionId)) + return []; + + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingScans[requestId] = tcs; + try + { + await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + using var reg = timeout.Token.Register(() => tcs.TrySetResult([])); + return await tcs.Task; + } + catch + { + return []; + } + finally + { + _pendingScans.TryRemove(requestId, out _); + } + } + + public void CompleteScan(string requestId, IReadOnlyList devices) + { + if (_pendingScans.TryGetValue(requestId, out var tcs)) + tcs.TrySetResult(devices); + } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 02ec266..1ccc214 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -333,6 +333,11 @@ "posDeviceSection": "جهاز نقطة البيع (بطاقة)", "posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.", "posDeviceIp": "عنوان IP لجهاز نقطة البيع", + "detect": "كشف تلقائي", + "detecting": "جارٍ فحص الشبكة…", + "detectNone": "لم يُعثر على أجهزة في الشبكة", + "detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي", + "detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.", "testSent": "تم إرسال الاختبار إلى الطابعة.", "sent": "تم الإرسال إلى الطابعة.", "noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 33f512f..cafffda 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -352,6 +352,11 @@ "posDeviceSection": "Card POS terminal", "posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.", "posDeviceIp": "POS device IP address", + "detect": "Auto-detect", + "detecting": "Scanning the network…", + "detectNone": "No devices found on the network", + "detectOffline": "A print server must be online to auto-detect", + "detectHint": "The print server scans your LAN to find the device.", "testSent": "Test sent to the printer.", "sent": "Sent to the printer.", "noStationItems": "This order has no items for that station.", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 1c30885..ed2576a 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -352,6 +352,11 @@ "posDeviceSection": "دستگاه پوز (کارتخوان)", "posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).", "posDeviceIp": "آدرس IP دستگاه پوز", + "detect": "تشخیص خودکار", + "detecting": "در حال جستجوی شبکه…", + "detectNone": "دستگاهی در شبکه پیدا نشد", + "detectOffline": "برای تشخیص خودکار باید پرینت‌سرور روشن و متصل باشد", + "detectHint": "پرینت‌سرور شبکه محلی را برای یافتن دستگاه اسکن می‌کند.", "testSent": "تست به پرینتر ارسال شد.", "sent": "به پرینتر ارسال شد.", "noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.", diff --git a/web/dashboard/src/components/settings/settings-printer-panel.tsx b/web/dashboard/src/components/settings/settings-printer-panel.tsx index 7fef936..ad6c5bd 100644 --- a/web/dashboard/src/components/settings/settings-printer-panel.tsx +++ b/web/dashboard/src/components/settings/settings-printer-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react"; +import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react"; import { apiGet, apiPatch } from "@/lib/api/client"; import { listPrintAgents, @@ -11,8 +11,11 @@ import { revokePrintAgent, testPrintDevice, deviceOptions, + scanNetwork, type PairingCode, + type ScannedDevice, } from "@/lib/api/print-agents"; +import { ApiClientError } from "@/lib/api/client"; import { printErrorMessage } from "@/lib/api/print"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -324,6 +327,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte dir="ltr" className="text-end" /> + { + setReceiptIp(ip); + setReceiptPort(String(port)); + }} + /> + { + setKitchenIp(ip); + setKitchenPort(String(port)); + }} + /> + { + setPosDeviceIp(ip); + setPosDevicePort(String(port)); + }} + /> ); } + +/** + * "Auto-detect" affordance for an IP field: asks the online print agent to scan + * the café LAN for the given ports and lets the owner pick a found host (which + * fills the IP + port) instead of typing an address by hand. + */ +function DetectButton({ + cafeId, + ports, + onPick, +}: { + cafeId: string; + ports: string; + onPick: (ip: string, port: number) => void; +}) { + const t = useTranslations("print"); + const [results, setResults] = useState(null); + + const scan = useMutation({ + mutationFn: () => scanNetwork(cafeId, ports), + onSuccess: (devices) => setResults(devices), + onError: (e) => + notify.error( + e instanceof ApiClientError && e.code === "AGENT_OFFLINE" + ? t("detectOffline") + : t("detectNone"), + ), + }); + + return ( +
+ + {results && + (results.length === 0 ? ( +

{t("detectNone")}

+ ) : ( +
+ {results.map((d) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/web/dashboard/src/lib/api/print-agents.ts b/web/dashboard/src/lib/api/print-agents.ts index 7f0f9e9..ec349d7 100644 --- a/web/dashboard/src/lib/api/print-agents.ts +++ b/web/dashboard/src/lib/api/print-agents.ts @@ -53,6 +53,22 @@ export function testPrintDevice(cafeId: string, deviceId: string): Promise { + return apiPost(`/api/cafes/${cafeId}/print-agents/scan`, { ports }); +} + /** Every device across all agents, for a printer-picker dropdown. */ export function deviceOptions(agents: PrintAgent[]): DeviceOption[] { return agents.flatMap((a) =>