This commit is contained in:
domenico
2025-10-24 20:55:45 +02:00
commit edb7a69bde
22 changed files with 2244 additions and 0 deletions

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# WellD challenge report
## What I did
- **Analyzed the existing Spring Boot/Thymeleaf order management application** for missing metrics.
- **Added and exposed additional custom metrics** using Micrometer and Spring Boot Actuator, making them available at `/actuator/prometheus` for Prometheus scraping.
- **Implemented the following custom metrics:**
- `orders_deleted_total`: Total orders deleted.
- `orders_created_per_product_total` (with `product` tag): Orders created per product.
- `order_quantity_average`: Distribution summary for order quantities (enables average, max, count, sum such as `order_quantity_average_sum` and `order_quantity_average_total`).
- `log_events_total` (with `level` tag): Counts of INFO and ERROR log events.
- **Ensured JVM and HTTP metrics are available** (latency, memory, CPU, threads, GC, etc.) via Actuator.
- **Created a Grafana dashboard** (see `grafana/monitoring/`) with panels for all key metrics and business KPIs.
---
## How to use the dashboard
1. Build the source code with `mvn clean package`
2. Start the stack: `docker compose up -d`
3. Access Prometheus at http://localhost:9090
4. Access Grafana at http://localhost:3000 (default login: `admin:admin`)
5. Import the JSON dashboard provided in this repository under `grafana/monitoring/`
6. Interact with the application at http://localhost:8080/web/orders , metrics will update in realtime on the dashboard.
---
## Which panels were created and their purpose
- **Total Orders Created:**
Visualizes the cumulative number of orders created (`orders_created_total`).
- **Total Orders Deleted:**
Shows the number of orders deleted (`orders_deleted_total`).
- **Orders per Product:**
Bar chart/table using `orders_created_per_product_total{product="..."}` for business insight.
- **Average Quantity per Order:**
Displays the average order quantity using `order_quantity_average_sum / order_quantity_average_count`.
- **HTTP Request Latency (p95, p99):**
Shows high-percentile request durations per endpoint using:
`histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))`
and
`histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))`
- **JVM Memory & CPU Usage:**
Monitors resource usage with `jvm_memory_used_bytes`, `process_cpu_usage`, etc.
- **Log Event Counters:**
Visualizes counts of INFO and ERROR logs (`log_events_total{level="INFO"}` and `log_events_total{level="ERROR"}`).
- **Other Stability Metrics:**
Panels for thread count, GC pause time, and queue size.
## Proposed alerts and thresholds
1. **High HTTP Latency**, threshold of `p95 > 1s for 5m`, to detect slow endpoints
2. **High ERROR log rate**, threshold of `>5 errors/min`, might indicate bugs or failures
3. **High JVM memory usage**, threshold of `>80% for 5m`, might help to prevent OOM errors
4. **High CPU usage**, threshold of `>80% for 5m`, might help to detect resource exhaustion
---
## Custom metrics and their purpose
- **orders_deleted_total:** Tracks deletions for auditing and anomaly detection.
- **orders_created_per_product_total (with optional `product` tag):** Enables product-level business insights and anomaly detection.
- **order_quantity_average:** Monitors average order size, useful for business KPIs and detecting outliers.
- **log_events_total (with `level` tag):** Provides visibility into application health and error rates.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
# Exclude grafana and target directories
grafana/
target/
# Maven
*.class
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
replay_pid*
# IDE files
.idea/
*.iml
.vscode/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
# Temporary files
*.tmp
*.temp
# Docker
.dockerignore
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

View File

