diff --git a/README.md b/README.md index ed558c5..633ef5f 100644 --- a/README.md +++ b/README.md @@ -312,3 +312,23 @@ docker compose config --quiet ## 정리 Archive Platform Ecosystem은 Archive-Market, Archive-Nexus, Archive-Logistics, Archive-Ledger, ArchiveOS를 연결해 외부 수요, 제조 이벤트 생성, 물류 경로와 비용 계산, 금융성 원장과 정산, 승인과 정책 근거, 장애 관제를 하나의 이벤트 드리븐 AX 백엔드 흐름으로 구현한 Java/Spring 기반 프로젝트입니다. 각 서비스는 Outbox, idempotency, retry, safe-mode, DEGRADED 상태 분리를 통해 외부 장애가 전체 런타임으로 전파되지 않도록 설계했습니다. +# ArchiveOS Console V3 + +ArchiveOS는 Archive-Market, Archive-Nexus, Archive-Logistics, Archive-Ledger의 상태·합성 런타임 이벤트·승인·정산 균형을 읽기 전용으로 관제하는 Control Tower입니다. + +## 핵심 콘솔 + +- **대시보드**: 전체 상태, 라이브 메쉬, 우선 조치, 서비스 균형 +- **서비스**: 핵심 서비스 상태와 외부 연동(Atlas) 분리 +- **운영**: 에이전트, 작업 역량, 작업 흐름, 자동화 +- **재무**: 합성 정산 흐름, 서비스별 손익, 승인·정산, 대사 +- **기록**: 실시간 이벤트, 감사 이력, 운영 지식 +- **설정**: 일반 설정, 연동, 고급 도구 + +실시간 메쉬는 `GET /api/live-flow/stream` SSE를 우선 사용하며 실제로 수집·저장된 합성 런타임 이벤트만 표시합니다. 실제 고객·결제·계좌·금융 데이터는 사용하지 않습니다. + +Archive 핵심 상태는 Market/Nexus/Logistics/Ledger/ArchiveOS만으로 계산합니다. Atlas와 실험 시스템은 외부 연동으로 분리되며, 기본 비활성인 Labs 시스템은 핵심 KPI나 상태 판정에 포함되지 않습니다. + +## 언어 지원 + +한국어(기본), English, 日本語, 简体中文을 지원합니다. 우측 상단 지구본 메뉴에서 선택하며 `archive.locale`에 저장합니다. eventType, API path, enum 및 기존 계약 호환 키(`logitics`)는 번역하지 않고 UI 문구만 번역합니다. diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceController.java b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceController.java new file mode 100644 index 0000000..d97dfc6 --- /dev/null +++ b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceController.java @@ -0,0 +1,18 @@ +package com.archiveos.ai.ecosystem; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class EcosystemBalanceController { + private final EcosystemBalanceService service; + public EcosystemBalanceController(EcosystemBalanceService service) { this.service = service; } + @GetMapping("/api/ecosystem/balance/summary") public Map summary() { return envelope(service.summary()); } + @GetMapping("/api/ecosystem/balance/recommendations") public Map recommendations() { return envelope(service.recommendations()); } + @PostMapping("/api/ecosystem/balance/simulate") public Map simulate(@RequestBody(required = false) Map request) { return envelope(service.simulate(request)); } + private Map envelope(Object data) { Map result = new LinkedHashMap<>(); result.put("data", data); return result; } +} diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceProperties.java b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceProperties.java new file mode 100644 index 0000000..99ecca0 --- /dev/null +++ b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceProperties.java @@ -0,0 +1,46 @@ +package com.archiveos.ai.ecosystem; + +import java.math.BigDecimal; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Thresholds for synthetic ecosystem balance observations. */ +@ConfigurationProperties(prefix = "archiveos.ecosystem.balance") +public class EcosystemBalanceProperties { + private Margin market = new Margin(8, 18); + private Margin nexus = new Margin(5, 12); + private Margin logistics = new Margin(3, 10); + private Margin ledger = new Margin(4, 12); + private Margin archiveos = new Margin(0, 8); + private int backlogWarning = 20; + private int capacityWarningPercent = 90; + private int profitConcentrationPercent = 55; + + public Margin getMarket() { return market; } public void setMarket(Margin value) { market = value; } + public Margin getNexus() { return nexus; } public void setNexus(Margin value) { nexus = value; } + public Margin getLogistics() { return logistics; } public void setLogistics(Margin value) { logistics = value; } + public Margin getLedger() { return ledger; } public void setLedger(Margin value) { ledger = value; } + public Margin getArchiveos() { return archiveos; } public void setArchiveos(Margin value) { archiveos = value; } + public int getBacklogWarning() { return backlogWarning; } public void setBacklogWarning(int value) { backlogWarning = value; } + public int getCapacityWarningPercent() { return capacityWarningPercent; } public void setCapacityWarningPercent(int value) { capacityWarningPercent = value; } + public int getProfitConcentrationPercent() { return profitConcentrationPercent; } public void setProfitConcentrationPercent(int value) { profitConcentrationPercent = value; } + + public Margin marginFor(String key) { + return switch (key) { + case "market" -> market; + case "nexus" -> nexus; + case "logitics", "logistics" -> logistics; + case "ledger" -> ledger; + default -> archiveos; + }; + } + + public static class Margin { + private BigDecimal minMargin; + private BigDecimal maxMargin; + public Margin() { this(BigDecimal.ZERO, BigDecimal.ZERO); } + public Margin(int minMargin, int maxMargin) { this(BigDecimal.valueOf(minMargin), BigDecimal.valueOf(maxMargin)); } + public Margin(BigDecimal minMargin, BigDecimal maxMargin) { this.minMargin = minMargin; this.maxMargin = maxMargin; } + public BigDecimal getMinMargin() { return minMargin; } public void setMinMargin(BigDecimal value) { minMargin = value; } + public BigDecimal getMaxMargin() { return maxMargin; } public void setMaxMargin(BigDecimal value) { maxMargin = value; } + } +} diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceService.java b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceService.java new file mode 100644 index 0000000..1d13887 --- /dev/null +++ b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemBalanceService.java @@ -0,0 +1,133 @@ +package com.archiveos.ai.ecosystem; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** Read-only explanation of synthetic cross-service financial and capacity balance. */ +@Service +public class EcosystemBalanceService { + private final EcosystemService ecosystem; + private final EcosystemBalanceProperties policy; + + public EcosystemBalanceService(EcosystemService ecosystem, EcosystemBalanceProperties policy) { + this.ecosystem = ecosystem; + this.policy = policy; + } + + public Map summary() { + Map services = map(ecosystem.summary().get("services")); + List> rows = new ArrayList<>(); + BigDecimal totalRevenue = BigDecimal.ZERO; + BigDecimal totalCost = BigDecimal.ZERO; + BigDecimal totalProfit = BigDecimal.ZERO; + for (String key : List.of("market", "nexus", "logitics", "ledger", "archiveos")) { + Map source = "archiveos".equals(key) ? Map.of("status", "HEALTHY", "name", "ArchiveOS") : map(services.get(key)); + Map body = financeBody(map(source.get("summary"))); + BigDecimal revenue = revenueFor(key, body); + BigDecimal cost = costFor(key, body); + BigDecimal profit = profitFor(key, body); + if (profit == null && revenue != null && cost != null) profit = revenue.subtract(cost); + BigDecimal cash = amount(body, "cashBalance", "availableCash", "cash", "balance"); + BigDecimal backlog = amount(body, "backlog", "pending", "approvalRequired"); + if (!"archiveos".equals(key)) { + totalRevenue = totalRevenue.add(orZero(revenue)); + totalCost = totalCost.add(orZero(cost)); + totalProfit = totalProfit.add(orZero(profit)); + } + rows.add(row(key, source, body, revenue, cost, profit, cash, backlog)); + } + for (Map row : rows) { + row.put("revenueShare", ratio(decimal(row.get("revenue")), totalRevenue)); + row.put("expenseShare", ratio(decimal(row.get("cost")), totalCost)); + BigDecimal rowProfit = decimal(row.get("profit")); + row.put("profitShare", ratio(rowProfit == null ? null : rowProfit.max(BigDecimal.ZERO), totalProfit.max(BigDecimal.ZERO))); + enrichBalance(row); + } + Map targetMargins = new LinkedHashMap<>(); + for (String key : List.of("market", "nexus", "logistics", "ledger", "archiveos")) { + EcosystemBalanceProperties.Margin margin = policy.marginFor(key); + targetMargins.put(key, margin.getMinMargin() + "-" + margin.getMaxMargin() + "%"); + } + Map result = new LinkedHashMap<>(); + result.put("syntheticData", true); + result.put("targetMargins", targetMargins); + result.put("policy", Map.of("backlogWarning", policy.getBacklogWarning(), "capacityWarningPercent", policy.getCapacityWarningPercent(), "profitConcentrationPercent", policy.getProfitConcentrationPercent())); + result.put("totals", Map.of("revenue", totalRevenue, "cost", totalCost, "profit", totalProfit)); + result.put("services", rows); + result.put("balanceStatus", balanceStatus(rows)); + result.put("reviewReason", reviewReason(rows)); + return result; + } + + public Map recommendations() { + Map summary = summary(); + @SuppressWarnings("unchecked") List> rows = (List>) summary.get("services"); + List> actions = new ArrayList<>(); + for (Map row : rows) { + String service = String.valueOf(row.get("serviceId")); + String balance = String.valueOf(row.get("balance")); + BigDecimal backlog = decimal(row.get("backlog")); + if ("CONCENTRATED".equals(balance)) actions.add(action(service, "수익 집중", String.valueOf(row.get("balanceReason")), "READ_ONLY")); + if ("UNDER_PRESSURE".equals(balance)) actions.add(action(service, "손익 압박", String.valueOf(row.get("balanceReason")), "READ_ONLY")); + if (backlog != null && backlog.compareTo(BigDecimal.valueOf(policy.getBacklogWarning())) > 0) actions.add(action(service, "적체 증가", "처리 대기량이 정책 경고 기준을 초과했습니다. 작업 역량과 자동 처리 상태를 확인하세요.", "READ_ONLY")); + BigDecimal capacity = decimal(row.get("capacityUtilization")); + if (capacity != null && capacity.compareTo(BigDecimal.valueOf(policy.getCapacityWarningPercent())) >= 0) actions.add(action(service, "처리 역량 주의", "처리 역량 사용률이 정책 경고 기준을 초과했습니다.", "READ_ONLY")); + } + if (actions.isEmpty()) actions.add(action("archive-platform", "균형 범위", "현재 수집된 합성 지표에서 즉시 조정이 필요한 불균형은 없습니다.", "READ_ONLY")); + return Map.of("syntheticData", true, "recommendations", actions); + } + + public Map simulate(Map request) { + return Map.of("status", "DRY_RUN", "syntheticData", true, "message", "외부 수수료나 자금은 변경하지 않습니다.", "current", summary(), "request", request == null ? Map.of() : request); + } + + private Map row(String key, Map source, Map body, BigDecimal revenue, BigDecimal cost, BigDecimal profit, BigDecimal cash, BigDecimal backlog) { + EcosystemBalanceProperties.Margin target = policy.marginFor(key); + BigDecimal margin = revenue == null || revenue.signum() == 0 || profit == null ? null : profit.multiply(BigDecimal.valueOf(100)).divide(revenue, 2, RoundingMode.HALF_UP); + Map row = new LinkedHashMap<>(); + row.put("serviceId", "archiveos".equals(key) ? "archiveos" : "logitics".equals(key) ? "archive-logistics" : "archive-" + key); + row.put("serviceName", "archiveos".equals(key) ? "ArchiveOS" : string(source.get("name"), "Archive-" + key)); + row.put("status", "archiveos".equals(key) ? "HEALTHY" : string(source.get("status"), "UNKNOWN")); + row.put("financeSource", key + " latest summary"); + row.put("revenue", revenue); row.put("cost", cost); row.put("profit", profit); row.put("cashBalance", cash); row.put("backlog", backlog); + row.put("targetMinMargin", target.getMinMargin()); row.put("targetMaxMargin", target.getMaxMargin()); row.put("operatingMargin", margin); + row.put("marginGap", margin == null ? null : margin.compareTo(target.getMinMargin()) < 0 ? margin.subtract(target.getMinMargin()) : margin.compareTo(target.getMaxMargin()) > 0 ? margin.subtract(target.getMaxMargin()) : BigDecimal.ZERO); + row.put("capacityUtilization", firstAmount(body, "capacityUtilization", "usedCapacityPercent")); + row.put("approvalBacklog", firstAmount(body, "approvalBacklog", "approvalRequired")); + row.put("settlementBacklog", firstAmount(body, "settlementBacklog", "settlementPending")); + row.put("feeConcentration", firstAmount(body, "feeConcentration")); + row.put("negativeProfitStreak", firstAmount(body, "negativeProfitStreak")); + return row; + } + + private void enrichBalance(Map row) { + BigDecimal margin = decimal(row.get("operatingMargin")); + BigDecimal min = decimal(row.get("targetMinMargin")); + BigDecimal max = decimal(row.get("targetMaxMargin")); + if (margin == null || min == null || max == null) { row.put("balance", "NO_DATA"); row.put("balanceReason", "손익률을 판단할 수 있는 합성 재무 데이터가 아직 수집되지 않았습니다."); return; } + if (margin.compareTo(max) > 0) { row.put("balance", "CONCENTRATED"); row.put("balanceReason", "영업이익률이 정책 상한을 초과했습니다."); return; } + if (margin.compareTo(min) < 0) { row.put("balance", "UNDER_PRESSURE"); row.put("balanceReason", "영업이익률이 정책 하한보다 낮습니다."); return; } + row.put("balance", "WITHIN_RANGE"); row.put("balanceReason", "영업이익률이 정책 목표 범위 안에 있습니다."); + } + + private Map action(String service, String title, String reason, String mode) { return Map.of("serviceId", service, "title", title, "reason", reason, "mode", mode); } + private String balanceStatus(List> rows) { long available = rows.stream().filter(row -> !"NO_DATA".equals(row.get("balance"))).count(); if (available == 0) return "NO_DATA"; if (available < rows.size()) return "PARTIAL_DATA"; return rows.stream().anyMatch(row -> "UNDER_PRESSURE".equals(row.get("balance")) || "CONCENTRATED".equals(row.get("balance")) || concentrationExceeded(row)) ? "COMPLETE_REVIEW" : "COMPLETE_BALANCED"; } + private String reviewReason(List> rows) { long missing = rows.stream().filter(row -> "NO_DATA".equals(row.get("balance"))).count(); if (missing == rows.size()) return "수집된 재무 데이터가 없어 생태계 균형을 평가할 수 없습니다."; if (missing > 0) return "일부 서비스의 재무 데이터가 아직 수집되지 않아 생태계 균형은 부분 평가 상태입니다."; return rows.stream().filter(row -> "UNDER_PRESSURE".equals(row.get("balance"))).findFirst().map(row -> row.get("serviceName") + " 손익이 권장 범위 아래입니다.").orElse("현재 수집된 합성 지표는 균형 범위에 있습니다."); } + private boolean concentrationExceeded(Map row) { BigDecimal share = decimal(row.get("profitShare")); return share != null && share.compareTo(BigDecimal.valueOf(policy.getProfitConcentrationPercent())) > 0; } + @SuppressWarnings("unchecked") private Map map(Object value) { return value instanceof Map map ? (Map) map : Map.of(); } + private String string(Object value, String fallback) { return value == null || String.valueOf(value).isBlank() ? fallback : String.valueOf(value); } + private BigDecimal amount(Map source, String... keys) { for (String key : keys) if (source.containsKey(key) && source.get(key) != null) return decimal(source.get(key)); return null; } + private BigDecimal firstAmount(Map source, String... keys) { return amount(source, keys); } + private BigDecimal decimal(Object value) { try { return value == null ? null : new BigDecimal(String.valueOf(value)); } catch (NumberFormatException error) { return null; } } + private BigDecimal orZero(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } + private BigDecimal ratio(BigDecimal value, BigDecimal total) { return value == null || total.signum() == 0 ? null : value.multiply(BigDecimal.valueOf(100)).divide(total, 2, RoundingMode.HALF_UP); } + private BigDecimal revenueFor(String key, Map body) { return switch (key) { case "market" -> amount(body, "recognizedRevenue", "totalRevenue", "revenue"); case "nexus" -> amount(body, "manufacturingRevenue", "totalRevenue", "revenue"); case "logitics" -> amount(body, "logisticsRevenue", "totalRevenue", "revenue"); case "ledger" -> amount(body, "settlementAgencyRevenue", "totalRevenue", "revenue"); default -> amount(body, "costRecoveryRevenue", "totalRevenue", "revenue"); }; } + private BigDecimal costFor(String key, Map body) { return switch (key) { case "market" -> amount(body, "totalExpense", "totalCost", "cost"); case "nexus" -> amount(body, "totalCost", "materialCost", "maintenanceCost", "qualityLossCost", "workforceCost"); case "logitics" -> amount(body, "totalCost", "fuelCost", "tollCost", "workforceCost", "delayPenaltyCost"); case "ledger" -> amount(body, "operatingCost", "totalCost", "cost"); default -> amount(body, "operatingCost", "totalCost", "cost"); }; } + private BigDecimal profitFor(String key, Map body) { return amount(body, "operatingProfit", "profit", "profitAmount"); } + private Map financeBody(Map source) { Map result = new LinkedHashMap<>(source); for (String key : List.of("data", "summary", "economy", "marketEconomy", "settlementAgency", "cashflow", "workforce")) if (result.get(key) instanceof Map) result.putAll(financeBody(map(result.get(key)))); return result; } +} diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemProperties.java b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemProperties.java index b392aee..10d5a66 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemProperties.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemProperties.java @@ -32,6 +32,7 @@ public static class ServiceConfig { private String baseUrl; private String healthPath = "/actuator/health"; private String summaryPath; + private String operationsSummaryPath; private String outboxSummaryPath; private String routeSummaryPath; private String approvalRequiredPath; @@ -55,6 +56,8 @@ public static class ServiceConfig { public void setHealthPath(String healthPath) { this.healthPath = healthPath; } public String getSummaryPath() { return summaryPath; } public void setSummaryPath(String summaryPath) { this.summaryPath = summaryPath; } + public String getOperationsSummaryPath() { return operationsSummaryPath; } + public void setOperationsSummaryPath(String operationsSummaryPath) { this.operationsSummaryPath = operationsSummaryPath; } public String getOutboxSummaryPath() { return outboxSummaryPath; } public void setOutboxSummaryPath(String outboxSummaryPath) { this.outboxSummaryPath = outboxSummaryPath; } public String getRouteSummaryPath() { return routeSummaryPath; } diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemService.java b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemService.java index bd4553d..8db1388 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemService.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/ecosystem/EcosystemService.java @@ -115,7 +115,7 @@ public Map runDemo() { private Map checkAll(String traceId) { Map services = new LinkedHashMap<>(); services.put("market", checkMarket()); - services.put("nexus", check("NEXUS", nexus.config(), nexus.health(), nexus.outboxSummary())); + services.put("nexus", checkNexus()); services.put("logitics", check("LOGITICS", logitics.config(), logitics.health(), logitics.operationsSummary())); services.put("ledger", check("LEDGER", ledger.config(), ledger.health(), ledger.operationsSummary())); return services; @@ -157,6 +157,25 @@ private Map checkMarket() { return serviceMap(config, status.name(), snapshot.get("checked_at"), body, error); } + private Map checkNexus() { + EcosystemProperties.ServiceConfig config = nexus.config(); + if (config == null || !config.isEnabled()) return disabled(config); + IntegrationResult health = nexus.health(); + IntegrationResult operations = nexus.operationsSummary(); + IntegrationResult outbox = nexus.outboxSummary(); + EcosystemServiceStatus status = aggregateServiceStatus(List.of(health, operations, outbox)); + Map operationsData = responseData(operations.body()); + Map outboxData = responseData(outbox.body()); + Map body = new LinkedHashMap<>(operationsData); + body.put("operations", operationsData); + body.put("outbox", outboxData); + body.put("health", health.body()); + body.put("capabilities", Map.of("operations", capability(operations), "outbox", capability(outbox))); + String error = firstError(health, operations, outbox); + Map snapshot = repository.recordHealth("NEXUS", config.getName(), config.getBaseUrl(), status.name(), firstHttpStatus(operations, outbox, health), body, error); + return serviceMap(config, status.name(), snapshot.get("checked_at"), body, error); + } + private Map check(String type, EcosystemProperties.ServiceConfig config, IntegrationResult health, IntegrationResult summary) { if (config == null || !config.isEnabled()) return disabled(config); EcosystemServiceStatus status = health.status() == EcosystemServiceStatus.HEALTHY && summary.status() == EcosystemServiceStatus.HEALTHY diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/integration/nexus/NexusClient.java b/archiveos-ai/src/main/java/com/archiveos/ai/integration/nexus/NexusClient.java index c1d364c..6efa16d 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/integration/nexus/NexusClient.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/integration/nexus/NexusClient.java @@ -19,6 +19,7 @@ public NexusClient(EcosystemProperties properties, EcosystemServiceClient client public IntegrationResult health() { return get(config().getHealthPath()); } public IntegrationResult outboxSummary() { return get(config().getSummaryPath()); } + public IntegrationResult operationsSummary() { return get(config().getOperationsSummaryPath()); } public IntegrationResult outboxEvents() { return get("/api/outbox/events"); } public IntegrationResult workforceSummary() { return get(config().getWorkforceSummaryPath()); } public IntegrationResult productivitySummary() { return get(config().getProductivitySummaryPath()); } diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowController.java b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowController.java index bf1378d..c0b6993 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowController.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowController.java @@ -7,13 +7,16 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController public class LiveFlowController { private final LiveFlowService service; + private final LiveFlowEventBroadcaster broadcaster; - public LiveFlowController(LiveFlowService service) { + public LiveFlowController(LiveFlowService service, LiveFlowEventBroadcaster broadcaster) { this.service = service; + this.broadcaster = broadcaster; } @GetMapping("/api/live-flow/summary") @@ -41,6 +44,11 @@ public Map replay(@RequestParam(required = false) String from, @PostMapping("/api/live-flow/refresh") public Map refresh() { return envelope(service.refresh()); } + @GetMapping(value = "/api/live-flow/stream", produces = "text/event-stream") + public SseEmitter stream(@org.springframework.web.bind.annotation.RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) { + return broadcaster.connect(lastEventId, service.summary()); + } + private Map envelope(Object data) { Map value = new LinkedHashMap<>(); value.put("data", data); diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcaster.java b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcaster.java new file mode 100644 index 0000000..3a5604e --- /dev/null +++ b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcaster.java @@ -0,0 +1,127 @@ +package com.archiveos.ai.liveflow; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.MediaType; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Small in-process SSE fan-out for normalized flow events. Events are already + * persisted by {@link LiveFlowRepository}; this component only delivers the + * persisted representation to connected read-only dashboards. + */ +@Component +public class LiveFlowEventBroadcaster { + private static final Logger log = LoggerFactory.getLogger(LiveFlowEventBroadcaster.class); + private static final long TIMEOUT_MS = 15 * 60 * 1000L; + private static final int HISTORY_LIMIT = 250; + private final Map emitters = new ConcurrentHashMap<>(); + private final CopyOnWriteArrayList> history = new CopyOnWriteArrayList<>(); + private final LiveFlowRepository repository; + + public LiveFlowEventBroadcaster(@Lazy LiveFlowRepository repository) { + this.repository = repository; + } + + public SseEmitter connect(String lastEventId, Map snapshot) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + String id = "emitter-" + System.nanoTime(); + emitters.put(id, emitter); + emitter.onCompletion(() -> emitters.remove(id)); + emitter.onTimeout(() -> { + emitters.remove(id); + emitter.complete(); + }); + emitter.onError(error -> emitters.remove(id)); + try { + send(emitter, "snapshot-" + Instant.now().toEpochMilli(), "snapshot", snapshot); + replayAfter(emitter, lastEventId); + } catch (IOException error) { + emitters.remove(id); + emitter.completeWithError(error); + } + return emitter; + } + + public void publish(Map event) { + if (event == null || event.isEmpty()) return; + Map safe = new LinkedHashMap<>(event); + String eventId = String.valueOf(safe.getOrDefault("event_id", "flow-" + System.nanoTime())); + history.removeIf(previous -> eventId.equals(String.valueOf(previous.get("event_id")))); + history.add(safe); + while (history.size() > HISTORY_LIMIT) history.remove(0); + for (Map.Entry entry : emitters.entrySet()) { + try { + send(entry.getValue(), eventId, "runtime-event", safe); + } catch (IOException error) { + emitters.remove(entry.getKey()); + entry.getValue().completeWithError(error); + } + } + } + + public void publishStatus(Map status) { + broadcast("service-status-" + Instant.now().toEpochMilli(), "service-status", status); + } + + @Scheduled(fixedDelay = 20_000L) + public void heartbeat() { + broadcast("heartbeat-" + Instant.now().toEpochMilli(), "heartbeat", Map.of("at", Instant.now().toString())); + } + + void replayAfter(SseEmitter emitter, String lastEventId) throws IOException { + for (Map event : replayCandidates(lastEventId)) { + send(emitter, String.valueOf(event.get("event_id")), "runtime-event", event); + } + } + + List> replayCandidates(String lastEventId) { + if (lastEventId == null || lastEventId.isBlank()) return List.of(); + int start = -1; + for (int index = 0; index < history.size(); index++) { + if (lastEventId.equals(String.valueOf(history.get(index).get("event_id")))) { + start = index; + break; + } + } + List> candidates; + if (start >= 0) { + candidates = history.subList(start + 1, history.size()); + } else { + if (!repository.existsEventId(lastEventId)) log.warn("Live Flow Last-Event-ID was not found; sending snapshot only. eventId={}", lastEventId); + candidates = repository.findAfterEventId(lastEventId, HISTORY_LIMIT); + } + Map> unique = new LinkedHashMap<>(); + for (Map event : candidates) { + String eventId = String.valueOf(event.get("event_id")); + if (!lastEventId.equals(eventId)) unique.putIfAbsent(eventId, event); + } + return List.copyOf(unique.values()); + } + + private void broadcast(String id, String type, Map payload) { + for (Map.Entry entry : emitters.entrySet()) { + try { + send(entry.getValue(), id, type, payload); + } catch (IOException error) { + emitters.remove(entry.getKey()); + entry.getValue().completeWithError(error); + } + } + } + + private void send(SseEmitter emitter, String id, String type, Object payload) throws IOException { + emitter.send(SseEmitter.event().id(id).name(type).data(payload, MediaType.APPLICATION_JSON)); + } +} diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowRepository.java b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowRepository.java index b5da5c1..255dbe4 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowRepository.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowRepository.java @@ -15,13 +15,15 @@ @Repository public class LiveFlowRepository { private final JdbcTemplate jdbc; + private final LiveFlowEventBroadcaster broadcaster; - public LiveFlowRepository(JdbcTemplate jdbc) { + public LiveFlowRepository(JdbcTemplate jdbc, LiveFlowEventBroadcaster broadcaster) { this.jdbc = jdbc; + this.broadcaster = broadcaster; } public Map upsert(LiveFlowEvent event) { - return jdbc.queryForObject(""" + List> changed = jdbc.query(""" insert into public.ecosystem_flow_event( event_id, correlation_id, source_system_id, source_service_id, domain, event_type, entity_type, entity_id, from_node, to_node, status, severity, display_label, @@ -35,11 +37,20 @@ on conflict (event_id) do update set occurred_at = excluded.occurred_at, metadata = excluded.metadata, received_at = now() + where ecosystem_flow_event.status is distinct from excluded.status + or ecosystem_flow_event.severity is distinct from excluded.severity + or ecosystem_flow_event.display_label is distinct from excluded.display_label + or ecosystem_flow_event.amount_bucket is distinct from excluded.amount_bucket + or ecosystem_flow_event.metadata is distinct from excluded.metadata returning * """, this::row, event.eventId(), event.correlationId(), event.sourceSystemId(), event.sourceServiceId(), event.domain(), event.eventType(), event.entityType(), event.entityId(), event.fromNode(), event.toNode(), event.status(), event.severity(), event.displayLabel(), event.amountBucket(), Timestamp.from(event.occurredAt()), Json.write(event.metadata() == null ? Map.of() : event.metadata())); + Map saved = changed.stream().findFirst().orElseGet(() -> jdbc.queryForObject( + "select * from public.ecosystem_flow_event where event_id = ?", this::row, event.eventId())); + if (!changed.isEmpty()) broadcaster.publish(saved); + return saved; } public List> recent(int limit) { @@ -47,6 +58,23 @@ public List> recent(int limit) { this::row, clamp(limit)); } + public boolean existsEventId(String eventId) { + Integer count = jdbc.queryForObject("select count(*) from public.ecosystem_flow_event where event_id = ?", Integer.class, eventId); + return count != null && count > 0; + } + + /** Returns persisted events strictly after a Last-Event-ID in receive order. */ + public List> findAfterEventId(String eventId, int limit) { + return jdbc.query(""" + select current_event.* from public.ecosystem_flow_event current_event + join public.ecosystem_flow_event checkpoint on checkpoint.event_id = ? + where current_event.received_at > checkpoint.received_at + or (current_event.received_at = checkpoint.received_at and current_event.id > checkpoint.id) + order by current_event.received_at asc, current_event.id asc + limit ? + """, this::row, eventId, clampReplayLimit(limit)); + } + public List> replay(String from, String to, int limit) { if (from != null && !from.isBlank() && to != null && !to.isBlank()) { return jdbc.query(""" @@ -116,6 +144,7 @@ private Map row(ResultSet rs, int index) throws SQLException { } private int clamp(int limit) { return Math.min(Math.max(limit, 1), 500); } + private int clampReplayLimit(int limit) { return Math.min(Math.max(limit, 1), 250); } private String instant(ResultSet rs, String name) throws SQLException { Timestamp timestamp = rs.getTimestamp(name); return timestamp == null ? null : timestamp.toInstant().toString(); diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowService.java b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowService.java index a43117a..bcd2d77 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowService.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/liveflow/LiveFlowService.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.UUID; import org.springframework.stereotype.Service; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.transaction.annotation.Transactional; @Service @@ -24,7 +25,7 @@ public class LiveFlowService { private final ApprovalCallbackOutboxRepository callbacks; private final AuditLogService audit; private volatile Instant lastAutoRefreshAt = Instant.EPOCH; - private static final Duration AUTO_REFRESH_INTERVAL = Duration.ofSeconds(15); + private static final Duration AUTO_REFRESH_INTERVAL = Duration.ofSeconds(1); private static final long LIVE_THRESHOLD_SECONDS = 60; private static final long STALE_THRESHOLD_SECONDS = 300; @@ -50,28 +51,40 @@ private Map summarySnapshot() { value.put("dataPolicy", "Synthetic Runtime Events"); value.put("warning", "No real customer, payment, account, or financial data."); value.put("recent", recent); - value.put("runtime", runtimeSummary(value, recent)); + Map runtime = runtimeSummary(value, recent); + value.put("runtime", runtime); + Map approvalSummary = approvals.summary(); + value.put("approvalBacklog", approvalSummary.containsKey("pending") ? number(approvalSummary.get("pending")) : null); + value.put("approvalBacklogSource", "current synthetic approval queue"); + value.put("processingBacklog", processingBacklog(runtime)); + value.put("processingBacklogSource", "current service outbox and processing backlog"); return value; } public Map topology() { return Map.of( "nodes", List.of( - node("market", "Archive-Market", "source", 10, 38), - node("logistics", "Archive-Logistics", "flow", 34, 30), - node("nexus", "Archive-Nexus", "factory", 34, 68), - node("ledger", "Archive-Ledger", "financial", 62, 38), - node("archiveos", "ArchiveOS Control Tower", "control", 84, 38), + node("market", "Archive-Market", "source", 10, 22), + node("nexus", "Archive-Nexus", "factory", 42, 22), + node("logistics", "Archive-Logistics", "flow", 76, 22), + node("ledger", "Archive-Ledger", "financial", 22, 70), + node("archiveos", "ArchiveOS Control Tower", "control", 52, 70), node("settlement", "Settlement", "batch", 84, 70)), - "lanes", List.of("Market", "Logistics", "Factory", "Ledger", "Control", "Settlement"), + "lanes", List.of("Demand", "Manufacturing", "Logistics", "Finance", "Control", "Settlement"), "edges", List.of( - edge("market", "logistics", "shipment request"), + edge("market", "nexus", "production / shipment request"), edge("market", "ledger", "sales / refund / claim"), + edge("nexus", "logistics", "shipment / route"), edge("logistics", "ledger", "logistics cost"), edge("nexus", "ledger", "manufacturing cost"), edge("ledger", "archiveos", "approval request"), edge("archiveos", "ledger", "approval callback"), - edge("ledger", "settlement", "daily settlement"))); + edge("ledger", "settlement", "daily settlement"), + edge("archiveos", "settlement", "settlement control"), + edge("market", "archiveos", "health / summary"), + edge("nexus", "archiveos", "health / summary"), + edge("logistics", "archiveos", "health / summary"), + edge("ledger", "archiveos", "health / summary"))); } public Map recent(int limit) { @@ -84,12 +97,26 @@ public Map recent(int limit) { @Transactional public Map refresh() { + return refresh(true); + } + + /** Read-only external collector used when upstream systems do not expose a push/cursor feed yet. */ + @Scheduled(fixedDelayString = "${archive.live-flow.collector-interval-ms:1000}") + public void collectRealtime() { + try { + refresh(false); + } catch (RuntimeException ignored) { + // The collector records degraded events inside refresh; scheduler threads must remain alive. + } + } + + private Map refresh(boolean auditEnabled) { String traceId = "flow-" + UUID.randomUUID().toString().substring(0, 8); - audit.recordEvent("live_flow_refresh_requested", "live_flow", traceId, traceId, Map.of("mode", "LIVE")); + if (auditEnabled) audit.recordEvent("live_flow_refresh_requested", "live_flow", traceId, traceId, Map.of("mode", "LIVE")); List> saved = new ArrayList<>(); try { try { - Map ecosystemSummary = ecosystem.summary(); + Map ecosystemSummary = ecosystem.refresh(); Map services = map(ecosystemSummary.get("services")); collectServiceSnapshots(saved, services); } catch (RuntimeException collectorError) { @@ -97,7 +124,7 @@ public Map refresh() { "FLOW_COLLECTOR_DEGRADED", "audit", traceId, "archiveos", "archiveos", "unavailable", "warning", "Live Flow collector degraded: " + collectorError.getClass().getSimpleName(), null, Map.of("riskLevel", "WARNING", "syntheticData", true)))); - audit.recordEvent("flow_collector_degraded", "live_flow", traceId, traceId, + if (auditEnabled) audit.recordEvent("flow_collector_degraded", "live_flow", traceId, traceId, Map.of("error", collectorError.getClass().getSimpleName())); } collectApprovals(saved); @@ -105,11 +132,11 @@ public Map refresh() { Map result = new LinkedHashMap<>(summarySnapshot()); result.put("traceId", traceId); result.put("collected", saved.size()); - audit.recordEvent("live_flow_refresh_completed", "live_flow", traceId, traceId, + if (auditEnabled) audit.recordEvent("live_flow_refresh_completed", "live_flow", traceId, traceId, Map.of("collected", saved.size(), "status", result.get("active_flows"))); return result; } catch (RuntimeException error) { - audit.recordEvent("live_flow_refresh_failed", "live_flow", traceId, traceId, + if (auditEnabled) audit.recordEvent("live_flow_refresh_failed", "live_flow", traceId, traceId, Map.of("error", error.getClass().getSimpleName())); throw error; } @@ -174,6 +201,24 @@ private Map runtimeSummary(Map summary, List runtime) { + Object values = runtime.get("services"); + if (!(values instanceof List services)) return null; + long total = 0; + boolean available = false; + for (Object item : services) { + if (!(item instanceof Map state)) continue; + Object status = state.get("serviceStatus"); + if ("UNAVAILABLE".equalsIgnoreCase(String.valueOf(status)) || "DISABLED".equalsIgnoreCase(String.valueOf(status))) continue; + Object backlog = state.get("backlogCount"); + if (backlog == null) continue; + total += number(backlog); + available = true; + } + return available ? total : null; + } + private Map runtimeServiceState(String key, Map service, Instant latestNodeEventAt) { String serviceStatus = string(service.get("status"), "UNKNOWN"); Map summary = map(service.get("summary")); @@ -309,7 +354,7 @@ private void collectHealthyService(List> saved, String key, long total = number(orders.get("total")); if (total > 0) { saved.add(repository.upsert(event("market-orders-" + total, "market-orders", "archive-market", "market", - "MARKET_ORDERS_OBSERVED", "order", "market-orders", "market", "logistics", "created", "info", + "MARKET_ORDERS_OBSERVED", "order", "market-orders", "market", "nexus", "created", "info", "Market orders observed: " + total, bucket(summary.get("totalRevenue")), Map.of("orderId", "market-orders", "orderCount", total, "riskLevel", string(summary.get("bankruptcyRisk"), "UNKNOWN"), "syntheticData", true)))); } @@ -331,7 +376,7 @@ private void collectHealthyService(List> saved, String key, long pending = number(summary.get("pending")); if (pending > 0) { saved.add(repository.upsert(event("nexus-outbox-pending-" + pending, "nexus-outbox", "archive-nexus", "nexus", - "NEXUS_OUTBOX_PENDING", "factory", "nexus-outbox", "nexus", "ledger", "waiting", "info", + "NEXUS_OUTBOX_PENDING", "factory", "nexus-outbox", "nexus", "logistics", "waiting", "info", "Nexus outbox pending: " + pending, null, Map.of("factoryId", "nexus-factory", "syntheticData", true)))); } } diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsService.java b/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsService.java index 8e38b8d..d71d36f 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsService.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsService.java @@ -26,6 +26,8 @@ public class ManagedSystemsService { private String ledgerCallbackToken; @Value("${archiveos.ledger.enabled:false}") private boolean ledgerEnabled; + @Value("${archiveos.experimental.deepstake.enabled:false}") + private boolean deepStakeEnabled; public ManagedSystemsService(ManagedSystemsRepository repository, ExternalApprovalRepository approvals) { this.repository = repository; @@ -62,7 +64,10 @@ public Map overview() { public List> systems() { List> tasks = repository.pmTasks(); Map queue = repository.queueSummary(); - return List.of(archiveOsSystem(queue), archiveMarketSystem(), archiveNexusSystem(tasks), archiveLogiticsSystem(), atlasSystem(), archiveLedgerSystem(), deepStakePlaceholder()); + List> systems = new ArrayList<>(List.of( + archiveOsSystem(queue), archiveMarketSystem(), archiveNexusSystem(tasks), archiveLogiticsSystem(), archiveLedgerSystem())); + if (deepStakeEnabled) systems.add(deepStakePlaceholder()); + return systems; } public Map system(String systemId) { @@ -92,8 +97,7 @@ public List> systemWorkLogs(String systemId) { public List> pmInbox() { List> items = new ArrayList<>(); items.addAll(nexusApprovalItems()); - addIfNotNull(items, atlasStatusItem()); - addIfNotNull(items, atlasRecoveryItem()); + // Atlas is an external integration. Its health must not alter Archive core inbox/KPI state. addIfNotNull(items, dailyReportItem()); items.addAll(ledgerApprovalItems()); items.addAll(ledgerCallbackFailedItems()); diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/security/SecurityConfiguration.java b/archiveos-ai/src/main/java/com/archiveos/ai/security/SecurityConfiguration.java index dde1c04..d3ea634 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/security/SecurityConfiguration.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/security/SecurityConfiguration.java @@ -30,6 +30,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, SessionAuthentication .requestMatchers(HttpMethod.POST, "/api/auth/logout").authenticated() .requestMatchers(HttpMethod.GET, "/api/security/**", "/api/audit/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/mcp/**", "/api/runtime/timeline/**").hasAnyRole("OPERATOR", "PM", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/live-flow/refresh", "/api/live-flow/events/ingest", "/api/ecosystem/balance/simulate").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/api/rag/ask", "/api/ecosystem/demo/dry-run", "/api/game/settlement-agency/simulate", "/api/integrations/market/events/review").permitAll() .requestMatchers(HttpMethod.POST, "/api/tasks/*/decision", "/api/tasks/*/retry", "/api/rpa/tasks/*/decision") .hasAnyRole("PM", "ADMIN") diff --git a/archiveos-ai/src/main/resources/application.yml b/archiveos-ai/src/main/resources/application.yml index a3bc984..e910020 100644 --- a/archiveos-ai/src/main/resources/application.yml +++ b/archiveos-ai/src/main/resources/application.yml @@ -61,6 +61,29 @@ archiveos: enabled: ${ARCHIVEOS_SCHEDULER_ENABLED:false} nightly-cron: ${ARCHIVEOS_NIGHTLY_CRON:0 50 23 * * *} daily-cron: ${ARCHIVEOS_DAILY_CRON:0 0 9 * * *} + experimental: + deepstake: + enabled: ${ARCHIVEOS_EXPERIMENTAL_DEEPSTAKE_ENABLED:false} + ecosystem: + balance: + market: + min-margin: ${ARCHIVEOS_BALANCE_MARKET_MIN_MARGIN:8} + max-margin: ${ARCHIVEOS_BALANCE_MARKET_MAX_MARGIN:18} + nexus: + min-margin: ${ARCHIVEOS_BALANCE_NEXUS_MIN_MARGIN:5} + max-margin: ${ARCHIVEOS_BALANCE_NEXUS_MAX_MARGIN:12} + logistics: + min-margin: ${ARCHIVEOS_BALANCE_LOGISTICS_MIN_MARGIN:3} + max-margin: ${ARCHIVEOS_BALANCE_LOGISTICS_MAX_MARGIN:10} + ledger: + min-margin: ${ARCHIVEOS_BALANCE_LEDGER_MIN_MARGIN:4} + max-margin: ${ARCHIVEOS_BALANCE_LEDGER_MAX_MARGIN:12} + archiveos: + min-margin: ${ARCHIVEOS_BALANCE_ARCHIVEOS_MIN_MARGIN:0} + max-margin: ${ARCHIVEOS_BALANCE_ARCHIVEOS_MAX_MARGIN:8} + backlog-warning: ${ARCHIVEOS_BALANCE_BACKLOG_WARNING:20} + capacity-warning-percent: ${ARCHIVEOS_BALANCE_CAPACITY_WARNING_PERCENT:90} + profit-concentration-percent: ${ARCHIVEOS_BALANCE_PROFIT_CONCENTRATION_PERCENT:55} security: admin-password: ${ARCHIVEOS_ADMIN_PASSWORD:} integration-token: ${ARCHIVEOS_INTEGRATION_TOKEN:} @@ -70,6 +93,8 @@ archiveos: secure-cookie: ${ARCHIVEOS_SECURE_COOKIE:false} archive: + live-flow: + collector-interval-ms: ${ARCHIVE_LIVE_FLOW_COLLECTOR_INTERVAL_MS:1000} ecosystem: enabled: ${ARCHIVE_ECOSYSTEM_ENABLED:true} refresh-timeout-ms: ${ARCHIVE_ECOSYSTEM_REFRESH_TIMEOUT_MS:3000} @@ -80,6 +105,7 @@ archive: base-url: ${ARCHIVE_ECOSYSTEM_SERVICES_NEXUS_BASE_URL:http://localhost:8080} health-path: /actuator/health summary-path: /api/outbox/summary + operations-summary-path: /api/operations/summary workforce-summary-path: /api/workforce/summary productivity-summary-path: /api/productivity/summary capacity-summary-path: /api/capacity/summary diff --git a/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemBalanceServiceTest.java b/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemBalanceServiceTest.java new file mode 100644 index 0000000..8d7ce12 --- /dev/null +++ b/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemBalanceServiceTest.java @@ -0,0 +1,37 @@ +package com.archiveos.ai.ecosystem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class EcosystemBalanceServiceTest { + @Test void appliesConfiguredMarginsAndPreservesMissingMetricsAsNoData() { + EcosystemService ecosystem = Mockito.mock(EcosystemService.class); + EcosystemBalanceProperties properties = new EcosystemBalanceProperties(); + properties.setProfitConcentrationPercent(95); + when(ecosystem.summary()).thenReturn(Map.of("services", Map.of( + "market", service("Archive-Market", Map.of("totalRevenue", 100, "totalCost", 70, "profit", 30)), + "nexus", service("Archive-Nexus", Map.of()), + "logitics", service("Archive-Logistics", Map.of("totalRevenue", 100, "totalCost", 92, "profit", 8)), + "ledger", service("Archive-Ledger", Map.of("totalRevenue", 100, "totalCost", 95, "profit", 5))))); + + Map summary = new EcosystemBalanceService(ecosystem, properties).summary(); + @SuppressWarnings("unchecked") var rows = (java.util.List>) summary.get("services"); + Map market = rows.stream().filter(row -> "archive-market".equals(row.get("serviceId"))).findFirst().orElseThrow(); + Map nexus = rows.stream().filter(row -> "archive-nexus".equals(row.get("serviceId"))).findFirst().orElseThrow(); + + assertThat(market).containsEntry("targetMinMargin", java.math.BigDecimal.valueOf(8)) + .containsEntry("targetMaxMargin", java.math.BigDecimal.valueOf(18)) + .containsEntry("operatingMargin", java.math.BigDecimal.valueOf(30).setScale(2)) + .containsEntry("balance", "CONCENTRATED"); + assertThat(nexus).containsEntry("balance", "NO_DATA").containsEntry("revenue", null).containsEntry("operatingMargin", null); + assertThat(summary).containsEntry("balanceStatus", "PARTIAL_DATA"); + } + + private Map service(String name, Map summary) { + return Map.of("name", name, "status", "HEALTHY", "summary", summary); + } +} diff --git a/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemServiceTest.java b/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemServiceTest.java index 4292739..19128e5 100644 --- a/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemServiceTest.java +++ b/archiveos-ai/src/test/java/com/archiveos/ai/ecosystem/EcosystemServiceTest.java @@ -31,7 +31,7 @@ class EcosystemServiceTest { IntegrationResult unavailable = new IntegrationResult(EcosystemServiceStatus.UNAVAILABLE, null, Map.of(), "Connection refused", 1); when(market.health()).thenReturn(unavailable); when(market.operationsSummary()).thenReturn(unavailable); when(market.marketEconomySummary()).thenReturn(unavailable); when(market.outboxSummary()).thenReturn(unavailable); - when(nexus.health()).thenReturn(unavailable); when(nexus.outboxSummary()).thenReturn(unavailable); + when(nexus.health()).thenReturn(unavailable); when(nexus.outboxSummary()).thenReturn(unavailable); when(nexus.operationsSummary()).thenReturn(unavailable); when(logitics.health()).thenReturn(unavailable); when(logitics.operationsSummary()).thenReturn(unavailable); when(ledger.health()).thenReturn(unavailable); when(ledger.operationsSummary()).thenReturn(unavailable); when(repository.recordHealth(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) @@ -73,6 +73,7 @@ private EcosystemProperties properties() { Map services = new LinkedHashMap<>(); services.put("market", marketConfig()); services.put("nexus", config("Archive-Nexus", "http://localhost:8080", "/api/outbox/summary")); + services.get("nexus").setOperationsSummaryPath("/api/operations/summary"); services.put("logitics", config("Archive-Logistics", "http://localhost:8092", "/api/operations/summary")); services.put("ledger", config("Archive-Ledger", "http://localhost:18080", "/api/operations/summary")); services.get("ledger").setApprovalCallbackPath("/api/approvals/callback"); diff --git a/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcasterTest.java b/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcasterTest.java new file mode 100644 index 0000000..560853b --- /dev/null +++ b/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowEventBroadcasterTest.java @@ -0,0 +1,52 @@ +package com.archiveos.ai.liveflow; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class LiveFlowEventBroadcasterTest { + @Test void replaysInMemoryHistoryAfterLastEventIdWithoutDuplicates() { + LiveFlowRepository repository = Mockito.mock(LiveFlowRepository.class); + LiveFlowEventBroadcaster broadcaster = new LiveFlowEventBroadcaster(repository); + broadcaster.publish(event("one")); + broadcaster.publish(event("two")); + broadcaster.publish(event("two")); + broadcaster.publish(event("three")); + + assertThat(broadcaster.replayCandidates("one")).extracting(value -> value.get("event_id")).containsExactly("two", "three"); + verify(repository, never()).findAfterEventId(Mockito.anyString(), Mockito.anyInt()); + } + + @Test void restoresReplayFromDatabaseWhenHistoryIsEmpty() { + LiveFlowRepository repository = Mockito.mock(LiveFlowRepository.class); + when(repository.existsEventId("checkpoint")).thenReturn(true); + when(repository.findAfterEventId("checkpoint", 250)).thenReturn(List.of(event("two"), event("three"))); + + LiveFlowEventBroadcaster broadcaster = new LiveFlowEventBroadcaster(repository); + + assertThat(broadcaster.replayCandidates("checkpoint")).extracting(value -> value.get("event_id")).containsExactly("two", "three"); + verify(repository).findAfterEventId("checkpoint", 250); + } + + @Test void ignoresMissingOrInvalidLastEventId() { + LiveFlowRepository repository = Mockito.mock(LiveFlowRepository.class); + when(repository.existsEventId("missing")).thenReturn(false); + when(repository.findAfterEventId("missing", 250)).thenReturn(List.of()); + LiveFlowEventBroadcaster broadcaster = new LiveFlowEventBroadcaster(repository); + + assertThat(broadcaster.replayCandidates(null)).isEmpty(); + assertThat(broadcaster.replayCandidates("missing")).isEmpty(); + verify(repository).existsEventId("missing"); + } + + private Map event(String id) { + return Map.of("event_id", id, "received_at", Instant.now().toString()); + } +} diff --git a/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowServiceTest.java b/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowServiceTest.java index 7507392..58c2195 100644 --- a/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowServiceTest.java +++ b/archiveos-ai/src/test/java/com/archiveos/ai/liveflow/LiveFlowServiceTest.java @@ -33,12 +33,14 @@ class LiveFlowServiceTest { "failed_callbacks", 1, "degraded_systems", 1)); when(repository.recent(12)).thenReturn(List.of()); - when(ecosystem.summary()).thenReturn(Map.of("services", Map.of( + Map ecosystemSnapshot = Map.of("services", Map.of( "market", Map.of("status", "HEALTHY", "name", "Archive-Market", "summary", Map.of( "orders", Map.of("total", 3), "totalRevenue", "1000000", "bankruptcyRisk", "LOW")), "logitics", Map.of("status", "UNAVAILABLE", "name", "Archive-Logistics", "summary", Map.of()), "nexus", Map.of("status", "HEALTHY", "name", "Archive-Nexus", "summary", Map.of("pending", 2)), - "ledger", Map.of("status", "HEALTHY", "name", "Archive-Ledger", "summary", Map.of("approvalRequired", 1))))); + "ledger", Map.of("status", "HEALTHY", "name", "Archive-Ledger", "summary", Map.of("approvalRequired", 1)))); + when(ecosystem.summary()).thenReturn(ecosystemSnapshot); + when(ecosystem.refresh()).thenReturn(ecosystemSnapshot); when(approvals.pending(50)).thenReturn(List.of(Map.of( "approval_request_id", "APR-1", "correlation_id", "corr-1", @@ -73,7 +75,9 @@ class LiveFlowServiceTest { @SuppressWarnings("unchecked") List> edges = (List>) topology.get("edges"); assertThat(nodes).extracting(node -> node.get("id")).contains("market", "logistics", "nexus", "ledger", "archiveos", "settlement"); - assertThat(edges).anySatisfy(edge -> assertThat(edge).containsEntry("from", "market").containsEntry("to", "logistics")); + assertThat(edges).anySatisfy(edge -> assertThat(edge).containsEntry("from", "market").containsEntry("to", "nexus")); + assertThat(edges).anySatisfy(edge -> assertThat(edge).containsEntry("from", "nexus").containsEntry("to", "logistics")); + assertThat(edges).anySatisfy(edge -> assertThat(edge).containsEntry("from", "archiveos").containsEntry("to", "settlement")); } private AuditLogService audit() { diff --git a/archiveos-ai/src/test/java/com/archiveos/ai/managed/ManagedSystemsServiceTest.java b/archiveos-ai/src/test/java/com/archiveos/ai/managed/ManagedSystemsServiceTest.java index 22bd868..69f29c1 100644 --- a/archiveos-ai/src/test/java/com/archiveos/ai/managed/ManagedSystemsServiceTest.java +++ b/archiveos-ai/src/test/java/com/archiveos/ai/managed/ManagedSystemsServiceTest.java @@ -13,7 +13,7 @@ import org.mockito.Mockito; class ManagedSystemsServiceTest { - @Test void overviewAggregatesArchiveOsNexusAtlasAndDeepStake() { + @Test void overviewAggregatesOnlyArchiveCoreSystemsByDefault() { ManagedSystemsRepository repository = Mockito.mock(ManagedSystemsRepository.class); ExternalApprovalRepository approvals = baseApprovalRepository(); when(repository.pmTasks()).thenReturn(List.of()); @@ -42,11 +42,9 @@ class ManagedSystemsServiceTest { @SuppressWarnings("unchecked") List> systems = (List>) overview.get("systems"); assertThat(systems).extracting(system -> system.get("systemId")) - .containsExactly("archiveos", "archive-market", "archive-nexus", "archive-logitics", "atlas-platform", "archive-ledger", "deepstake-placeholder"); - assertThat(systems).anySatisfy(system -> { - assertThat(system).containsEntry("systemId", "deepstake-placeholder"); - assertThat(system).containsEntry("status", "not_connected"); - }); + .containsExactly("archiveos", "archive-market", "archive-nexus", "archive-logitics", "archive-ledger"); + assertThat(systems).extracting(system -> system.get("systemId")) + .doesNotContain("atlas-platform", "deepstake-placeholder"); assertThat(systems).anySatisfy(system -> { assertThat(system).containsEntry("systemId", "archive-ledger"); assertThat(system).containsEntry("type", "FINANCIAL_OPERATIONS_BACKEND"); diff --git a/backend/src/server.ts b/backend/src/server.ts index e7ce278..4198463 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -131,6 +131,9 @@ const endpointRegistry: EndpointRegistration[] = [ { name: "Ecosystem Summary", method: "GET", path: "/api/ecosystem/summary", service: "runtime", description: "Market, Nexus, Logistics, Ledger integrated operations status." }, { name: "Ecosystem Topology", method: "GET", path: "/api/ecosystem/topology", service: "runtime", description: "Control Tower topology nodes and edges." }, { name: "Ecosystem Timeline", method: "GET", path: "/api/ecosystem/timeline", service: "runtime", description: "Cross-service timeline events." }, + { name: "Ecosystem Balance Summary", method: "GET", path: "/api/ecosystem/balance/summary", service: "runtime", description: "Read-only synthetic service balance analysis." }, + { name: "Ecosystem Balance Recommendations", method: "GET", path: "/api/ecosystem/balance/recommendations", service: "runtime", description: "Read-only synthetic balance recommendations." }, + { name: "Ecosystem Balance Simulation", method: "POST", path: "/api/ecosystem/balance/simulate", service: "runtime", description: "Admin safe dry-run balance simulation." }, { name: "Refresh Ecosystem", method: "POST", path: "/api/ecosystem/refresh", service: "runtime", description: "Read-only external service health refresh." }, { name: "Ecosystem Demo Dry-run", method: "POST", path: "/api/ecosystem/demo/dry-run", service: "runtime", description: "Safe dry-run ecosystem scenario." }, { name: "Ecosystem Demo Run", method: "POST", path: "/api/ecosystem/demo/run", service: "runtime", description: "Blocked unless external writes are explicitly enabled." }, @@ -155,6 +158,7 @@ const endpointRegistry: EndpointRegistration[] = [ { name: "Live Flow Summary", method: "GET", path: "/api/live-flow/summary", service: "runtime", description: "Operational Twin summary from runtime flow events." }, { name: "Live Flow Topology", method: "GET", path: "/api/live-flow/topology", service: "runtime", description: "Operational Twin node and lane topology." }, { name: "Live Flow Recent Events", method: "GET", path: "/api/live-flow/events/recent", service: "runtime", description: "Recent normalized runtime flow events." }, + { name: "Live Flow Stream", method: "GET", path: "/api/live-flow/stream", service: "runtime", description: "Unbuffered SSE stream of persisted normalized flow events." }, { name: "Live Flow Replay", method: "GET", path: "/api/live-flow/replay", service: "runtime", description: "Replay normalized runtime flow events by time window." }, { name: "Live Flow Correlation", method: "GET", path: "/api/live-flow/correlation/:id", service: "runtime", description: "Trace one runtime correlation chain." }, { name: "Live Flow Entity", method: "GET", path: "/api/live-flow/entity/:id", service: "runtime", description: "Trace one entity through the flow." }, @@ -771,6 +775,18 @@ app.get("/api/ecosystem/timeline", async (request, response) => { await relayArchiveOsAi(response, `/api/ecosystem/timeline?limit=${encodeURIComponent(String(limit))}`, undefined, undefined, request); }); +app.get("/api/ecosystem/balance/summary", async (request, response) => { + await relayArchiveOsAi(response, "/api/ecosystem/balance/summary", undefined, undefined, request); +}); + +app.get("/api/ecosystem/balance/recommendations", async (request, response) => { + await relayArchiveOsAi(response, "/api/ecosystem/balance/recommendations", undefined, undefined, request); +}); + +app.post("/api/ecosystem/balance/simulate", async (request, response) => { + await relayArchiveOsAi(response, "/api/ecosystem/balance/simulate", jsonProxyRequest("POST", request.body), undefined, request); +}); + app.post("/api/ecosystem/refresh", async (request, response) => { await relayArchiveOsAi(response, "/api/ecosystem/refresh", { method: "POST" }, undefined, request); }); @@ -883,6 +899,10 @@ app.get("/api/live-flow/events/recent", async (request, response) => { await relayArchiveOsAi(response, `/api/live-flow/events/recent${limit}`, undefined, undefined, request); }); +app.get("/api/live-flow/stream", async (request, response) => { + await relayArchiveOsAiSse(request, response, "/api/live-flow/stream"); +}); + app.get("/api/live-flow/replay", async (request, response) => { const params = new URLSearchParams(); for (const key of ["from", "to", "limit"]) { @@ -2043,6 +2063,39 @@ async function relayArchiveOsAi( } } +async function relayArchiveOsAiSse(request: any, response: any, path: string) { + const baseUrl = process.env.ARCHIVEOS_AI_BASE_URL?.trim() || "http://localhost:4100"; + const headers = new Headers({ accept: "text/event-stream" }); + const cookie = request?.header?.("cookie"); + const lastEventId = request?.header?.("last-event-id"); + if (cookie) headers.set("cookie", cookie); + if (lastEventId) headers.set("Last-Event-ID", lastEventId); + try { + const upstream = await fetch(`${baseUrl}${path}`, { headers }); + if (!upstream.ok || !upstream.body) { + response.status(upstream.status || 503).json({ error: "Live Flow stream is unavailable." }); + return; + } + response.status(200); + response.setHeader("content-type", "text/event-stream; charset=utf-8"); + response.setHeader("cache-control", "no-cache, no-transform"); + response.setHeader("connection", "keep-alive"); + response.setHeader("x-accel-buffering", "no"); + response.flushHeaders?.(); + const reader = upstream.body.getReader(); + request.on("close", () => reader.cancel().catch(() => undefined)); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + response.write(Buffer.from(value)); + } + response.end(); + } catch { + if (!response.headersSent) response.status(503).json({ error: "Live Flow stream is unavailable." }); + else response.end(); + } +} + function sendProxyError(response: any, error: unknown, fallback: string) { if (error instanceof ArchiveOsAiProxyError) { response.status(error.statusCode).json({ error: error.message, details: error.payload }); diff --git a/docs/console-v3-audit.md b/docs/console-v3-audit.md new file mode 100644 index 0000000..6e11f24 --- /dev/null +++ b/docs/console-v3-audit.md @@ -0,0 +1,23 @@ +# ArchiveOS Console V3 진단 + +## 작업 시작 기준 + +- 기준 HEAD: `f2ed9d9355e1d5f74e91b33af1746abb68016bca` +- 기준 화면: `OS.zip`에 포함된 기존 운영 개요, 실시간 관제, 재무, 작업 역량, 시스템 관리 화면 +- 대상: ArchiveOS 콘솔만 수정하며 Market, Nexus, Logistics, Ledger는 읽기 전용 계약으로 확인한다. + +## 발견 사항 + +| 항목 | 기존 상태 | V3 처리 | +| --- | --- | --- | +| 탐색 | 16개 상위 메뉴 | 대시보드·서비스·운영·재무·기록·설정 6개로 통합 | +| 초기 조회 | AppShell에서 약 30개 API를 45초마다 전체 조회 | 현재 화면에 필요한 API만 조회 | +| 실시간 관제 | 15초 수집 기반 화면 갱신 | SSE 우선, 연결 실패 때만 최근 이벤트 폴백 | +| DeepStake | placeholder가 핵심 관리 시스템 집계에 포함 | 핵심 화면·집계에서 분리, 향후 Labs 전용 | +| Atlas | 핵심 대시보드 상태와 섞임 | 서비스 > 외부 연동으로 분리 | +| 무데이터 | 0과 미수집 상태가 혼재 | 없음·연동 안 됨·데이터 없음으로 구분 | +| 언어 | native select + DOM MutationObserver 교체 | React I18nProvider + popover 선택기 | + +## 반응형 확인 대상 + +390px, 430px, 768px, 1440px, 1920px에서 6개 핵심 화면과 언어 선택 메뉴를 확인한다. diff --git a/docs/console-v3-information-architecture.md b/docs/console-v3-information-architecture.md new file mode 100644 index 0000000..45f6a20 --- /dev/null +++ b/docs/console-v3-information-architecture.md @@ -0,0 +1,14 @@ +# Console V3 정보 구조 + +| 기존 경로 | 새 핵심 화면 | 비고 | +| --- | --- | --- | +| overview, liveflow | dashboard | 운영 개요와 라이브 메쉬 통합 | +| ecosystem, managed, atlas | services | Atlas는 외부 연동 탭 | +| agents, workforce, workflows, batch, rpa | operations | 자동화는 하위 탭 | +| finance, approvals | finance | 승인·정산·대사 하위 탭 | +| knowledge, history | records | 이벤트·감사·지식 하위 탭 | +| mcp, settings | settings | MCP는 고급 도구 탭 | + +기존 URL/해시의 식별자는 제거하지 않는다. 클라이언트는 기존 식별자를 적절한 핵심 화면으로 정규화한다. + +PUBLIC은 6개 핵심 메뉴만 보며, 쓰기·고급 도구는 권한이 있을 때만 API 수준에서 허용한다. diff --git a/docs/console-v3-performance.md b/docs/console-v3-performance.md new file mode 100644 index 0000000..ef89c2f --- /dev/null +++ b/docs/console-v3-performance.md @@ -0,0 +1,12 @@ +# Console V3 성능 기준 + +| 기준 | 구현 방식 | +| --- | --- | +| 초기 대시보드 요청 | auth, ecosystem, live summary, topology, recent events, balance summary의 6개 | +| 전역 새로고침 | 제거. 화면 전환 시 해당 화면 데이터만 요청 | +| 실시간 이벤트 | SSE 연결 중 폴링 0회 | +| SSE 실패 | 최근 이벤트만 1초 폴백, 연결 재개 시 중단 | +| 토큰 수 | 최근 30개, 나머지는 `+N` 클러스터 | +| 레이아웃 | 고정 최소 폭 캔버스 + 모바일 가로 스크롤, uncontrolled horizontal scroll 없음 | + +로컬 측정은 실행 환경과 서비스 기동 상태에 따라 달라진다. 최종 측정값은 A-Z smoke와 브라우저 네트워크 패널에서 기록한다. diff --git a/docs/console-v3-realtime-sse.md b/docs/console-v3-realtime-sse.md new file mode 100644 index 0000000..c070163 --- /dev/null +++ b/docs/console-v3-realtime-sse.md @@ -0,0 +1,16 @@ +# 실시간 SSE 메쉬 + +`GET /api/live-flow/stream`은 저장된 `ecosystem_flow_event`를 SSE로 전달한다. + +- `snapshot`: 연결 시 현재 요약 +- `runtime-event`: 저장된 실제 런타임 이벤트 +- `service-status`: 상태 변경용 이벤트 +- `heartbeat`: 유휴 연결 유지 + +SSE 이벤트는 `event_id`로 중복 제거한다. Last-Event-ID가 있으면 서버의 짧은 전송 이력 범위에서 이후 이벤트를 재전송한다. Node 호환 계층은 `text/event-stream`, keep-alive, `X-Accel-Buffering: no`를 유지하며 JSON 버퍼링을 하지 않는다. + +메모리 전송 이력에 Last-Event-ID가 없으면 `ecosystem_flow_event`의 `received_at`, `id` 순서를 기준으로 최대 250건을 다시 조회한다. 따라서 ArchiveOS 재기동 뒤에도 저장된 이벤트를 복구할 수 있다. 유효하지 않은 Last-Event-ID는 snapshot만 전달하고 서버 경고 로그로 남긴다. + +브라우저는 SSE 연결 중 전체 AppData 폴링을 하지 않는다. 연결이 실패한 경우에만 최근 이벤트 조회를 폴백으로 사용하며, 이것은 상태를 `연결 재시도 중`으로 명확히 표시한다. 수집된 이벤트가 없으면 토큰을 만들지 않는다. + +재접속 간격은 1초에서 시작해 2초, 4초, 8초, 16초, 최대 30초까지 증가하며, 성공 시 초기화한다. offline 상태에서는 대기하고 online 이벤트가 발생하면 즉시 재접속을 시도한다. 클라이언트는 최근 750개 eventId만 유지해 snapshot과 재전송 이벤트의 중복을 막는다. diff --git a/docs/cross-service-balance-actions.md b/docs/cross-service-balance-actions.md new file mode 100644 index 0000000..27f72bf --- /dev/null +++ b/docs/cross-service-balance-actions.md @@ -0,0 +1,12 @@ +# 교차 서비스 균형 조정 후속 작업 + +ArchiveOS는 분석과 권고만 담당한다. 다음 변경은 각 소유 저장소에서 별도 승인 후 수행해야 한다. + +| 서비스 | 필요한 후속 작업 | 예시 명령 목적 | +| --- | --- | --- | +| Archive-Market | GMV·인식 매출·반품 준비금 계약 명확화 | Market economy summary 확인 | +| Archive-Nexus | 생산비·품질비·출하 비용 이벤트의 비용 귀속 확인 | outbox 및 cost event 검증 | +| Archive-Logistics | 배송 수수료·지연 비용·Ledger 비용 이벤트 계약 확인 | operations/outbox summary 검증 | +| Archive-Ledger | 수수료·정산·대사 지표와 처리량의 대응 확인 | settlement/reconciliation summary 검증 | + +각 변경은 idempotencyKey, correlationId, causationId, hopCount/maxHop 보존과 safe-mode 검증을 전제로 한다. diff --git a/docs/ecosystem-balance-policy.md b/docs/ecosystem-balance-policy.md new file mode 100644 index 0000000..975cb2e --- /dev/null +++ b/docs/ecosystem-balance-policy.md @@ -0,0 +1,15 @@ +# 생태계 균형 분석 정책 + +모든 금액은 Synthetic Runtime Data다. ArchiveOS는 수수료·자금을 자동으로 이동하거나 외부 서비스 설정을 변경하지 않는다. + +초기 권장 영업이익률 범위는 설정 가능한 정책값으로 관리한다. + +- Market: 8~18% +- Nexus: 5~12% +- Logistics: 3~10% +- Ledger: 4~12% +- ArchiveOS: 0~8% 비용 회수 기준 + +읽기 전용 분석은 매출·비용·이익·현금·적체·이익 집중도를 비교한다. 40% 초과 이익률은 집중 검토, -10% 미만은 손익 압박으로 표시한다. GMV와 인식 매출은 같은 값으로 취급하지 않는다. + +`POST /api/ecosystem/balance/simulate`은 ADMIN 전용 DRY_RUN이며 외부 자금·수수료를 변경하지 않는다. diff --git a/docs/i18n-audit.md b/docs/i18n-audit.md index 2514ecc..85a9828 100644 --- a/docs/i18n-audit.md +++ b/docs/i18n-audit.md @@ -79,3 +79,14 @@ The following are not translated because they are operating contracts rather tha ## Future improvement The current pass is deliberately low-risk. A later cleanup can replace DOM application with direct `t("key")` calls inside every component once the UI stabilizes further. That would improve static analysis coverage and allow stricter missing-key tests. +# Console V3 i18n 감사 + +- 기본 언어: 한국어(`ko`), localStorage 키: `archive.locale` +- 지원 언어: `ko`, `en`, `ja`, `zh-CN` +- 변경: native select 및 MutationObserver 기반 DOM 재작성 제거 +- 구조: `I18nProvider`와 `useI18n`을 사용해 React 렌더 단계에서 선택 언어를 유지 +- 언어 선택: 우측 상단 지구본 popover, ESC 및 ARIA 상태 지원 + +번역하지 않는 값: API path, eventType, enum, correlationId, service ID, repository 이름, 내부 `logitics` 호환 키. + +기존 상세 하위 화면에는 한국어 중심 운영 문구가 남아 있다. V3의 6개 핵심 메뉴·헤더·상태 선택기는 key 기반으로 우선 전환했으며, 이후 상세 화면별 문구 키 보강은 API/도메인 식별자에 영향을 주지 않는 범위에서 진행한다. diff --git a/docs/screenshots/console-v3-dashboard-desktop.png b/docs/screenshots/console-v3-dashboard-desktop.png new file mode 100644 index 0000000..8c07352 Binary files /dev/null and b/docs/screenshots/console-v3-dashboard-desktop.png differ diff --git a/docs/screenshots/console-v3-dashboard-mobile.png b/docs/screenshots/console-v3-dashboard-mobile.png new file mode 100644 index 0000000..fc3033f Binary files /dev/null and b/docs/screenshots/console-v3-dashboard-mobile.png differ diff --git a/docs/screenshots/console-v3-dashboard-tablet.png b/docs/screenshots/console-v3-dashboard-tablet.png new file mode 100644 index 0000000..99804c3 Binary files /dev/null and b/docs/screenshots/console-v3-dashboard-tablet.png differ diff --git a/docs/screenshots/console-v3-dashboard-wide.png b/docs/screenshots/console-v3-dashboard-wide.png new file mode 100644 index 0000000..72489ad Binary files /dev/null and b/docs/screenshots/console-v3-dashboard-wide.png differ diff --git a/docs/screenshots/console-v3-finance.png b/docs/screenshots/console-v3-finance.png new file mode 100644 index 0000000..9cbd5a4 Binary files /dev/null and b/docs/screenshots/console-v3-finance.png differ diff --git a/docs/screenshots/console-v3-language-menu.png b/docs/screenshots/console-v3-language-menu.png new file mode 100644 index 0000000..be65b61 Binary files /dev/null and b/docs/screenshots/console-v3-language-menu.png differ diff --git a/docs/screenshots/console-v3-operations.png b/docs/screenshots/console-v3-operations.png new file mode 100644 index 0000000..a22459f Binary files /dev/null and b/docs/screenshots/console-v3-operations.png differ diff --git a/docs/screenshots/console-v3-records.png b/docs/screenshots/console-v3-records.png new file mode 100644 index 0000000..9e3c2d6 Binary files /dev/null and b/docs/screenshots/console-v3-records.png differ diff --git a/docs/screenshots/console-v3-services.png b/docs/screenshots/console-v3-services.png new file mode 100644 index 0000000..b91cebe Binary files /dev/null and b/docs/screenshots/console-v3-services.png differ diff --git a/docs/screenshots/console-v3-settings.png b/docs/screenshots/console-v3-settings.png new file mode 100644 index 0000000..c8b50cc Binary files /dev/null and b/docs/screenshots/console-v3-settings.png differ diff --git a/scripts/ui-ia-smoke-test.mjs b/scripts/ui-ia-smoke-test.mjs index 0e5d92b..ae56766 100644 --- a/scripts/ui-ia-smoke-test.mjs +++ b/scripts/ui-ia-smoke-test.mjs @@ -1,109 +1,37 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; -const appShell = readFileSync("src/app/AppShell.tsx", "utf-8"); -const navigation = readFileSync("src/app/navigation.ts", "utf-8"); -const styles = readFileSync("src/styles.css", "utf-8"); -const overview = readFileSync("src/pages/OverviewPage.tsx", "utf-8"); -const knowledge = readFileSync("src/pages/KnowledgePage.tsx", "utf-8"); -const backendApi = readFileSync("src/lib/backendApi.ts", "utf-8"); -const sidebar = readFileSync("src/components/shared/Sidebar.tsx", "utf-8"); -const overviewViewModel = readFileSync("src/lib/viewModels/overview.ts", "utf-8"); -const ledgerApprovals = readFileSync("src/pages/LedgerApprovalsPage.tsx", "utf-8"); -const ecosystemPage = readFileSync("src/pages/EcosystemPage.tsx", "utf-8"); -const liveFlowPage = readFileSync("src/pages/LiveFlowPage.tsx", "utf-8"); +const read = (file) => readFileSync(file, "utf-8"); +const appShell = read("src/app/AppShell.tsx"); +const navigation = read("src/app/navigation.ts"); +const styles = read("src/styles.css"); +const api = read("src/lib/backendApi.ts"); +const liveMesh = read("src/components/console/LiveMeshTopology.tsx"); +const i18n = read("src/i18n/I18nProvider.tsx"); -for (const label of ["운영 개요", "실시간 관제", "에이전트", "에코시스템", "작업 역량", "재무 흐름", "시스템 관리", "작업 흐름", "Ledger 승인", "운영 지식", "이력", "배치", "RPA", "설정"]) { - if (!navigation.includes(label)) { - throw new Error(`Missing final navigation label: ${label}`); - } +for (const label of ["대시보드", "서비스", "운영", "재무", "기록", "설정"]) { + if (!navigation.includes(`label: "${label}"`)) throw new Error(`Missing Console V3 navigation label: ${label}`); } - -for (const removed of ["Dashboard", "Decisions", "Operators", "Timeline", "Mesh", "KPI"]) { - if (navigation.includes(`label: "${removed}"`)) { - throw new Error(`Legacy top-level tab still present: ${removed}`); - } -} - -for (const token of [ - "--color-bg", - "--color-surface", - "--color-surface-elevated", - "--color-surface-muted", - "--color-border", - "--color-border-strong", - "--color-text", - "--color-text-muted", - "--color-text-subtle", - "--color-primary", - "--color-primary-contrast", - "--color-success", - "--color-warning", - "--color-danger", - "--color-info", - "--color-overlay", - "--color-focus-ring", -]) { - if (!styles.includes(token)) { - throw new Error(`Missing semantic theme token: ${token}`); - } -} - -if (!appShell.includes(" 750", "window.addEventListener(\"online\""]) { + if (!appShell.includes(contract)) throw new Error(`Live Flow SSE contract missing: ${contract}`); } - -for (const liveFlowContract of ["getLiveFlowSummary", "refreshLiveFlow", "실시간 관제", "합성 런타임 이벤트", "실제 고객, 결제, 계좌, 금융 데이터는 사용하지 않습니다"]) { - if (!backendApi.includes(liveFlowContract) && !appShell.includes(liveFlowContract) && !liveFlowPage.includes(liveFlowContract)) { - throw new Error(`Live Flow contract missing: ${liveFlowContract}`); - } +for (const contract of ["Archive-Market", "Archive-Nexus", "Archive-Logistics", "Archive-Ledger", "ArchiveOS", "Settlement", "events.slice(0, 30)"]) { + if (!liveMesh.includes(contract)) throw new Error(`Mesh topology contract missing: ${contract}`); } - -for (const workforceContract of ["getWorkforceOverview", "작업 역량 현황", "에이전트 제안", "Synthetic workforce"]) { - if (!backendApi.includes(workforceContract) && !appShell.includes(workforceContract) && !readFileSync("src/pages/WorkforcePage.tsx", "utf-8").includes(workforceContract)) { - throw new Error(`Workforce contract missing: ${workforceContract}`); - } +if (appShell.includes("MutationObserver")) throw new Error("DOM MutationObserver translation must not remain in AppShell."); +for (const contract of ["I18nProvider", "archive.locale", "setLocale"]) { + if (!i18n.includes(contract)) throw new Error(`I18n provider contract missing: ${contract}`); } - -for (const forbidden of [ - "Embeddings\" value={data.knowledge?.totalNodes", - "Vector Index\" value={data.knowledge?.totalEdges", - "References\" value={data.knowledge?.totalEdges", - "Last RAG Check\" value={data.axReadiness?.generatedAt", - "pgvector\" value={overview.memorySummary.ragReady", -]) { - if (overview.includes(forbidden) || knowledge.includes(forbidden)) { - throw new Error(`Forbidden inferred Spring AI metric mapping found: ${forbidden}`); - } +for (const token of [".console-kpi-grid", ".live-mesh", ".mesh-canvas", ".language-popover", "@media (max-width:640px)"]) { + if (!styles.includes(token)) throw new Error(`Console V3 responsive style missing: ${token}`); } - -if (overviewViewModel.includes("summary.failed + endpointHealth.summary.missing + endpointHealth.summary.error")) { - throw new Error("Endpoint failures are double-counted in the Overview critical alert KPI."); -} - -if (!overviewViewModel.includes("affectedEndpointServices")) { - throw new Error("Overview must summarize endpoint failures by affected service."); +for (const doc of ["docs/console-v3-audit.md", "docs/console-v3-information-architecture.md", "docs/console-v3-realtime-sse.md", "docs/console-v3-performance.md", "docs/ecosystem-balance-policy.md", "docs/cross-service-balance-actions.md"]) { + if (!existsSync(doc)) throw new Error(`Console V3 documentation missing: ${doc}`); } - -console.log("archiveos ui information-architecture smoke-test passed"); +console.log("archiveos console-v3 information architecture smoke-test passed"); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index a854181..e8e4bce 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,334 +1,156 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { - configuredBackendUrl, - getAxReadiness, - getAuthSession, - getAtlasOverview, - getAiRuntime, - getDashboardData, - getEndpointHealth, - getExternalApprovals, - getEcosystemSummary, - getEcosystemTopology, - getEcosystemTimeline, - getHistorianStatus, - getKnowledgeOverview, - getLatestArchitectureReview, - getLatestBatchStatus, - getLatestDailyReport, - getLocalRuntimeStatus, - getManagedSystemsOverview, - getMeshOverview, - getPmTasks, - getPlatformReadiness, - getPublicAccessStatus, - getQueueSummary, - getRecentCommands, - getRecentRuntimeEvents, - getRuntimeVersion, - getSecurityStatus, - getSettlementAgencyGameSummary, - getGameFinanceSummary, - getLiveFlowSummary, - getLiveFlowTopology, - getLiveFlowRecentEvents, - getWorkforceOverview, - getKpiOverview, - getMcpRegistry, - getRuntimeTimeline, - type AuthSession, - type ArchitectureReview, - type AtlasOverview, - type AxReadiness, - type AiRuntime, - type DashboardData, - type EndpointHealth, - type ExternalApprovalRequest, - type EcosystemSummary, - type EcosystemTopology, - type EcosystemTimeline, - type GameFinanceSummary, - type LiveFlowSummary, - type LiveFlowTopology, - type LiveFlowEvent, - type WorkforceOverview, - type HistorianStatus, - type KnowledgeOverview, - type KpiOverview, - type LatestBatchStatus, - type LocalRuntimeStatus, - type ManagedSystemsOverview, - type MeshOverview, - type PlatformReadiness, - type PublicAccessStatus, - type QueueSummary, - type RuntimeEvent, - type RuntimeVersion, - type SecurityStatus, - type McpRegistryEntry, - type RuntimeTimelineEntry, - type SettlementAgencyGameSummary, + configuredBackendUrl, getAtlasOverview, getAuthSession, getEcosystemBalanceSummary, getEcosystemSummary, getEcosystemTopology, + getExternalApprovals, getGameFinanceSummary, getHistorianStatus, getKnowledgeOverview, getLiveFlowRecentEvents, getLiveFlowSummary, + getLiveFlowTopology, getMcpRegistry, getMeshOverview, getPmTasks, getQueueSummary, getRuntimeTimeline, getWorkforceOverview, liveFlowStreamUrl, + type AuthSession, type AtlasOverview, type EcosystemBalanceSummary, type EcosystemSummary, type EcosystemTopology, type ExternalApprovalRequest, + type GameFinanceSummary, type HistorianStatus, type KnowledgeOverview, type LiveFlowEvent, type LiveFlowSummary, type LiveFlowTopology, + type McpRegistryEntry, type MeshOverview, type QueueSummary, type RuntimeTimelineEntry, type WorkforceOverview, + type ArchitectureReview, type AxReadiness, type AiRuntime, type DashboardData, type EndpointHealth, type EcosystemTimeline, + type KpiOverview, type LatestBatchStatus, type LocalRuntimeStatus, type ManagedSystemsOverview, type PlatformReadiness, + type PublicAccessStatus, type RuntimeEvent, type RuntimeVersion, type SecurityStatus, type SettlementAgencyGameSummary, } from "../lib/backendApi"; import type { CommandRun, DailyReport, PmTask } from "../types/database"; -import { navigationItems, type AppRoute } from "./navigation"; -import { OverviewPage } from "../pages/OverviewPage"; -import { WorkflowsPage } from "../pages/WorkflowsPage"; -import { KnowledgePage } from "../pages/KnowledgePage"; -import { HistoryPage } from "../pages/HistoryPage"; -import { SettingsPage } from "../pages/SettingsPage"; -import { AgentsPage } from "../pages/AgentsPage"; -import { BatchPage } from "../pages/BatchPage"; -import { RpaPage } from "../pages/RpaPage"; -import { AtlasPage } from "../pages/AtlasPage"; -import { McpRegistryPage } from "../pages/McpRegistryPage"; -import { ManagedSystemsPage } from "../pages/ManagedSystemsPage"; -import { LedgerApprovalsPage } from "../pages/LedgerApprovalsPage"; -import { EcosystemPage } from "../pages/EcosystemPage"; -import { SettlementGamePage } from "../pages/SettlementGamePage"; -import { LiveFlowPage } from "../pages/LiveFlowPage"; -import { WorkforcePage } from "../pages/WorkforcePage"; -import { Icon } from "../components/shared/Icon"; +import { navigationItems, normalizeRoute, type CoreRoute } from "./navigation"; import { Sidebar } from "../components/shared/Sidebar"; +import { Icon } from "../components/shared/Icon"; import { ThemeProvider } from "../theme/ThemeProvider"; -import { applyLocale, languageOptions as i18nLanguageOptions, readStoredLocale, t, type Locale } from "../i18n"; +import { I18nProvider, useI18n } from "../i18n/I18nProvider"; +import { consoleText } from "../i18n/console"; +import { languageOptions, t, type Locale } from "../i18n"; +import { ConsoleDashboardPage } from "../pages/ConsoleDashboardPage"; +import { ConsoleServicesPage } from "../pages/ConsoleServicesPage"; +import { ConsoleOperationsPage } from "../pages/ConsoleOperationsPage"; +import { ConsoleFinancePage } from "../pages/ConsoleFinancePage"; +import { ConsoleRecordsPage } from "../pages/ConsoleRecordsPage"; +import { ConsoleSettingsPage } from "../pages/ConsoleSettingsPage"; export type AppData = { - loading: boolean; - refreshedAt: string | null; - errors: Record; - dashboard: DashboardData | null; - runtime: LocalRuntimeStatus | null; - queue: QueueSummary | null; - tasks: PmTask[]; - events: RuntimeEvent[]; - commands: CommandRun[]; - knowledge: KnowledgeOverview | null; - historian: HistorianStatus | null; - mesh: MeshOverview | null; - kpi: KpiOverview | null; - endpointHealth: EndpointHealth | null; - platformReadiness: PlatformReadiness | null; - publicAccess: PublicAccessStatus | null; - runtimeVersion: RuntimeVersion | null; - security: SecurityStatus | null; - architect: ArchitectureReview | null; - axReadiness: AxReadiness | null; - aiRuntime: AiRuntime | null; - latestBatch: LatestBatchStatus | null; - dailyReport: DailyReport | null; - auth: AuthSession; - atlas: AtlasOverview | null; - managedSystems: ManagedSystemsOverview | null; - externalApprovals: ExternalApprovalRequest[]; - ecosystem: EcosystemSummary | null; - ecosystemTopology: EcosystemTopology | null; - ecosystemTimeline: EcosystemTimeline | null; - settlementGame: SettlementAgencyGameSummary | null; - gameFinance: GameFinanceSummary | null; - liveFlow: LiveFlowSummary | null; - liveFlowTopology: LiveFlowTopology | null; - liveFlowEvents: LiveFlowEvent[]; - workforce: WorkforceOverview | null; - mcpRegistry: McpRegistryEntry[]; - timeline: RuntimeTimelineEntry[]; + loading: boolean; refreshedAt: string | null; errors: Record; auth: AuthSession; + dashboard: DashboardData | null; runtime: LocalRuntimeStatus | null; events: RuntimeEvent[]; commands: CommandRun[]; kpi: KpiOverview | null; + endpointHealth: EndpointHealth | null; platformReadiness: PlatformReadiness | null; publicAccess: PublicAccessStatus | null; runtimeVersion: RuntimeVersion | null; + security: SecurityStatus | null; architect: ArchitectureReview | null; axReadiness: AxReadiness | null; aiRuntime: AiRuntime | null; latestBatch: LatestBatchStatus | null; dailyReport: DailyReport | null; + managedSystems: ManagedSystemsOverview | null; ecosystemTimeline: EcosystemTimeline | null; settlementGame: SettlementAgencyGameSummary | null; + ecosystem: EcosystemSummary | null; ecosystemTopology: EcosystemTopology | null; liveFlow: LiveFlowSummary | null; liveFlowTopology: LiveFlowTopology | null; liveFlowEvents: LiveFlowEvent[]; + balance: EcosystemBalanceSummary | null; balanceRecommendations: { recommendations: Array<{ serviceId: string; title: string; reason: string; mode: string }> } | null; + workforce: WorkforceOverview | null; mesh: MeshOverview | null; queue: QueueSummary | null; tasks: PmTask[]; atlas: AtlasOverview | null; + gameFinance: GameFinanceSummary | null; externalApprovals: ExternalApprovalRequest[]; knowledge: KnowledgeOverview | null; historian: HistorianStatus | null; + mcpRegistry: McpRegistryEntry[]; timeline: RuntimeTimelineEntry[]; + lastEventLatencyMs: number | null; }; -const emptyData: AppData = { - loading: true, - refreshedAt: null, - errors: {}, - dashboard: null, - runtime: null, - queue: null, - tasks: [], - events: [], - commands: [], - knowledge: null, - historian: null, - mesh: null, - kpi: null, - endpointHealth: null, - platformReadiness: null, - publicAccess: null, - runtimeVersion: null, - security: null, - architect: null, - axReadiness: null, - aiRuntime: null, - latestBatch: null, - dailyReport: null, - auth: { actor: "anonymous", role: "PUBLIC", authenticated: false }, - atlas: null, - managedSystems: null, - externalApprovals: [], - ecosystem: null, - ecosystemTopology: null, - ecosystemTimeline: null, - settlementGame: null, - gameFinance: null, - liveFlow: null, - liveFlowTopology: null, - liveFlowEvents: [], - workforce: null, - mcpRegistry: [], - timeline: [], -}; - -async function settle(key: string, fn: () => Promise) { - try { - return { key, value: await fn(), error: null }; - } catch (err) { - return { key, value: null, error: err instanceof Error ? err.message : String(err) }; - } -} +const publicAuth: AuthSession = { actor: "anonymous", role: "PUBLIC", authenticated: false }; +const emptyData: AppData = { loading: true, refreshedAt: null, errors: {}, auth: publicAuth, dashboard: null, runtime: null, events: [], commands: [], kpi: null, endpointHealth: null, platformReadiness: null, publicAccess: null, runtimeVersion: null, security: null, architect: null, axReadiness: null, aiRuntime: null, latestBatch: null, dailyReport: null, managedSystems: null, ecosystemTimeline: null, settlementGame: null, ecosystem: null, ecosystemTopology: null, liveFlow: null, liveFlowTopology: null, liveFlowEvents: [], balance: null, balanceRecommendations: null, workforce: null, mesh: null, queue: null, tasks: [], atlas: null, gameFinance: null, externalApprovals: [], knowledge: null, historian: null, mcpRegistry: [], timeline: [], lastEventLatencyMs: null }; +type Result = { key: keyof AppData; value: unknown; error: string | null }; +async function settle(key: keyof AppData, fn: () => Promise): Promise { try { return { key, value: await fn(), error: null }; } catch (error) { return { key, value: null, error: error instanceof Error ? error.message : String(error) }; } } function AppShellInner() { - const [route, setRoute] = useState("overview"); + const [route, setRouteState] = useState(() => routeFromLocation()); const [sidebarOpen, setSidebarOpen] = useState(false); const [data, setData] = useState(emptyData); - const [language, setLanguage] = useState(() => readStoredLocale()); - - useEffect(() => { - applyLocale(language); - const observer = new MutationObserver(() => window.requestAnimationFrame(() => applyLocale(language))); - observer.observe(document.body, { childList: true, subtree: true }); - return () => observer.disconnect(); - }, [language]); - + const [streamState, setStreamState] = useState<"connecting" | "connected" | "fallback">("connecting"); + const fallbackTimer = useRef(null); + const reconnectTimer = useRef(null); + const reconnectAttempt = useRef(0); + const eventIds = useRef(new Set()); + const { locale, setLocale } = useI18n(); + + const navigate = useCallback((next: CoreRoute) => { window.history.pushState({}, "", `#/${next}`); setRouteState(next); setSidebarOpen(false); }, []); useEffect(() => { - const timer = window.setTimeout(() => applyLocale(language), 0); - return () => window.clearTimeout(timer); - }, [data, language, route]); + const requested = (window.location.hash.replace(/^#\/?/, "") || window.location.pathname.split("/").filter(Boolean).pop() || "").toLowerCase(); + const canonical = normalizeRoute(requested); + if (requested && requested !== canonical) window.history.replaceState({}, "", `#/${canonical}`); + }, []); + useEffect(() => { const onPopState = () => setRouteState(routeFromLocation()); window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onPopState); return () => { window.removeEventListener("popstate", onPopState); window.removeEventListener("hashchange", onPopState); }; }, []); + useEffect(() => { document.body.classList.toggle("sidebar-open", sidebarOpen); return () => document.body.classList.remove("sidebar-open"); }, [sidebarOpen]); const refresh = useCallback(async () => { setData((current) => ({ ...current, loading: true })); - const authResult = await settle("auth", getAuthSession); - const role = authResult.value?.role ?? "PUBLIC"; - const operatorAccess = role !== "PUBLIC"; - const adminAccess = role === "ADMIN"; - const results = [authResult, ...(await Promise.all([ - settle("dashboard", getDashboardData), - settle("runtime", getLocalRuntimeStatus), - settle("queue", getQueueSummary), - settle("tasks", getPmTasks), - settle("events", getRecentRuntimeEvents), - settle("commands", getRecentCommands), - settle("knowledge", getKnowledgeOverview), - settle("historian", getHistorianStatus), - settle("mesh", getMeshOverview), - settle("kpi", () => getKpiOverview("7d")), - settle("endpointHealth", getEndpointHealth), - settle("platformReadiness", getPlatformReadiness), - adminAccess ? settle("publicAccess", getPublicAccessStatus) : Promise.resolve({ key: "publicAccess", value: null, error: null }), - settle("runtimeVersion", getRuntimeVersion), - adminAccess ? settle("security", getSecurityStatus) : Promise.resolve({ key: "security", value: null, error: null }), - settle("architect", getLatestArchitectureReview), - settle("axReadiness", getAxReadiness), - settle("aiRuntime", getAiRuntime), - settle("latestBatch", getLatestBatchStatus), - settle("dailyReport", getLatestDailyReport), - settle("atlas", getAtlasOverview), - settle("managedSystems", getManagedSystemsOverview), - settle("externalApprovals", () => getExternalApprovals(50)), - settle("ecosystem", getEcosystemSummary), - settle("ecosystemTopology", getEcosystemTopology), - settle("ecosystemTimeline", () => getEcosystemTimeline(50)), - settle("settlementGame", getSettlementAgencyGameSummary), - settle("gameFinance", getGameFinanceSummary), - settle("liveFlow", getLiveFlowSummary), - settle("liveFlowTopology", getLiveFlowTopology), - settle("liveFlowEvents", () => getLiveFlowRecentEvents(100)), - settle("workforce", getWorkforceOverview), - operatorAccess ? settle("mcpRegistry", getMcpRegistry) : Promise.resolve({ key: "mcpRegistry", value: [], error: null }), - operatorAccess ? settle("timeline", () => getRuntimeTimeline(100)) : Promise.resolve({ key: "timeline", value: [], error: null }), - ]))]; - - const next: AppData = { ...emptyData, loading: false, refreshedAt: new Date().toISOString(), errors: {} }; - for (const result of results) { - if (result.error) { - next.errors[result.key] = result.error; - } else { - (next as unknown as Record)[result.key] = result.value; - } - } - setData(next); - }, []); - - useEffect(() => { - refresh(); - const timer = window.setInterval(refresh, 45_000); - return () => window.clearInterval(timer); - }, [refresh]); + const loaders = loadersFor(route); + const results = await Promise.all(loaders.map(([key, fn]) => settle(key, fn))); + setData((current) => { + const next: AppData = { ...current, loading: false, refreshedAt: new Date().toISOString(), errors: {} }; + for (const result of results) { if (result.error) next.errors[result.key] = result.error; else (next as unknown as Record)[result.key] = result.value; } + if (!next.auth) next.auth = publicAuth; + return next; + }); + }, [route]); + useEffect(() => { refresh(); }, [refresh]); useEffect(() => { - document.body.classList.toggle("sidebar-open", sidebarOpen); - return () => document.body.classList.remove("sidebar-open"); - }, [sidebarOpen]); - - const healthTone = useMemo(() => { - if (data.loading) return "working"; - const failing = data.endpointHealth?.summary.failed ?? Object.keys(data.errors).length; - return failing > 0 ? "warning" : "healthy"; - }, [data.endpointHealth?.summary.failed, data.errors, data.loading]); - - const page = { - overview: , - ecosystem: , - liveflow: , - workforce: , - finance: , - managed: , - approvals: , - agents: , - workflows: , - knowledge: , - history: , - batch: , - rpa: , - atlas: , - mcp: , - settings: , - }[route]; - - return ( -
- { setRoute(nextRoute); setSidebarOpen(false); }} health={healthTone} loading={data.loading} branch={data.runtimeVersion?.branch} commitSha={data.runtimeVersion?.commitSha} role={data.auth.role} /> - {sidebarOpen ? -
ArchiveOS 관제 센터

{navigationItems.find((item) => item.id === route)?.label}

-
- - 갱신 {data.refreshedAt ? new Date(data.refreshedAt).toLocaleTimeString() : "대기 중"} - -
- -
{page}
-
- - ); + if (route !== "dashboard") return; + let disposed = false; + let source: EventSource | null = null; + const receive = (raw: string) => { + try { + const payload = JSON.parse(raw) as LiveFlowEvent | LiveFlowSummary; + if ("event_id" in payload) { + if (eventIds.current.has(payload.event_id)) return; + eventIds.current.add(payload.event_id); + if (eventIds.current.size > 750) eventIds.current.delete(eventIds.current.values().next().value as string); + const receivedAt = payload.received_at ? Date.parse(payload.received_at) : Number.NaN; + const latency = Number.isFinite(receivedAt) ? Math.max(0, Date.now() - receivedAt) : null; + setData((current) => ({ ...current, lastEventLatencyMs: latency, liveFlowEvents: [payload, ...current.liveFlowEvents.filter((event) => event.event_id !== payload.event_id)].slice(0, 100), liveFlow: current.liveFlow ? { ...current.liveFlow, latest_event_at: payload.occurred_at, recent_events: (current.liveFlow.recent_events ?? 0) + 1, active_flows: (current.liveFlow.active_flows ?? 0) + 1 } : current.liveFlow })); + } else if ("active_flows" in payload) setData((current) => ({ ...current, liveFlow: payload })); + } catch { /* malformed stream data is ignored; API polling remains a degraded fallback. */ } + }; + const startFallback = () => { + if (fallbackTimer.current) return; + setStreamState("fallback"); + fallbackTimer.current = window.setInterval(() => { getLiveFlowRecentEvents(30).then((events) => { if (!disposed) setData((current) => ({ ...current, liveFlowEvents: events })); }).catch(() => undefined); }, 1000); + }; + const stopFallback = () => { if (fallbackTimer.current) { window.clearInterval(fallbackTimer.current); fallbackTimer.current = null; } }; + const clearReconnect = () => { if (reconnectTimer.current) { window.clearTimeout(reconnectTimer.current); reconnectTimer.current = null; } }; + const connect = (reconnecting = false) => { + if (disposed) return; + clearReconnect(); + setStreamState(reconnecting ? "fallback" : "connecting"); + source?.close(); + source = new EventSource(liveFlowStreamUrl(), { withCredentials: true }); + const connected = (event: Event) => { receive((event as MessageEvent).data); reconnectAttempt.current = 0; setStreamState("connected"); stopFallback(); clearReconnect(); }; + source.addEventListener("snapshot", connected); + source.addEventListener("runtime-event", connected); + source.addEventListener("service-status", (event) => receive((event as MessageEvent).data)); + source.onerror = () => { + if (disposed) return; + startFallback(); + source?.close(); + if (!navigator.onLine) return; + const delay = Math.min(1000 * 2 ** reconnectAttempt.current, 30_000); + reconnectAttempt.current += 1; + if (!reconnectTimer.current) reconnectTimer.current = window.setTimeout(() => connect(true), delay); + }; + }; + const reconnectWhenOnline = () => { if (!disposed && !reconnectTimer.current) connect(true); }; + window.addEventListener("online", reconnectWhenOnline); + connect(); + return () => { disposed = true; window.removeEventListener("online", reconnectWhenOnline); clearReconnect(); source?.close(); stopFallback(); reconnectAttempt.current = 0; eventIds.current.clear(); }; + }, [route]); + + const health = useMemo(() => data.ecosystem?.status === "HEALTHY" ? "healthy" : Object.keys(data.errors).length ? "warning" : "waiting", [data.ecosystem?.status, data.errors]); + const page = route === "dashboard" ? : route === "services" ? : route === "operations" ? : route === "finance" ? : route === "records" ? : ; + return
{sidebarOpen ?
ARCHIVEOS CONTROL TOWER

{consoleText(locale, `nav.${route}`)}

{route === "dashboard" ? {streamState === "connected" ? `${consoleText(locale, "common.live")}${data.lastEventLatencyMs === null ? "" : ` · ${data.lastEventLatencyMs}ms`}` : streamState === "fallback" ? consoleText(locale, "common.reconnecting") : consoleText(locale, "common.connecting")} : null}{data.refreshedAt ? `${consoleText(locale, "common.updated")} ${new Date(data.refreshedAt).toLocaleTimeString()}` : consoleText(locale, "common.loading")}
{page}
; } -function LanguageSelector({ value, onChange }: { value: Locale; onChange: (value: Locale) => void }) { - return ( - - ); +function LanguagePopover({ locale, setLocale }: { locale: Locale; setLocale: (locale: Locale) => void }) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const menuId = useId(); + const close = (returnFocus = false) => { setOpen(false); if (returnFocus) window.setTimeout(() => triggerRef.current?.focus(), 0); }; + useEffect(() => { const dismiss = (event: PointerEvent) => { const target = event.target as Node; if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) close(false); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") close(true); }; window.addEventListener("pointerdown", dismiss); window.addEventListener("keydown", escape); return () => { window.removeEventListener("pointerdown", dismiss); window.removeEventListener("keydown", escape); }; }, []); + useEffect(() => { if (open) menuRef.current?.querySelector("[aria-checked='true']")?.focus(); }, [open]); + return
{open ? : null}
; } -export function AppShell() { - return ( - - - - ); +function loadersFor(route: CoreRoute): Array<[keyof AppData, () => Promise]> { + const auth: [keyof AppData, () => Promise] = ["auth", getAuthSession]; + if (route === "dashboard") return [auth, ["ecosystem", getEcosystemSummary], ["liveFlow", getLiveFlowSummary], ["liveFlowTopology", getLiveFlowTopology], ["liveFlowEvents", () => getLiveFlowRecentEvents(30)], ["balance", getEcosystemBalanceSummary]]; + if (route === "services") return [auth, ["ecosystem", getEcosystemSummary], ["ecosystemTopology", getEcosystemTopology], ["atlas", getAtlasOverview]]; + if (route === "operations") return [auth, ["mesh", getMeshOverview], ["workforce", getWorkforceOverview], ["queue", getQueueSummary], ["tasks", getPmTasks]]; + if (route === "finance") return [auth, ["ecosystem", getEcosystemSummary], ["balance", getEcosystemBalanceSummary], ["gameFinance", getGameFinanceSummary], ["externalApprovals", () => getExternalApprovals(50)]]; + if (route === "records") return [auth, ["liveFlowEvents", () => getLiveFlowRecentEvents(100)], ["knowledge", getKnowledgeOverview], ["historian", getHistorianStatus], ["timeline", () => getRuntimeTimeline(100)]]; + return [auth, ["mcpRegistry", getMcpRegistry]]; } +function routeFromLocation(): CoreRoute { const hash = window.location.hash.replace(/^#\/?/, ""); const path = window.location.pathname.split("/").filter(Boolean).pop(); return normalizeRoute(hash || path); } +export function AppShell() { return ; } diff --git a/src/app/navigation.ts b/src/app/navigation.ts index 51beafd..424f7d3 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -1,126 +1,39 @@ import type { IconName } from "../components/shared/Icon"; -export type AppRoute = "overview" | "ecosystem" | "liveflow" | "workforce" | "finance" | "managed" | "approvals" | "agents" | "workflows" | "knowledge" | "history" | "batch" | "rpa" | "atlas" | "mcp" | "settings"; +export type CoreRoute = "dashboard" | "services" | "operations" | "finance" | "records" | "settings"; +export type LegacyRoute = "overview" | "liveflow" | "ecosystem" | "managed" | "atlas" | "agents" | "workforce" | "workflows" | "batch" | "rpa" | "approvals" | "knowledge" | "history" | "mcp"; +export type AppRoute = CoreRoute | LegacyRoute; -export type NavigationItem = { - id: AppRoute; - label: string; - shortLabel: string; - description: string; - icon: IconName; -}; +export type NavigationItem = { id: CoreRoute; label: string; shortLabel: string; description: string; icon: IconName }; export const navigationItems: NavigationItem[] = [ - { - id: "overview", - label: "운영 개요", - shortLabel: "홈", - description: "현재 상태와 우선 조치를 빠르게 확인합니다", - icon: "overview", - }, - { - id: "liveflow", - label: "실시간 관제", - shortLabel: "관제", - description: "주문, 제조, 물류, 정산, 승인 흐름을 실시간으로 확인합니다", - icon: "workflow", - }, - { - id: "agents", - label: "에이전트", - shortLabel: "에이전트", - description: "서비스별 에이전트 상태와 담당 작업을 확인합니다", - icon: "agents", - }, - { - id: "ecosystem", - label: "에코시스템", - shortLabel: "에코", - description: "Market, Nexus, Logistics, Ledger, ArchiveOS 연결 상태를 봅니다", - icon: "activity", - }, - { - id: "workforce", - label: "작업 역량", - shortLabel: "역량", - description: "서비스별 처리 역량, 적체, 병목을 확인합니다", - icon: "agents", - }, - { - id: "finance", - label: "재무 흐름", - shortLabel: "재무", - description: "정산 흐름, 보유자금, 수입과 지출을 확인합니다", - icon: "health", - }, - { - id: "managed", - label: "시스템 관리", - shortLabel: "시스템", - description: "외부 시스템과 PM Inbox를 관리합니다", - icon: "activity", - }, - { - id: "approvals", - label: "Ledger 승인", - shortLabel: "Ledger", - description: "Ledger 승인 요청과 callback 상태를 확인합니다", - icon: "approval", - }, - { - id: "workflows", - label: "작업 흐름", - shortLabel: "작업", - description: "작업 큐, 파이프라인, PM 결정을 관리합니다", - icon: "workflow", - }, - { - id: "knowledge", - label: "운영 지식", - shortLabel: "지식", - description: "운영 메모리, 지식 그래프, RAG 상태를 확인합니다", - icon: "knowledge", - }, - { - id: "history", - label: "이력", - shortLabel: "로그", - description: "결정, 명령, 오류, KPI 이력을 확인합니다", - icon: "history", - }, - { - id: "batch", - label: "배치", - shortLabel: "Batch", - description: "Spring Batch 작업과 실행 근거를 확인합니다", - icon: "batch", - }, - { - id: "rpa", - label: "RPA", - shortLabel: "RPA", - description: "분류된 작업과 PM 결정 이력을 확인합니다", - icon: "rpa", - }, - { - id: "atlas", - label: "Atlas", - shortLabel: "Atlas", - description: "외부 Atlas 플랫폼 상태와 작업 로그를 확인합니다", - icon: "activity", - }, - { - id: "mcp", - label: "MCP Registry", - shortLabel: "MCP", - description: "도구 권한과 승인 레지스트리를 확인합니다", - icon: "activity", - }, - { - id: "settings", - label: "설정", - shortLabel: "설정", - description: "런타임, 연동, 보안, 화면, 빌드 상태를 관리합니다", - icon: "settings", - }, + { id: "dashboard", label: "대시보드", shortLabel: "대시보드", description: "전체 상태와 라이브 메쉬를 한눈에 확인합니다.", icon: "overview" }, + { id: "services", label: "서비스", shortLabel: "서비스", description: "핵심 서비스와 외부 연동 상태를 확인합니다.", icon: "activity" }, + { id: "operations", label: "운영", shortLabel: "운영", description: "에이전트, 처리 역량, 작업 흐름과 자동화를 관리합니다.", icon: "workflow" }, + { id: "finance", label: "재무", shortLabel: "재무", description: "합성 정산 흐름, 손익, 승인과 대사를 확인합니다.", icon: "health" }, + { id: "records", label: "기록", shortLabel: "기록", description: "실시간 이벤트, 감사 이력과 운영 지식을 조회합니다.", icon: "history" }, + { id: "settings", label: "설정", shortLabel: "설정", description: "연동, 보안, 화면과 고급 도구를 설정합니다.", icon: "settings" }, ]; + +export const legacyRedirects: Record = { + overview: "dashboard", + liveflow: "dashboard", + ecosystem: "services", + managed: "services", + atlas: "services", + agents: "operations", + workforce: "operations", + workflows: "operations", + batch: "operations", + rpa: "operations", + approvals: "finance", + knowledge: "records", + history: "records", + mcp: "settings", +}; + +export function normalizeRoute(value: string | null | undefined): CoreRoute { + if (!value) return "dashboard"; + if (navigationItems.some((item) => item.id === value)) return value as CoreRoute; + return legacyRedirects[value as LegacyRoute] ?? "dashboard"; +} diff --git a/src/components/console/LiveMeshTopology.tsx b/src/components/console/LiveMeshTopology.tsx new file mode 100644 index 0000000..29f4805 --- /dev/null +++ b/src/components/console/LiveMeshTopology.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from "react"; +import type { LiveFlowEvent, LiveFlowSummary, LiveFlowTopology } from "../../lib/backendApi"; +import { StatusBadge } from "../shared/StatusBadge"; +import { useI18n } from "../../i18n/I18nProvider"; +import { consoleText } from "../../i18n/console"; + +type MeshNode = { id: string; label: string; type: string; x: number; y: number }; + +const roleByNode: Record = { + market: "주문·결제", + nexus: "제조", + logistics: "물류", + ledger: "정산", + archiveos: "운영 오케스트레이터", + settlement: "정산 배치", +}; + +export function LiveMeshTopology({ + topology, + summary, + events, + compact = false, +}: { + topology: LiveFlowTopology | null; + summary: LiveFlowSummary | null; + events: LiveFlowEvent[]; + compact?: boolean; +}) { + const { locale } = useI18n(); + const [selected, setSelected] = useState(null); + const nodes = topology?.nodes?.length ? topology.nodes : fallbackNodes; + const edges = topology?.edges?.length ? topology.edges : fallbackEdges; + const nodeMap = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const runtime = summary?.runtime?.services ?? []; + const visibleEvents = events.slice(0, 30); + const clusters = useMemo(() => clusterEvents(events.slice(30)), [events]); + + return ( +
+
+
+ LIVE MESH +

{consoleText(locale, "mesh.title")}

+

{consoleText(locale, "mesh.description")}

+
+
+ 주요 흐름 + 비동기 + 승인·검증 + 상태 공유 +
+
+
+
+ + {nodes.map((node) => { + const runtimeState = runtime.find((state) => normalizeNode(state.serviceId || state.serviceName) === node.id); + const count = visibleEvents.filter((event) => normalizeNode(event.from_node) === node.id || normalizeNode(event.to_node) === node.id).length; + return ; + })} + {clusters.map((cluster) => +{cluster.count})} +
+
+ {!compact ? : null} +
+ ); +} + +function MeshDetail({ event }: { event: LiveFlowEvent | null }) { + if (!event) return
이벤트를 선택하면 관련 흐름과 운영 영향을 확인할 수 있습니다.
; + const metadata = maskSensitive(event.metadata || {}); + return
+
선택 이벤트

{event.event_type}

경로
{event.from_node} → {event.to_node}
상태
{runtimeLabel(event.status)}
발생
{formatTime(event.occurred_at)}
+
연관 흐름

추적 정보

상관관계
{event.correlation_id || "연결 정보 없음"}
대상
{event.entity_type} · {event.entity_id}
출처
{event.source_system_id}
+
운영 영향

{impactLabel(event)}

{impactDescription(event)}

메타데이터 보기
{JSON.stringify(metadata, null, 2)}
+
; +} + +function clusterEvents(events: LiveFlowEvent[]) { + const groups = new Map(); + for (const event of events) { const key = `${normalizeNode(event.from_node)}-${normalizeNode(event.to_node)}`; groups.set(key, (groups.get(key) ?? 0) + 1); } + return [...groups.entries()].slice(0, 6).map(([key, count], index) => ({ key, count, x: 20 + (index % 3) * 28, y: 45 + Math.floor(index / 3) * 20 })); +} +function pointOnLine(from: MeshNode, to: MeshNode, index: number) { const step = 0.25 + (index % 5) * 0.12; return { x: from.x + (to.x - from.x) * step, y: from.y + (to.y - from.y) * step }; } +function normalizeNode(value?: string | null) { const text = String(value || "").toLowerCase(); if (text.includes("market")) return "market"; if (text.includes("nexus") || text.includes("factory")) return "nexus"; if (text.includes("logit")) return "logistics"; if (text.includes("ledger") || text.includes("transaction")) return "ledger"; if (text.includes("settle")) return "settlement"; return "archiveos"; } +function edgeTone(from: string, to: string) { if (from === "archiveos" || to === "archiveos") return "approval"; if (to === "settlement") return "settlement"; if (to === "archiveos") return "monitor"; return "business"; } +function tokenTone(event: LiveFlowEvent) { const value = `${event.status} ${event.severity}`.toLowerCase(); if (value.includes("fail") || value.includes("critical")) return "critical"; if (value.includes("delay") || value.includes("warning")) return "warning"; if (value.includes("approval")) return "approval"; if (value.includes("wait")) return "waiting"; if (value.includes("complete") || value.includes("settled")) return "completed"; return "normal"; } +function runtimeLabel(value?: string | null) { const text = String(value || "").toUpperCase(); if (["PROCESSING", "RUNNING", "MOVING"].includes(text)) return "처리 중"; if (["STALLED", "STALE"].includes(text)) return "정체"; if (["WARNING", "DEGRADED", "SLOW", "DELAYED"].includes(text)) return "주의"; if (["FAILED", "UNAVAILABLE"].includes(text)) return "실패"; if (["HEALTHY", "LIVE", "COMPLETED"].includes(text)) return "정상"; return "대기"; } +function impactLabel(event: LiveFlowEvent) { return tokenTone(event) === "critical" ? "확인 필요" : tokenTone(event) === "warning" ? "지연 가능성" : "정상 흐름"; } +function impactDescription(event: LiveFlowEvent) { if (tokenTone(event) === "critical") return "실패 또는 위험 상태입니다. 관련 승인·콜백·적체를 확인하세요."; if (tokenTone(event) === "warning") return "지연 또는 처리 대기 상태입니다. 서비스 처리량과 적체를 확인하세요."; return "수집된 합성 런타임 이벤트가 서비스 간 흐름에 반영되었습니다."; } +function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } +function maskSensitive(value: Record) { return Object.fromEntries(Object.entries(value).map(([key, item]) => /secret|token|password|webhook|private.?key|api.?key/i.test(key) ? [key, "***"] : [key, item])); } + +const fallbackNodes: MeshNode[] = [ + { id: "market", label: "Archive-Market", type: "source", x: 10, y: 22 }, { id: "nexus", label: "Archive-Nexus", type: "factory", x: 42, y: 22 }, { id: "logistics", label: "Archive-Logistics", type: "flow", x: 76, y: 22 }, { id: "ledger", label: "Archive-Ledger", type: "financial", x: 22, y: 70 }, { id: "archiveos", label: "ArchiveOS", type: "control", x: 52, y: 70 }, { id: "settlement", label: "Settlement", type: "batch", x: 84, y: 70 }, +]; +const fallbackEdges = [{ from: "market", to: "nexus", label: "order" }, { from: "market", to: "ledger", label: "sales" }, { from: "nexus", to: "logistics", label: "shipment" }, { from: "nexus", to: "ledger", label: "cost" }, { from: "logistics", to: "ledger", label: "cost" }, { from: "ledger", to: "archiveos", label: "approval" }, { from: "archiveos", to: "ledger", label: "callback" }, { from: "ledger", to: "settlement", label: "settlement" }]; diff --git a/src/components/shared/Sidebar.tsx b/src/components/shared/Sidebar.tsx index e70f092..ce97927 100644 --- a/src/components/shared/Sidebar.tsx +++ b/src/components/shared/Sidebar.tsx @@ -1,8 +1,10 @@ import type { SemanticStatus } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge"; import { Icon } from "./Icon"; -import { navigationItems, type AppRoute } from "../../app/navigation"; +import { navigationItems, type CoreRoute } from "../../app/navigation"; import type { PlatformRole } from "../../lib/backendApi"; +import { useI18n } from "../../i18n/I18nProvider"; +import { consoleText } from "../../i18n/console"; export function Sidebar({ route, @@ -14,15 +16,16 @@ export function Sidebar({ commitSha, role, }: { - route: AppRoute; + route: CoreRoute; open: boolean; - onNavigate: (route: AppRoute) => void; + onNavigate: (route: CoreRoute) => void; health: SemanticStatus; loading: boolean; branch?: string | null; commitSha?: string | null; role: PlatformRole; }) { + const { locale } = useI18n(); const displayedCommit = commitSha ? commitSha.slice(0, 7) : "local"; const displayedBranch = branch || "main"; @@ -43,7 +46,7 @@ export function Sidebar({