Skip to content

Commit 269a0f7

Browse files
committed
Add circuitBreakers to UI and refactor
1 parent b121a6a commit 269a0f7

File tree

3 files changed

+178
-70
lines changed

3 files changed

+178
-70
lines changed

src/main/resources/dashboard.html

Lines changed: 122 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
.stats-grid {
2727
display: grid;
2828
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
29-
gap: 15px;
30-
margin-bottom: 20px;
29+
gap: 15px;
3130
}
3231

3332
.stat-card {
@@ -100,53 +99,118 @@
10099
border: 1px solid #f5c6cb;
101100
}
102101

103-
.auto-refresh {
104-
margin: 10px 0;
102+
.section {
103+
background: white;
104+
border-radius: 8px;
105+
padding: 20px;
106+
margin-bottom: 20px;
107+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
108+
}
109+
110+
.refresh-link {
111+
color: white;
112+
text-decoration: none;
113+
}
114+
115+
.cb-grid {
116+
display: grid;
117+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
118+
gap: 15px;
119+
}
120+
121+
.cb-item {
122+
border: 1px solid #ecf0f1;
123+
border-radius: 6px;
124+
padding: 15px;
125+
background: #fafafa;
126+
}
127+
128+
.cb-header {
129+
display: flex;
130+
justify-content: space-between;
131+
align-items: center;
132+
margin-bottom: 10px;
133+
}
134+
135+
.cb-target {
136+
font-weight: bold;
137+
}
138+
139+
.cb-status {
140+
font-size: 16px;
141+
font-weight: bold;
142+
}
143+
144+
.cb-status.closed {
145+
color: #27ae60;
146+
}
147+
148+
.cb-status.opened {
149+
color: #e74c3c;
150+
}
151+
152+
.cb-status.half-opened {
153+
color: #f39c12;
154+
}
155+
156+
.cb-metrics {
157+
font-size: 12px;
158+
color: #7f8c8d;
159+
}
160+
161+
.cb-metrics span {
162+
margin-right: 15px;
105163
}
106164
</style>
107165
</head>
108166
<body>
109167
<div class="container">
110168
<div class="header">
111-
<h1>🔄 ReverseProxy Monitor</h1>
169+
<h1><a href="#" onclick="location.reload(); return false;" class="refresh-link">🔄</a> ReverseProxy Monitor</h1>
112170
<p>Real-time traffic monitoring and statistics</p>
113171
</div>
114172

115173
<div id="connection-status" class="connection-status disconnected">
116174
🔴 Disconnected from ReverseProxy
117175
</div>
118176

119-
<div class="auto-refresh">
120-
<label><input type="checkbox" id="auto-refresh" checked> Auto-refresh every 2 seconds</label>
177+
<div class="section">
178+
<h3>🔧 Circuit Breakers</h3>
179+
<div class="cb-grid" id="cb-grid">
180+
<p>Loading circuit breaker status...</p>
181+
</div>
121182
</div>
122183

123-
<div class="stats-grid">
124-
<div class="stat-card">
125-
<div class="stat-value" id="total-requests">0</div>
126-
<div class="stat-label">Total Requests</div>
127-
</div>
128-
<div class="stat-card">
129-
<div class="stat-value" id="total-responses">0</div>
130-
<div class="stat-label">Total Responses</div>
131-
</div>
132-
<div class="stat-card">
133-
<div class="stat-value" id="error-rate">0.0%</div>
134-
<div class="stat-label">Error Rate</div>
135-
</div>
136-
<div class="stat-card">
137-
<div class="stat-value" id="avg-response-time">0ms</div>
138-
<div class="stat-label">Avg Response Time</div>
184+
<div class="section">
185+
<h3>📊 Statistics (max last 1000 requests)</h3>
186+
<div class="stats-grid">
187+
<div class="stat-card">
188+
<div class="stat-value" id="total-requests">0</div>
189+
<div class="stat-label">Total Requests</div>
190+
</div>
191+
<div class="stat-card">
192+
<div class="stat-value" id="total-responses">0</div>
193+
<div class="stat-label">Total Responses</div>
194+
</div>
195+
<div class="stat-card">
196+
<div class="stat-value" id="error-rate">0.0%</div>
197+
<div class="stat-label">Error Rate</div>
198+
</div>
199+
<div class="stat-card">
200+
<div class="stat-value" id="avg-response-time">0ms</div>
201+
<div class="stat-label">Avg Response Time</div>
202+
</div>
139203
</div>
140204
</div>
141205

142206
<div class="traffic-table">
207+
<h3>🚦 Traffic data (last 100 requests)</h3>
143208
<table>
144209
<thead>
145210
<tr>
146211
<th>Time</th>
147212
<th>Method</th>
148-
<th>Path</th>
149-
<th>Target</th>
213+
<th>Target URL</th>
150214
<th>Status</th>
151215
<th>Duration</th>
152216
<th>Correlation ID</th>
@@ -163,14 +227,13 @@ <h1>🔄 ReverseProxy Monitor</h1>
163227

164228
<script>
165229
let ws = null;
166-
let autoRefreshEnabled = true;
167230

168231
function formatTimestamp(timestamp) {
169232
return new Date(timestamp).toLocaleTimeString();
170233
}
171234

172235
function formatDuration(ms) {
173-
return ms ? ms + 'ms' : '-';
236+
return ms ? ms + ' ms' : '-';
174237
}
175238

176239
function formatStatus(status) {
@@ -184,6 +247,36 @@ <h1>🔄 ReverseProxy Monitor</h1>
184247
document.getElementById('total-responses').textContent = stats.totalResponses;
185248
document.getElementById('error-rate').textContent = (stats.errorRate * 100).toFixed(1) + '%';
186249
document.getElementById('avg-response-time').textContent = Math.round(stats.avgResponseTime) + 'ms';
250+
updateCircuitBreakers(stats.circuitBreakers);
251+
}
252+
253+
function updateCircuitBreakers(circuitBreakers) {
254+
const container = document.getElementById('cb-grid');
255+
256+
if (circuitBreakers.length === 0) {
257+
container.innerHTML = '<p>No circuit breakers configured</p>';
258+
return;
259+
}
260+
261+
const items = circuitBreakers.map(cb => {
262+
const lastFailure = cb.lastFailure
263+
? new Date(cb.lastFailure).toLocaleString()
264+
: '-';
265+
266+
return `
267+
<div class="cb-item">
268+
<div class="cb-header">
269+
<span class="cb-target">${cb.target}</span>
270+
<span class="cb-status ${cb.state.toLowerCase()}">${cb.state}</span>
271+
</div>
272+
<div class="cb-metrics">
273+
<span>Last Failure: ${lastFailure}</span>
274+
</div>
275+
</div>
276+
`;
277+
}).join('');
278+
279+
container.innerHTML = items;
187280
}
188281

189282
function updateTrafficTable(traffic) {
@@ -193,15 +286,14 @@ <h1>🔄 ReverseProxy Monitor</h1>
193286
return;
194287
}
195288

196-
const rows = traffic.slice(-100).reverse().map(entry => {
289+
const rows = traffic.reverse().map(entry => {
197290
const req = entry.request;
198291
const resp = entry.response;
199292
return `
200293
<tr>
201294
<td>${formatTimestamp(req.timestamp)}</td>
202295
<td>${req.method}</td>
203-
<td>${req.uri}</td>
204-
<td>${req.targetHost}</td>
296+
<td>${req.targetUrl}</td>
205297
<td>${formatStatus(resp ? resp.status : null)}</td>
206298
<td>${formatDuration(entry.duration)}</td>
207299
<td>${req.correlationId}</td>
@@ -261,19 +353,13 @@ <h1>🔄 ReverseProxy Monitor</h1>
261353
.catch(error => console.error('Error loading stats:', error));
262354
}
263355

264-
// Auto-refresh toggle
265-
document.getElementById('auto-refresh').addEventListener('change', function () {
266-
autoRefreshEnabled = this.checked;
267-
});
268356

269357
// Auto-refresh interval
270358
setInterval(() => {
271-
if (autoRefreshEnabled) {
272359
loadTrafficData();
273360
if (!ws || ws.readyState !== WebSocket.OPEN) {
274361
loadStats();
275362
}
276-
}
277363
}, 2000);
278364

279365
// Initial load

src/main/scala/akkahttp/ReverseProxy.scala

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import org.bouncycastle.util.encoders.Hex
2020
import org.slf4j.{Logger, LoggerFactory}
2121

2222
import java.security.MessageDigest
23-
import java.util.concurrent.ConcurrentHashMap
2423
import java.util.concurrent.atomic.AtomicInteger
24+
import java.util.concurrent.{ConcurrentHashMap, ThreadLocalRandom}
2525
import scala.collection.parallel.CollectionConverters.ImmutableIterableIsParallelizable
2626
import scala.concurrent.*
2727
import scala.concurrent.duration.DurationInt
@@ -47,12 +47,11 @@ import scala.util.{Failure, Success}
4747
*
4848
* Remarks:
4949
* - The target server selection is via the "Host" HTTP header
50-
* - Local/Remote target servers are designed to be flaky to show Retry/CircuitBreaker behavior
51-
* e.g. for Local adjust [[responseCodes]]
50+
* - Local/Remote target servers are designed to be faulty to show Retry/CircuitBreaker behavior
51+
* e.g. for mode Local adjust [[responseCodes]]
5252
* - On top of the built-in client, you may also try other clients, see below
53-
* - This PoC may not scale well, possible bottlenecks are:
54-
* - Combination of CircuitBreaker which wraps Retry
55-
* - Round robin impl. with `requestCounter` means shared state
53+
* - This PoC may not scale well, because the 'round robin' implementation
54+
* with `requestCounter` means shared state
5655
*
5756
* Gatling client: [[ReverseProxySimulation]]
5857
*
@@ -77,8 +76,6 @@ object ReverseProxy extends App {
7776

7877
val http: HttpExt = Http(system)
7978

80-
ReverseProxyMonitor.initializeWebUI(system)
81-
8279
val circuitBreakers = new ConcurrentHashMap[String, CircuitBreaker]()
8380
val requestCounter = new AtomicInteger(0)
8481

@@ -98,14 +95,19 @@ object ReverseProxy extends App {
9895
)
9996
)
10097

101-
// For Mode.local: Adjust to provoke more retries on ReverseProxy
102-
val responseCodes = List(200, 200, 200, 200, 200, 200, 200, 200, 500, 503)
98+
// For Mode.local: Add more failure response codes to provoke more retries on ReverseProxy
99+
// and thus provoke the CircuitBreaker to open
100+
//val responseCodes = List(200, 200, 200, 200, 200, 200, 200, 200, 500, 503)
101+
val responseCodes = List(200, 200, 500, 500, 500, 500, 503, 503, 503, 503)
103102

104103
localTargetServers(maxConnections = 100) // 1-1024
105104
reverseProxy()
106-
// Switch Mode to let ReverseProxy forward client requests to local/remote target server(s)
105+
106+
// Switch mode to let ReverseProxy forward client requests to local/remote target server(s)
107107
// Note that the remote servers can not interpret the X-Correlation-ID header
108-
clients(nbrOfClients = 10, requestsPerClient = 10, Mode.local)
108+
val mode = Mode.remote
109+
clients(nbrOfClients = 10, requestsPerClient = 10, mode)
110+
ReverseProxyMonitor.initializeWebUI(system, services(mode))
109111

110112
sys.addShutdownHook {
111113
ReverseProxyMonitor.shutdown()
@@ -131,7 +133,7 @@ object ReverseProxy extends App {
131133
}
132134

133135
Source(1 to nbrOfRequests)
134-
.throttle(1, 1.second, 10, ThrottleMode.shaping)
136+
.throttle(1, 2.seconds, 10, ThrottleMode.shaping)
135137
.wireTap(each => logger.info(s"Client: $clientId about to send request with id: $clientId-$each..."))
136138
.mapAsync(1)(each => http.singleRequest(HttpRequest(uri = s"http://$proxyHost:$proxyPort/$fixedPath")
137139
.withHeaders(Seq(RawHeader("Host", targetHost.toString), RawHeader("X-Correlation-ID", s"$clientId-$each")))))
@@ -162,7 +164,6 @@ object ReverseProxy extends App {
162164
val mode = Mode.values.find(_.toString == host).getOrElse(Mode.local)
163165
val id = request.getHeader("X-Correlation-ID").orElse(RawHeader("X-Correlation-ID", "N/A")).value()
164166

165-
val requestId = ReverseProxyMonitor.logRequest(request, host, id)
166167
val startTime = System.currentTimeMillis()
167168

168169
def headers(target: Target) = {
@@ -205,20 +206,19 @@ object ReverseProxy extends App {
205206
val target = seq(index)
206207
logger.info(s"Forwarding request with id: $id to $mode target server: ${target.url}")
207208

209+
val requestId = ReverseProxyMonitor.logRequest(request, target.url, id)
210+
208211
val circuitBreaker = circuitBreakers.computeIfAbsent(target.url, _ => {
209212
val cb = new CircuitBreaker(
210213
system.scheduler,
211-
// Account for retry attempts
212-
// A lower value opens the circuit breaker for subsequent requests (until resetTimeout)
213214
maxFailures = 5,
214215
// Needs to be shorter than pekko-http 'request-timeout' (20s)
215216
// If not, clients get 503 from pekko-http
216-
callTimeout = 15.seconds,
217-
resetTimeout = 10.seconds)
218-
// For yet unknown reasons these callbacks are not executed
219-
cb.onOpen { () => logger.info(s"CircuitBreaker OPENED for ${target.url}") }
220-
cb.onClose { () => logger.info(s"CircuitBreaker CLOSED for ${target.url}") }
221-
cb.onHalfOpen { () => logger.info(s"CircuitBreaker HALF-OPEN for ${target.url}") }
217+
callTimeout = 5.seconds,
218+
resetTimeout = 5.seconds)
219+
cb.onOpen(ReverseProxyMonitor.logCircuitBreakerEvent(target.url, "OPENED"))
220+
cb.onClose(ReverseProxyMonitor.logCircuitBreakerEvent(target.url, "CLOSED"))
221+
cb.onHalfOpen(ReverseProxyMonitor.logCircuitBreakerEvent(target.url, "HALF-OPENED"))
222222
cb
223223
})
224224

@@ -248,17 +248,18 @@ object ReverseProxy extends App {
248248
case Failure(exception) =>
249249
val responseTime = System.currentTimeMillis() - startTime
250250
val errorResponse = exception match {
251-
case _: CircuitBreakerOpenException => BadGateway(id, "Circuit breaker opened")
251+
case e: CircuitBreakerOpenException => BadGateway(id, e.getMessage)
252252
case _: TimeoutException => GatewayTimeout(id)
253253
case e => BadGateway(id, e.getMessage)
254254
}
255255
ReverseProxyMonitor.logResponse(requestId, errorResponse, responseTime, id, Some(exception.getMessage))
256256
}.recover {
257-
case _: CircuitBreakerOpenException => BadGateway(id, "Circuit breaker opened")
257+
case e: CircuitBreakerOpenException => BadGateway(id, e.getMessage)
258258
case _: TimeoutException => GatewayTimeout(id)
259259
case e => BadGateway(id, e.getMessage)
260260
}
261261
case None =>
262+
val requestId = ReverseProxyMonitor.logRequest(request, host, id)
262263
val notFoundResponse = NotFound(id, host)
263264
val responseTime = System.currentTimeMillis() - startTime
264265
ReverseProxyMonitor.logResponse(requestId, notFoundResponse, responseTime, id, Some("Host not found"))
@@ -281,7 +282,7 @@ object ReverseProxy extends App {
281282
val echoRoute: Route =
282283
extractRequest { request =>
283284
complete {
284-
Thread.sleep(500)
285+
Thread.sleep(ThreadLocalRandom.current.nextInt(1, 20) * 100)
285286
val id = request.getHeader("X-Correlation-ID").orElse(RawHeader("X-Correlation-ID", "N/A")).value()
286287

287288
val randomResponseCode = responseCodes(new scala.util.Random().nextInt(responseCodes.length))

0 commit comments

Comments
 (0)