adding files 2
This commit is contained in:
44
order-service/pom.xml
Normal file
44
order-service/pom.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.oracle.demo</groupId>
|
||||
<artifactId>kagent-oci-devops-demo</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>order-service</artifactId>
|
||||
<name>order-service</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.oracle.demo.orders;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@SpringBootApplication
|
||||
public class OrderServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OrderServiceApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
RestClient restClient() {
|
||||
return RestClient.builder().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.oracle.demo.orders.api;
|
||||
|
||||
import com.oracle.demo.orders.model.BusinessHealth;
|
||||
import com.oracle.demo.orders.model.CreateOrderRequest;
|
||||
import com.oracle.demo.orders.model.OrderRecord;
|
||||
import com.oracle.demo.orders.service.OrderService;
|
||||
import jakarta.validation.Valid;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
public OrderController(OrderService orderService) {
|
||||
this.orderService = orderService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public OrderRecord createOrder(@Valid @RequestBody CreateOrderRequest request) {
|
||||
return orderService.createOrder(request);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<OrderRecord> listOrders() {
|
||||
return orderService.listOrders();
|
||||
}
|
||||
|
||||
@GetMapping("/business-health")
|
||||
public BusinessHealth businessHealth() {
|
||||
return orderService.businessHealth();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.oracle.demo.orders.client;
|
||||
|
||||
import com.oracle.demo.orders.model.InventoryReservation;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Component
|
||||
public class InventoryClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String inventoryBaseUrl;
|
||||
|
||||
public InventoryClient(RestClient restClient, @Value("${services.inventory.base-url}") String inventoryBaseUrl) {
|
||||
this.restClient = restClient;
|
||||
this.inventoryBaseUrl = inventoryBaseUrl;
|
||||
}
|
||||
|
||||
public InventoryReservation reserve(String sku, int quantity) {
|
||||
return restClient.get()
|
||||
.uri(inventoryBaseUrl + "/api/inventory/{sku}?quantity={quantity}", sku, quantity)
|
||||
.retrieve()
|
||||
.body(InventoryReservation.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.oracle.demo.orders.client;
|
||||
|
||||
import com.oracle.demo.orders.model.PaymentDecision;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class PaymentClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String paymentBaseUrl;
|
||||
|
||||
public PaymentClient(RestClient restClient, @Value("${services.payment.base-url}") String paymentBaseUrl) {
|
||||
this.restClient = restClient;
|
||||
this.paymentBaseUrl = paymentBaseUrl;
|
||||
}
|
||||
|
||||
public PaymentDecision authorize(String orderId, String cardToken, double amount) {
|
||||
return restClient.post()
|
||||
.uri(paymentBaseUrl + "/api/payments/authorize")
|
||||
.body(Map.of("orderId", orderId, "cardToken", cardToken, "amount", amount))
|
||||
.retrieve()
|
||||
.body(PaymentDecision.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.oracle.demo.orders.model;
|
||||
|
||||
public record BusinessHealth(
|
||||
long totalOrders,
|
||||
long confirmedOrders,
|
||||
long pendingReviewOrders,
|
||||
long failedOrders,
|
||||
double manualReviewRate,
|
||||
double totalRevenue,
|
||||
double confirmedRevenue,
|
||||
double revenueAtRisk,
|
||||
boolean healthy,
|
||||
String summary,
|
||||
String businessImpact) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.oracle.demo.orders.model;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateOrderRequest(
|
||||
@NotBlank String customerId,
|
||||
@NotBlank String sku,
|
||||
@Min(1) int quantity,
|
||||
@Min(1) double amount,
|
||||
@NotBlank String cardToken) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.oracle.demo.orders.model;
|
||||
|
||||
public record InventoryReservation(String sku, boolean reserved, int availableUnits, String message) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.oracle.demo.orders.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record OrderRecord(
|
||||
String orderId,
|
||||
String customerId,
|
||||
String sku,
|
||||
int quantity,
|
||||
double amount,
|
||||
String status,
|
||||
String reason,
|
||||
Instant createdAt) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.oracle.demo.orders.model;
|
||||
|
||||
public record PaymentDecision(boolean approved, String status, String message) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.oracle.demo.orders.observability;
|
||||
|
||||
import com.oracle.demo.orders.model.BusinessHealth;
|
||||
import com.oracle.demo.orders.model.OrderRecord;
|
||||
import com.oracle.demo.orders.service.OrderService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/observability")
|
||||
public class ObservabilityController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final RestClient restClient;
|
||||
private final String inventoryBaseUrl;
|
||||
private final String paymentBaseUrl;
|
||||
|
||||
public ObservabilityController(
|
||||
OrderService orderService,
|
||||
RestClient restClient,
|
||||
@Value("${services.inventory.base-url}") String inventoryBaseUrl,
|
||||
@Value("${services.payment.base-url}") String paymentBaseUrl) {
|
||||
this.orderService = orderService;
|
||||
this.restClient = restClient;
|
||||
this.inventoryBaseUrl = inventoryBaseUrl;
|
||||
this.paymentBaseUrl = paymentBaseUrl;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ObservabilitySnapshot snapshot() {
|
||||
BusinessHealth businessHealth = orderService.businessHealth();
|
||||
List<OrderRecord> recentOrders = orderService.listOrders().stream().limit(12).toList();
|
||||
List<ServiceSignal> services = new ArrayList<>();
|
||||
|
||||
services.add(new ServiceSignal(
|
||||
"order-service",
|
||||
"business-api",
|
||||
businessHealth.healthy() ? "UP" : "DEGRADED",
|
||||
businessHealth.summary(),
|
||||
!businessHealth.healthy()));
|
||||
services.add(probe("inventory-service", "dependency", inventoryBaseUrl + "/actuator/health"));
|
||||
services.add(probe("payment-service", "dependency", paymentBaseUrl + "/actuator/health"));
|
||||
|
||||
return new ObservabilitySnapshot(
|
||||
Instant.now(),
|
||||
businessHealth,
|
||||
services,
|
||||
recentOrders,
|
||||
recommendedActions(businessHealth, services));
|
||||
}
|
||||
|
||||
private ServiceSignal probe(String name, String layer, String url) {
|
||||
try {
|
||||
Map<?, ?> body = restClient.get().uri(url).retrieve().body(Map.class);
|
||||
Object status = body == null ? null : body.get("status");
|
||||
status = status == null ? "UNKNOWN" : status;
|
||||
boolean healthy = "UP".equals(String.valueOf(status));
|
||||
return new ServiceSignal(
|
||||
name,
|
||||
layer,
|
||||
String.valueOf(status),
|
||||
healthy ? "Actuator health endpoint is reachable." : "Actuator returned a non-UP status.",
|
||||
!healthy);
|
||||
} catch (Exception ex) {
|
||||
return new ServiceSignal(
|
||||
name,
|
||||
layer,
|
||||
"DOWN",
|
||||
ex.getClass().getSimpleName() + ": " + ex.getMessage(),
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> recommendedActions(BusinessHealth businessHealth, List<ServiceSignal> services) {
|
||||
List<String> actions = new ArrayList<>();
|
||||
|
||||
services.stream()
|
||||
.filter(ServiceSignal::kagentSignal)
|
||||
.filter(signal -> !"order-service".equals(signal.name()))
|
||||
.findFirst()
|
||||
.ifPresent(signal -> actions.add("Inspect " + signal.name() + " pod logs, endpoints, and recent deployment changes."));
|
||||
|
||||
if (!businessHealth.healthy()) {
|
||||
if (businessHealth.revenueAtRisk() > 0) {
|
||||
actions.add("Revenue at risk is " + businessHealth.revenueAtRisk()
|
||||
+ " USD with manual review rate " + businessHealth.manualReviewRate()
|
||||
+ "%. Inspect payment risk rules and recent OCI DevOps changes.");
|
||||
}
|
||||
actions.add("Correlate pending, failed, and revenue-at-risk signals with the latest OCI DevOps deployment.");
|
||||
actions.add("Recommend rollback or approved config correction through OCI DevOps if degradation started after the last release.");
|
||||
}
|
||||
|
||||
if (actions.isEmpty()) {
|
||||
actions.add("No remediation required. Continue monitoring pipeline and runtime signals.");
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.oracle.demo.orders.observability;
|
||||
|
||||
import com.oracle.demo.orders.model.BusinessHealth;
|
||||
import com.oracle.demo.orders.model.OrderRecord;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record ObservabilitySnapshot(
|
||||
Instant timestamp,
|
||||
BusinessHealth businessHealth,
|
||||
List<ServiceSignal> services,
|
||||
List<OrderRecord> recentOrders,
|
||||
List<String> recommendedKagentActions) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.oracle.demo.orders.observability;
|
||||
|
||||
public record ServiceSignal(
|
||||
String name,
|
||||
String layer,
|
||||
String status,
|
||||
String detail,
|
||||
boolean kagentSignal) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.oracle.demo.orders.service;
|
||||
|
||||
import com.oracle.demo.orders.client.InventoryClient;
|
||||
import com.oracle.demo.orders.client.PaymentClient;
|
||||
import com.oracle.demo.orders.model.BusinessHealth;
|
||||
import com.oracle.demo.orders.model.CreateOrderRequest;
|
||||
import com.oracle.demo.orders.model.InventoryReservation;
|
||||
import com.oracle.demo.orders.model.OrderRecord;
|
||||
import com.oracle.demo.orders.model.PaymentDecision;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
private final InventoryClient inventoryClient;
|
||||
private final PaymentClient paymentClient;
|
||||
private final Map<String, OrderRecord> orders = new ConcurrentHashMap<>();
|
||||
private final boolean stuckReviewMode;
|
||||
private final double manualReviewRateThreshold;
|
||||
private final double revenueAtRiskThreshold;
|
||||
private final Counter createdCounter;
|
||||
private final Counter reviewCounter;
|
||||
private final Counter failedCounter;
|
||||
private final AtomicInteger pendingReviewGauge = new AtomicInteger();
|
||||
|
||||
public OrderService(
|
||||
InventoryClient inventoryClient,
|
||||
PaymentClient paymentClient,
|
||||
MeterRegistry meterRegistry,
|
||||
@Value("${demo.orders.stuck-review-mode:false}") boolean stuckReviewMode,
|
||||
@Value("${demo.orders.manual-review-rate-threshold:40}") double manualReviewRateThreshold,
|
||||
@Value("${demo.orders.revenue-at-risk-threshold:5000}") double revenueAtRiskThreshold) {
|
||||
this.inventoryClient = inventoryClient;
|
||||
this.paymentClient = paymentClient;
|
||||
this.stuckReviewMode = stuckReviewMode;
|
||||
this.manualReviewRateThreshold = manualReviewRateThreshold;
|
||||
this.revenueAtRiskThreshold = revenueAtRiskThreshold;
|
||||
this.createdCounter = Counter.builder("demo_orders_created_total").register(meterRegistry);
|
||||
this.reviewCounter = Counter.builder("demo_orders_review_required_total").register(meterRegistry);
|
||||
this.failedCounter = Counter.builder("demo_orders_failed_total").register(meterRegistry);
|
||||
Gauge.builder("demo_orders_pending_review", pendingReviewGauge, AtomicInteger::get).register(meterRegistry);
|
||||
}
|
||||
|
||||
public OrderRecord createOrder(CreateOrderRequest request) {
|
||||
String orderId = UUID.randomUUID().toString();
|
||||
createdCounter.increment();
|
||||
|
||||
InventoryReservation inventory = inventoryClient.reserve(request.sku(), request.quantity());
|
||||
if (!inventory.reserved()) {
|
||||
failedCounter.increment();
|
||||
return store(new OrderRecord(
|
||||
orderId,
|
||||
request.customerId(),
|
||||
request.sku(),
|
||||
request.quantity(),
|
||||
request.amount(),
|
||||
"FAILED",
|
||||
"Inventory check failed: " + inventory.message(),
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
PaymentDecision payment = paymentClient.authorize(orderId, request.cardToken(), request.amount());
|
||||
if (!payment.approved()) {
|
||||
reviewCounter.increment();
|
||||
String reason = stuckReviewMode
|
||||
? "Orders are stuck in review because stuck-review-mode is enabled."
|
||||
: "Payment requires manual review: " + payment.message();
|
||||
return store(new OrderRecord(
|
||||
orderId,
|
||||
request.customerId(),
|
||||
request.sku(),
|
||||
request.quantity(),
|
||||
request.amount(),
|
||||
"PENDING_REVIEW",
|
||||
reason,
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
return store(new OrderRecord(
|
||||
orderId,
|
||||
request.customerId(),
|
||||
request.sku(),
|
||||
request.quantity(),
|
||||
request.amount(),
|
||||
"CONFIRMED",
|
||||
"Order confirmed.",
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
public List<OrderRecord> listOrders() {
|
||||
return orders.values().stream()
|
||||
.sorted(Comparator.comparing(OrderRecord::createdAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public BusinessHealth businessHealth() {
|
||||
long total = orders.size();
|
||||
long confirmed = orders.values().stream().filter(order -> "CONFIRMED".equals(order.status())).count();
|
||||
long pendingReview = orders.values().stream().filter(order -> "PENDING_REVIEW".equals(order.status())).count();
|
||||
long failed = orders.values().stream().filter(order -> "FAILED".equals(order.status())).count();
|
||||
double totalRevenue = orders.values().stream().mapToDouble(OrderRecord::amount).sum();
|
||||
double confirmedRevenue = orders.values().stream()
|
||||
.filter(order -> "CONFIRMED".equals(order.status()))
|
||||
.mapToDouble(OrderRecord::amount)
|
||||
.sum();
|
||||
double revenueAtRisk = orders.values().stream()
|
||||
.filter(order -> "PENDING_REVIEW".equals(order.status()))
|
||||
.mapToDouble(OrderRecord::amount)
|
||||
.sum();
|
||||
double manualReviewRate = total == 0 ? 0 : (pendingReview * 100.0) / total;
|
||||
boolean manualReviewRateIncident = total >= 10 && manualReviewRate >= manualReviewRateThreshold;
|
||||
boolean healthy = pendingReview < 5
|
||||
&& failed < 3
|
||||
&& !manualReviewRateIncident
|
||||
&& revenueAtRisk < revenueAtRiskThreshold;
|
||||
String summary = healthy
|
||||
? "Business flow healthy."
|
||||
: "Business degradation detected. Review pending or failed orders before promoting.";
|
||||
String businessImpact = healthy
|
||||
? "No revenue protection action required."
|
||||
: "Revenue at risk from manual-review backlog. Validate payment risk rules before continuing promotion.";
|
||||
return new BusinessHealth(
|
||||
total,
|
||||
confirmed,
|
||||
pendingReview,
|
||||
failed,
|
||||
round(manualReviewRate),
|
||||
round(totalRevenue),
|
||||
round(confirmedRevenue),
|
||||
round(revenueAtRisk),
|
||||
healthy,
|
||||
summary,
|
||||
businessImpact);
|
||||
}
|
||||
|
||||
private OrderRecord store(OrderRecord order) {
|
||||
orders.put(order.orderId(), order);
|
||||
pendingReviewGauge.set((int) orders.values().stream().filter(item -> "PENDING_REVIEW".equals(item.status())).count());
|
||||
return order;
|
||||
}
|
||||
|
||||
private double round(double value) {
|
||||
return Math.round(value * 100.0) / 100.0;
|
||||
}
|
||||
}
|
||||
28
order-service/src/main/resources/application.yml
Normal file
28
order-service/src/main/resources/application.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
spring:
|
||||
application:
|
||||
name: order-service
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
services:
|
||||
inventory:
|
||||
base-url: http://localhost:8081
|
||||
payment:
|
||||
base-url: http://localhost:8082
|
||||
|
||||
demo:
|
||||
orders:
|
||||
stuck-review-mode: false
|
||||
manual-review-rate-threshold: 40
|
||||
revenue-at-risk-threshold: 5000
|
||||
118
order-service/src/main/resources/static/app.js
Normal file
118
order-service/src/main/resources/static/app.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const totalOrders = document.querySelector("#totalOrders");
|
||||
const confirmedOrders = document.querySelector("#confirmedOrders");
|
||||
const pendingOrders = document.querySelector("#pendingOrders");
|
||||
const failedOrders = document.querySelector("#failedOrders");
|
||||
const businessStatus = document.querySelector("#businessStatus");
|
||||
const manualReviewRate = document.querySelector("#manualReviewRate");
|
||||
const revenueAtRisk = document.querySelector("#revenueAtRisk");
|
||||
const confirmedRevenue = document.querySelector("#confirmedRevenue");
|
||||
const businessImpact = document.querySelector("#businessImpact");
|
||||
const serviceSignals = document.querySelector("#serviceSignals");
|
||||
const kagentActions = document.querySelector("#kagentActions");
|
||||
const ordersTable = document.querySelector("#ordersTable");
|
||||
const lastUpdated = document.querySelector("#lastUpdated");
|
||||
|
||||
document.querySelector("#refreshBtn").addEventListener("click", loadDashboard);
|
||||
document.querySelector("#normalOrderBtn").addEventListener("click", () => createOrder("4111111111111111"));
|
||||
document.querySelector("#reviewOrderBtn").addEventListener("click", () => createOrder("9999123412341234"));
|
||||
|
||||
async function createOrder(cardToken) {
|
||||
await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerId: `CUST-${Math.floor(Math.random() * 900 + 100)}`,
|
||||
sku: "LAPTOP-15",
|
||||
quantity: 1,
|
||||
amount: 1499.99,
|
||||
cardToken
|
||||
})
|
||||
});
|
||||
await loadDashboard();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const response = await fetch("/api/observability");
|
||||
const snapshot = await response.json();
|
||||
const health = snapshot.businessHealth;
|
||||
|
||||
totalOrders.textContent = health.totalOrders;
|
||||
confirmedOrders.textContent = health.confirmedOrders;
|
||||
pendingOrders.textContent = health.pendingReviewOrders;
|
||||
failedOrders.textContent = health.failedOrders;
|
||||
businessStatus.textContent = health.healthy ? "HEALTHY" : "DEGRADED";
|
||||
manualReviewRate.textContent = `${formatNumber(health.manualReviewRate)}%`;
|
||||
revenueAtRisk.textContent = formatCurrency(health.revenueAtRisk);
|
||||
confirmedRevenue.textContent = formatCurrency(health.confirmedRevenue);
|
||||
businessImpact.textContent = health.businessImpact;
|
||||
lastUpdated.textContent = new Date(snapshot.timestamp).toLocaleString();
|
||||
|
||||
serviceSignals.innerHTML = snapshot.services.map(signal => `
|
||||
<div class="signal">
|
||||
<div>
|
||||
<strong>${escapeHtml(signal.name)} <span class="muted">/ ${escapeHtml(signal.layer)}</span></strong>
|
||||
<p>${escapeHtml(signal.detail)}</p>
|
||||
</div>
|
||||
<span class="status ${statusClass(signal.status)}">${escapeHtml(signal.status)}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
kagentActions.innerHTML = snapshot.recommendedKagentActions
|
||||
.map(action => `<li>${escapeHtml(action)}</li>`)
|
||||
.join("");
|
||||
|
||||
if (snapshot.recentOrders.length === 0) {
|
||||
ordersTable.innerHTML = `<tr><td colspan="5" class="empty">No orders yet. Create a normal or review order to generate signals.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
ordersTable.innerHTML = snapshot.recentOrders.map(order => `
|
||||
<tr>
|
||||
<td>${escapeHtml(order.orderId.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(order.customerId)}</td>
|
||||
<td>${escapeHtml(order.sku)}</td>
|
||||
<td><span class="status ${statusClass(order.status)}">${escapeHtml(order.status)}</span></td>
|
||||
<td>${escapeHtml(order.reason)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const normalized = String(status).toLowerCase();
|
||||
if (normalized === "up" || normalized === "confirmed" || normalized === "healthy") {
|
||||
return "up";
|
||||
}
|
||||
if (normalized.includes("pending") || normalized.includes("degraded")) {
|
||||
return "degraded";
|
||||
}
|
||||
if (normalized === "down" || normalized === "failed") {
|
||||
return "down";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0
|
||||
}).format(Number(value ?? 0));
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2
|
||||
}).format(Number(value ?? 0));
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 10000);
|
||||
106
order-service/src/main/resources/static/index.html
Normal file
106
order-service/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kagent Demo Observability</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="topbar">
|
||||
<div>
|
||||
<p class="section-label">OKE business application</p>
|
||||
<h1>Order Flow Observability</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="refreshBtn" type="button">Refresh</button>
|
||||
<button id="normalOrderBtn" type="button">Create normal order</button>
|
||||
<button id="reviewOrderBtn" type="button" class="danger">Create review order</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<article class="metric">
|
||||
<span>Total orders</span>
|
||||
<strong id="totalOrders">0</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>Confirmed orders</span>
|
||||
<strong id="confirmedOrders">0</strong>
|
||||
</article>
|
||||
<article class="metric warning">
|
||||
<span>Pending review</span>
|
||||
<strong id="pendingOrders">0</strong>
|
||||
</article>
|
||||
<article class="metric danger">
|
||||
<span>Failed orders</span>
|
||||
<strong id="failedOrders">0</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>Business status</span>
|
||||
<strong id="businessStatus">UNKNOWN</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid business-grid">
|
||||
<article class="metric">
|
||||
<span>Manual review rate</span>
|
||||
<strong id="manualReviewRate">0%</strong>
|
||||
</article>
|
||||
<article class="metric warning">
|
||||
<span>Revenue at risk</span>
|
||||
<strong id="revenueAtRisk">$0</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>Confirmed revenue</span>
|
||||
<strong id="confirmedRevenue">$0</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>Business impact</span>
|
||||
<p id="businessImpact" class="metric-note">Waiting for data</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Service signals</h2>
|
||||
<span id="lastUpdated">Waiting for data</span>
|
||||
</div>
|
||||
<div id="serviceSignals" class="signals"></div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Kagent action view</h2>
|
||||
<span>diagnostic inputs</span>
|
||||
</div>
|
||||
<ul id="kagentActions" class="action-list"></ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Recent orders</h2>
|
||||
<span>business symptoms visible to the agent</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Customer</th>
|
||||
<th>SKU</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
273
order-service/src/main/resources/static/styles.css
Normal file
273
order-service/src/main/resources/static/styles.css
Normal file
@@ -0,0 +1,273 @@
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #eef3f8;
|
||||
--text: #15202b;
|
||||
--muted: #5f6f82;
|
||||
--line: #d8e0ea;
|
||||
--accent: #126c59;
|
||||
--accent-strong: #0f4f43;
|
||||
--warn: #a26013;
|
||||
--danger: #b42318;
|
||||
--shadow: 0 18px 45px rgba(31, 45, 61, 0.10);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e8eef6 55%, #edf5f1 100%);
|
||||
color: var(--text);
|
||||
font-family: "Aptos", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.shell {
|
||||
margin: 0 auto;
|
||||
max-width: 1180px;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.business-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric,
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 110px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.metric span,
|
||||
.panel-head span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
font-size: 34px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric-note {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.metric.warning strong {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.metric.danger strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: 1.25fr 0.75fr;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.signals {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signal {
|
||||
align-items: center;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.signal strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.signal p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
|
||||
.status.up {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.status.degraded,
|
||||
.status.down {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.status.unknown {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.action-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.action-list li {
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
min-width: 860px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user