@@ -0,0 +1,9 @@
FROM eclipse-temurin:17.0.15_6-jre-ubi9-minimal
WORKDIR /app
COPY target/monitoring-test-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,125 @@
# Candidate Assessment Project
## Overview
You will work on a **Java Spring Boot web application** with a **Thymeleaf frontend** for managing orders.
The application includes:
- REST endpoints (`/api/orders`) and web endpoints (`/web/orders`)
- Order creation with `id`, `productName`, and `quantity`
- Viewing and deleting orders via forms
- Metrics exposed via Spring Boot Actuator at `/actuator/prometheus`
- Application logging configured for INFO, ERROR, and DEBUG levels
---
## Environment
The **pre-configured environment is provided** with:
- The **Java application,** **Prometheus** and **Grafana** already set up inside `docker-compose.yml`.
- Prometheus scraping configurations for the application's `/actuator/prometheus` endpoint ready.
You can start the environment (after minimal manipulation) with:
```bash
docker-compose up
```
N.B.
Grafana is to be configured to use Prometheus as its data source.
---
## Task
### Monitoring and Dashboard Creation
Your task is to **create a Grafana dashboard** to monitor this application using **Prometheus** as the data source.
The dashboard should include:
- Total orders created
- Total orders deleted
- HTTP request latency (p95, p99)
- JVM memory and CPU usage
- Logged events count (INFO, ERROR)
- Any additional metrics you consider relevant for application stability and debugging
### Custom Metrics
As part of this assessment, you are **required to create and expose custom metrics** in the application. Examples of custom metrics you could implement:
- Number of orders per product
- Average quantity per order
- Custom application counters or timers useful for business KPIs
These custom metrics should be:
- Exposed via Prometheus in the application
- Documented in the deliverables
- Visualized in the Grafana dashboard
### Brief Observability Strategy
In addition to the dashboard:
- Describe which **alerts** you would configure, including thresholds and the rationale behind them.
---
## Optional Improvements
You are welcome to implement additional improvements, such as:
- Adding **tags to metrics** for richer filtering
- Improving **structured logging** to trace order flows
- Tools and approaches for **profiling and identifying performance bottlenecks**
---
## Deliverables
- Create a **Git repository**.
- Add the **Grafana dashboard JSON** ready to import under a `grafana/monitoring/` folder.
- Add your updated **Java application with custom metrics** exposed.
- Create a `README.md` describing:
- What you did
- How to use the dashboard
- Which panels were created and their purpose
- Proposed alerts and thresholds
- The custom metrics you created and their purpose
---
## Requirements
- Java 17
- Maven
- Docker and Docker Compose
---
## Objective
This assessment aims to evaluate your ability to:
- Configure effective **monitoring** on a Java web application
- Select and create **meaningful metrics** for observability and debugging
- Implement **custom Prometheus metrics** for business and technical insights
- Communicate clearly through documentation
- Apply **DevOps practices** for production stability
- Find and correct anti-patterns in the configuration
---
## Contact
For technical questions, please contact:
[attilio.gualandi@welld.ch]
---
Thank you for your time and effort on this assessment.

View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
monitoring-app:
image: monitoring-test:latest
build:
context: .
ports:
- "8080:8080"
networks:
- monitoring
prometheus:
image: prom/prometheus:v2.52.0
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- monitoring
grafana:
image: grafana/grafana:10.4.2
ports:
- "3000:3000"
networks:
- monitoring
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
networks:
monitoring:
driver: bridge

View File

@@ -0,0 +1,57 @@
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>monitoring-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<name>Monitoring Test App</name>
<description>Spring Boot application exposing Prometheus metrics for Grafana testing</description>
<properties>
<java.version>17</java.version>
<docker.image.prefix>monitoring-test</docker.image.prefix>
</properties>
<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>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,8 @@
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'monitoring-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['monitoring-app:8080']

View File

@@ -0,0 +1,20 @@
package com.example.monitoringtest;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import io.micrometer.core.instrument.MeterRegistry;
@SpringBootApplication
public class MonitoringTestApplication {
public static void main(String[] args) {
SpringApplication.run(MonitoringTestApplication.class, args);
}
@Bean
MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags("application", "monitoring-test-app");
}
}

View File

@@ -0,0 +1,41 @@
package com.example.monitoringtest.controller;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
class MonitoringController {
private final Counter helloCounter;
private final AtomicInteger queueSize;
public MonitoringController(MeterRegistry registry) {
this.helloCounter = Counter.builder("app_requests_hello_total")
.description("Total requests to /hello endpoint")
.register(registry);
this.queueSize = new AtomicInteger(0);
Gauge.builder("app_queue_size", queueSize, AtomicInteger::get)
.description("Current size of the processing queue")
.register(registry);
}
@GetMapping("/hello")
public String hello() {
helloCounter.increment();
// Simulate queue increase
queueSize.incrementAndGet();
return "Hello, Monitoring Engineer!";
}
@GetMapping("/dequeue")
public String dequeue() {
queueSize.updateAndGet(size -> size > 0 ? size - 1 : 0);
return "Dequeued one item. Current queue size: " + queueSize.get();
}
}

View File

@@ -0,0 +1,54 @@
package com.example.monitoringtest.controller;
import com.example.monitoringtest.model.Order;
import com.example.monitoringtest.service.OrderService;
import com.example.monitoringtest.metrics.OrderMetrics;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final OrderMetrics orderMetrics;
public OrderController(OrderService orderService, OrderMetrics orderMetrics) {
this.orderService = orderService;
this.orderMetrics = orderMetrics;
}
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody Order order) {
Order created = orderService.createOrder(order);
orderMetrics.incrementOrderCount();
orderMetrics.recordOrderQuantity(order.getQuantity());
orderMetrics.incrementOrderPerProduct(order.getProductName());
return ResponseEntity.ok(created);
}
@GetMapping
public ResponseEntity<List<Order>> getOrders() {
List<Order> orders = orderService.getAllOrders();
return ResponseEntity.ok(orders);
}
@GetMapping("/{id}")
public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
try {
Order order = orderService.getOrderById(id);
return ResponseEntity.ok(order);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
orderService.deleteOrder(id);
orderMetrics.incrementOrderDeletedCount();
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,57 @@
package com.example.monitoringtest.controller;
import com.example.monitoringtest.model.Order;
import com.example.monitoringtest.service.OrderService;
import com.example.monitoringtest.metrics.OrderMetrics;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/web/orders")
public class WebOrderController {
private final OrderService orderService;
private final OrderMetrics orderMetrics;
public WebOrderController(OrderService orderService, OrderMetrics orderMetrics) {
this.orderService = orderService;
this.orderMetrics = orderMetrics;
}
@GetMapping("/test")
@ResponseBody
public String test() {
System.out.println("WebOrderController.test() called");
return "Test endpoint working!";
}
@GetMapping
public String getOrders(Model model) {
System.out.println("WebOrderController.getOrders() called");
System.out.println("Returning view name: order-list");
model.addAttribute("orders", orderService.getAllOrders());
model.addAttribute("order", new Order());
return "order-list";
}
@PostMapping
public String createOrder(@ModelAttribute Order order) {
orderService.createOrder(order);
orderMetrics.incrementOrderCount();
orderMetrics.recordOrderQuantity(order.getQuantity());
orderMetrics.incrementOrderPerProduct(order.getProductName());
return "redirect:/web/orders";
}
@PostMapping("/{id}")
public String deleteOrder(@PathVariable Long id, @RequestParam("_method") String method) {
if ("delete".equalsIgnoreCase(method)) {
orderService.deleteOrder(id);
orderMetrics.incrementOrderDeletedCount();
}
return "redirect:/web/orders";
}
}

View File

@@ -0,0 +1,24 @@
package com.example.monitoringtest.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
@Component
public class LogMetrics {
private final Counter infoCounter;
private final Counter errorCounter;
public LogMetrics(MeterRegistry meterRegistry) {
this.infoCounter = meterRegistry.counter("log_events_total", "level", "INFO");
this.errorCounter = meterRegistry.counter("log_events_total", "level", "ERROR");
}
public void incrementInfo() {
infoCounter.increment();
}
public void incrementError() {
errorCounter.increment();
}
}

View File

@@ -0,0 +1,28 @@
package com.example.monitoringtest.metrics;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MetricsLogAppender extends AppenderBase<ILoggingEvent> {
private static LogMetrics logMetrics;
@Autowired
public void setLogMetrics(LogMetrics logMetrics) {
MetricsLogAppender.logMetrics = logMetrics;
}
@Override
protected void append(ILoggingEvent eventObject) {
if (logMetrics == null) return;
if (eventObject.getLevel() == Level.INFO) {
logMetrics.incrementInfo();
} else if (eventObject.getLevel() == Level.ERROR) {
logMetrics.incrementError();
}
}
}

View File

@@ -0,0 +1,42 @@
package com.example.monitoringtest.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.DistributionSummary;
@Component
public class OrderMetrics {
private final Counter orderCounter;
private final Counter orderDeletedCounter;
private final MeterRegistry meterRegistry;
private final DistributionSummary orderQuantityAverage;
public OrderMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.orderCounter = meterRegistry.counter("orders_created_total");
this.orderDeletedCounter = meterRegistry.counter("orders_deleted_total");
this.orderQuantityAverage = DistributionSummary.builder("order_quantity_average").description("Order quantity average").register(meterRegistry);
}
public void incrementOrderCount() {
orderCounter.increment();
}
public void incrementOrderDeletedCount() {
orderDeletedCounter.increment();
}
public void incrementOrderPerProduct(String productName) {
meterRegistry.counter("orders_created_per_product_total", "product", productName).increment();
}
public void recordOrderQuantity(Integer quantity) {
orderQuantityAverage.record(quantity);
}
}

View File

@@ -0,0 +1,23 @@
package com.example.monitoringtest.model;
public class Order {
private Long id;
private String productName;
private Integer quantity;
public Order() {}
public Order(Long id, String productName, Integer quantity) {
this.id = id;
this.productName = productName;
this.quantity = quantity;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

View File

@@ -0,0 +1,33 @@
package com.example.monitoringtest.repository;
import com.example.monitoringtest.model.Order;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class OrderRepository {
private final Map<Long, Order> orders = new ConcurrentHashMap<>();
private final AtomicLong counter = new AtomicLong(1);
public Order save(Order order) {
Long id = counter.getAndIncrement();
order.setId(id);
orders.put(id, order);
return order;
}
public List<Order> findAll() {
return new ArrayList<>(orders.values());
}
public Optional<Order> findById(Long id) {
return Optional.ofNullable(orders.get(id));
}
public void deleteById(Long id) {
orders.remove(id);
}
}

View File

@@ -0,0 +1,34 @@
package com.example.monitoringtest.service;
import com.example.monitoringtest.model.Order;
import com.example.monitoringtest.repository.OrderRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public Order createOrder(Order order) {
return repository.save(order);
}
public List<Order> getAllOrders() {
return repository.findAll();
}
public Order getOrderById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new RuntimeException("Order not found with id: " + id));
}
public void deleteOrder(Long id) {
repository.deleteById(id);
}
}

View File

@@ -0,0 +1,8 @@
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.prometheus.enabled=true
management.metrics.distribution.percentiles-histogram.http.server.requests=true
# Thymeleaf configuration
spring.thymeleaf.cache=false
logging.level.org.thymeleaf=INFO
logging.level.org.springframework.web=INFO

View File

@@ -0,0 +1,12 @@
<configuration>
<appender name="METRICS" class="com.example.monitoringtest.metrics.MetricsLogAppender"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="METRICS"/>
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
<rect x="4" y="4" width="56" height="56" rx="12" fill="rgba(0,0,0,0.8)"/>
<polyline points="20,34 28,42 44,26" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<title>Order Management</title>
<style>
body {
font-family: "Poppins","Segoe UI", "Roboto", sans-serif;
background: linear-gradient(135deg, #000000, #222222);
color: #f0f0f0;
margin: 0;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
letter-spacing: 0.5px;
}
h1, h2 {
color: #ffffff;
text-align: center;
margin-bottom: 1rem;
font-weight: 600;
font-style: italic;
letter-spacing: 1px;
}
p {
text-align: center;
margin-bottom: 1rem;
font-size: 1.1rem;
font-style: italic;
color: #dddddd;
}
form {
background: rgba(0, 0, 0, 0.7);
padding: 0.5rem 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
margin-bottom: 1rem;
max-width: 400px;
width: 100%;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-style: italic;
}
input::placeholder {
color: #aaaaaa;
font-style: italic;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
width: 100%;
font-style: italic;
letter-spacing: 0.8px;
}
.btn-create {
background: rgba(0, 128, 255, 0.2);
color: #8ccfff;
border: 1px solid rgba(140, 207, 255, 0.3);
}
.btn-create:hover {
background: rgba(0, 128, 255, 0.35);
transform: scale(1.02);
}
.btn-delete {
background: rgba(255, 0, 0, 0.15);
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.3);
width: auto;
padding: 0.4rem 0.8rem;
}
.btn-delete:hover {
background: rgba(255, 0, 0, 0.3);
transform: scale(1.05);
}
table {
width: 90%;
max-width: 800px;
border-collapse: collapse;
background: rgba(0, 0, 0, 0.7);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
margin-bottom: 2rem;
font-size: 0.95rem;
}
th, td {
padding: 0.6rem 0.8rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
th {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-style: italic;
}
tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.no-orders {
font-style: italic;
color: #cccccc;
padding: 1rem;
background: rgba(0, 0, 0, 0.7);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
max-width: 400px;
text-align: center;
}
.action-cell {
text-align: center;
}
</style>
</head>
<body>
<h1>Order Management</h1>
<p>Orders count: <span th:text="${orders.size()}">0</span></p>
<h2>Create New Order</h2>
<form action="/web/orders" method="post">
Product Name: <input type="text" name="productName" required placeholder="Enter product name"/><br/>
Quantity: <input type="number" name="quantity" required placeholder="Enter quantity"/><br/>
<button type="submit" class="btn-create">Create Order</button>
</form>
<h2>Current Orders</h2>
<div th:if="${orders.empty}" class="no-orders">
<p>No orders found.</p>
</div>
<table border="0" th:if="${!orders.empty}">
<thead>
<tr>
<th>ID</th>
<th>Product Name</th>
<th>Quantity</th>
<th class="action-cell">Action</th>
</tr>
</thead>
<tbody>
<tr th:each="order : ${orders}">
<td th:text="${order.id}"></td>
<td th:text="${order.productName}"></td>
<td th:text="${order.quantity}"></td>
<td class="action-cell">
<form th:action="@{/web/orders/{id}(id=${order.id})}" method="post" style="display: inline;">
<input type="hidden" name="_method" value="delete"/>
<button type="submit" class="btn-delete">Delete</button>
</form>
</td>
</tr>
</tbody>
</table>
</body>
</html>