Browse Source

initial commit

pull/1/head
Stephanie Gredell 7 months ago
parent
commit
d6246545d7
  1. 315
      cost.py
  2. 765
      game.html
  3. 286
      internals/simulation/simulation.go
  4. 140
      main.go
  5. 1051
      old.html
  6. 1047
      static/index.html

315
cost.py

@ -0,0 +1,315 @@ @@ -0,0 +1,315 @@
from typing import NamedTuple, Dict, Tuple
from enum import Enum
class LoadBalancerSpec(NamedTuple):
capacity: float # e.g. float('inf')
baseLatency: int # ms
cost: int
class WebServerSmall(NamedTuple):
capacity: int
baseLatency: int
penaltyPerRPS: float
cost: int
class WebServerMedium(NamedTuple):
capacity: int
baseLatency: int
penaltyPerRPS: float
cost: int
class CacheStandard(NamedTuple):
capacity: int
baseLatency: int
penaltyPer10RPS: float
hitRates: Dict[str, float]
cost: int
class CacheLarge(NamedTuple):
capacity: int
baseLatency: int
penaltyPer10RPS: float
hitRates: Dict[str, float]
cost: int
class DbReadReplica(NamedTuple):
readCapacity: int # RPS
baseReadLatency: int # ms
penaltyPer10RPS: float
cost: int
class ComponentSpec(NamedTuple):
loadBalancer: LoadBalancerSpec
webServerSmall: WebServerSmall
webServerMedium: WebServerMedium
cacheStandard: CacheStandard
cacheLarge: CacheLarge
dbReadReplica: DbReadReplica
class Design(NamedTuple):
numWebServerSmall: int
numWebServerMedium: int
cacheType: str # Either "cacheStandard" or "cacheLarge"
cacheTTL: str
numDbReplicas: int
promotionDelaySeconds: int
class Level(NamedTuple):
id: int
description: str
targetRPS: int
maxP95Latency: int
maxMonthlyCost: int
requiredAvailability: int
failureEvents: list
componentSpec: ComponentSpec
simulatedDurationSeconds: int
class CacheType(Enum):
STANDARD = "cacheStandard"
LARGE = "cacheLarge"
class LevelSimulator:
def __init__(self, level: Level, design: Design):
self.level = level
self.design = design
self.specs = self.level.componentSpec
def compute_cost(self) -> int:
s = self.specs
d = self.design
cost_lb = s.loadBalancer.cost
cost_ws_small = d.numWebServerSmall * s.webServerSmall.cost
cost_ws_medium = d.numWebServerMedium * s.webServerMedium.cost
if d.cacheType == CacheType.STANDARD.value:
cost_cache = s.cacheStandard.cost
else:
cost_cache = s.cacheLarge.cost
# “1” here stands for the master; add d.numDbReplicas for replicas
cost_db = s.dbReadReplica.cost * (1 + d.numDbReplicas)
return cost_lb + cost_ws_small + cost_ws_medium + cost_cache + cost_db
def compute_rps(self) -> Tuple[float, float]:
"""
Returns (hits_rps, misses_rps) for a read workload of size level.targetRPS.
"""
s = self.specs
d = self.design
total_rps = self.level.targetRPS
if d.cacheType == CacheType.STANDARD.value:
hit_rate = s.cacheStandard.hitRates[d.cacheTTL]
else:
hit_rate = s.cacheLarge.hitRates[d.cacheTTL]
hits_rps = total_rps * hit_rate
misses_rps = total_rps * (1 - hit_rate)
return hits_rps, misses_rps
def compute_latencies(self) -> Dict[str, float]:
"""
Computes:
- L95_ws (worst P95 among small/medium, given misses_rps)
- L95_cache (baseLatency)
- L95_db_read (based on misses_rps and replicas)
- L95_total_read = miss_path (since misses are slower)
"""
s = self.specs
d = self.design
# 1) First compute hits/misses
_, misses_rps = self.compute_rps()
# 2) Web server P95
cap_small = s.webServerSmall.capacity
cap_medium = s.webServerMedium.capacity
weighted_count = d.numWebServerSmall + (2 * d.numWebServerMedium)
if weighted_count == 0:
L95_ws = float("inf")
else:
load_per_weighted = misses_rps / weighted_count
L95_ws_small = 0.0
if d.numWebServerSmall > 0:
if load_per_weighted <= cap_small:
L95_ws_small = s.webServerSmall.baseLatency
else:
L95_ws_small = (
s.webServerSmall.baseLatency
+ s.webServerSmall.penaltyPerRPS
* (load_per_weighted - cap_small)
)
L95_ws_medium = 0.0
# <<== FIXED: change “> 00” to “> 0”
if d.numWebServerMedium > 0:
if load_per_weighted <= cap_medium:
L95_ws_medium = s.webServerMedium.baseLatency
else:
L95_ws_medium = (
s.webServerMedium.baseLatency
+ s.webServerMedium.penaltyPerRPS
* (load_per_weighted - cap_medium)
)
L95_ws = max(L95_ws_small, L95_ws_medium)
# 3) Cache P95
if d.cacheType == CacheType.STANDARD.value:
L95_cache = s.cacheStandard.baseLatency
else:
L95_cache = s.cacheLarge.baseLatency
# 4) DB read P95
read_cap = s.dbReadReplica.readCapacity
base_read_lat = s.dbReadReplica.baseReadLatency
pen_per10 = s.dbReadReplica.penaltyPer10RPS
num_reps = d.numDbReplicas
if num_reps == 0:
if misses_rps <= read_cap:
L95_db_read = base_read_lat
else:
excess = misses_rps - read_cap
L95_db_read = base_read_lat + pen_per10 * (excess / 10.0)
else:
load_per_rep = misses_rps / num_reps
if load_per_rep <= read_cap:
L95_db_read = base_read_lat
else:
excess = load_per_rep - read_cap
L95_db_read = base_read_lat + pen_per10 * (excess / 10.0)
# 5) End-to-end P95 read = miss_path
L_lb = s.loadBalancer.baseLatency
miss_path = L_lb + L95_ws + L95_db_read
L95_total_read = miss_path
return {
"L95_ws": L95_ws,
"L95_cache": L95_cache,
"L95_db_read": L95_db_read,
"L95_total_read": L95_total_read,
}
def compute_availability(self) -> float:
"""
If failureEvents=[], just return 100.0.
Otherwise:
- For each failure (e.g. DB master crash at t_crash),
if numDbReplicas==0 downtime = sim_duration - t_crash
else if design has auto_failover:
downtime = failover_delay
else:
downtime = sim_duration - t_crash
- availability = (sim_duration - total_downtime) / sim_duration * 100
"""
sim_duration = self.level.simulatedDurationSeconds # you’d need this field
total_downtime = 0
for event in self.level.failureEvents:
t_crash = event["time"]
if event["type"] == "DB_MASTER_CRASH":
if self.design.numDbReplicas == 0:
total_downtime += (sim_duration - t_crash)
else:
# assume a fixed promotion delay (e.g. 5s)
delay = self.design.promotionDelaySeconds
total_downtime += delay
# (handle other event types if needed)
return (sim_duration - total_downtime) / sim_duration * 100
def validate(self) -> dict:
"""
1) Cost check
2) Throughput checks (cache, DB, WS)
3) Latency check
4) Availability check (if there are failureEvents)
Return { "pass": True, "metrics": {...} } or { "pass": False, "reason": "..." }.
"""
total_cost = self.compute_cost()
if total_cost > self.level.maxMonthlyCost:
return { "pass": False, "reason": f"Budget ${total_cost} > ${self.level.maxMonthlyCost}" }
hits_rps, misses_rps = self.compute_rps()
# Cache capacity
cache_cap = (
self.specs.cacheStandard.capacity
if self.design.cacheType == CacheType.STANDARD.value
else self.specs.cacheLarge.capacity
)
if hits_rps > cache_cap:
return { "pass": False, "reason": f"Cache overloaded ({hits_rps:.1f} RPS > {cache_cap})" }
# DB capacity
db_cap = self.specs.dbReadReplica.readCapacity
if self.design.numDbReplicas == 0:
if misses_rps > db_cap:
return { "pass": False, "reason": f"DB overloaded ({misses_rps:.1f} RPS > {db_cap})" }
else:
per_rep = misses_rps / self.design.numDbReplicas
if per_rep > db_cap:
return {
"pass": False,
"reason": f"DB replicas overloaded ({per_rep:.1f} RPS/replica > {db_cap})"
}
# WS capacity
total_ws_cap = (
self.design.numWebServerSmall * self.specs.webServerSmall.capacity
+ self.design.numWebServerMedium * self.specs.webServerMedium.capacity
)
if misses_rps > total_ws_cap:
return {
"pass": False,
"reason": f"Web servers overloaded ({misses_rps:.1f} RPS > {total_ws_cap})"
}
# Latency
lat = self.compute_latencies()
if lat["L95_total_read"] > self.level.maxP95Latency:
return {
"pass": False,
"reason": f"P95 too high ({lat['L95_total_read']:.1f} ms > {self.level.maxP95Latency} ms)"
}
# Availability (only if failureEvents is nonempty)
availability = 100.0
if self.level.failureEvents:
availability = self.compute_availability()
if availability < self.level.requiredAvailability:
return {
"pass": False,
"reason": f"Availability too low ({availability:.1f}% < "
f"{self.level.requiredAvailability}%)"
}
# If we reach here, all checks passed
return {
"pass": True,
"metrics": {
"cost": total_cost,
"p95": lat["L95_total_read"],
"achievedRPS": self.level.targetRPS,
"availability": (
100.0 if not self.level.failureEvents else availability
)
}
}

765
game.html

@ -0,0 +1,765 @@ @@ -0,0 +1,765 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Design Canvas Game</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
height: 100vh;
font-family: 'Fira Code', monospace;
background-color: #1e1e1e;
color: #ccc;
}
#sidebar {
width: 280px;
background-color: #111;
border-right: 1px solid #333;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.component-icon, #arrow-tool {
padding: 10px 12px;
background-color: #1e1e1e;
border: 1px solid #444;
border-radius: 4px;
text-align: center;
cursor: grab;
user-select: none;
font-size: 13px;
transition: background-color 0.1s ease;
color: #eee;
}
.component-icon:hover, #arrow-tool:hover {
background-color: #2a2a2a;
}
.component-icon:active, #arrow-tool:active {
cursor: grabbing;
}
#arrow-tool.active {
background-color: #005f87;
color: white;
border-color: #007acc;
}
#canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #202020;
}
#canvas {
width: 100%;
height: 100%;
}
.dropped {
cursor: move;
}
.dropped.selected rect {
stroke: #00bcd4;
stroke-width: 2;
}
#run-button {
margin-top: auto;
padding: 10px;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#run-button:disabled {
background-color: #555;
cursor: not-allowed;
}
#node-props-panel {
position: absolute;
top: 20px;
right: 20px;
width: 220px;
background-color: #111;
border: 1px solid #444;
border-radius: 4px;
padding: 12px;
color: #eee;
box-shadow: 0 0 10px rgba(0,0,0,0.6);
display: none;
z-index: 10;
}
#node-props-panel h3 {
margin-top: 0;
font-size: 15px;
color: #ccc;
}
.prop-group {
display: none;
margin-bottom: 12px;
}
.prop-group label, .prop-group input {
display: block;
width: 100%;
margin-top: 6px;
font-size: 13px;
}
input[type="text"],
input[type="number"] {
padding: 6px;
background-color: #222;
border: 1px solid #555;
color: #eee;
border-radius: 4px;
font-family: 'Fira Code', monospace;
}
#node-props-panel button {
padding: 8px;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 8px;
}
#node-props-panel button:disabled {
background-color: #555;
cursor: not-allowed;
}
#info-panel {
position: absolute;
top: 1rem;
right: 1rem;
background: #121212;
color: #ccc;
padding: 1rem;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
min-width: 220px;
z-index: 10;
border: 1px solid #333;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
#constraints-panel,
#score-panel {
margin-bottom: 1rem;
}
.panel-title {
font-weight: bold;
color: #fff;
font-size: 15px;
margin-bottom: 0.5rem;
}
.panel-metric {
margin-bottom: 0.4rem;
}
.panel-metric .label {
display: inline-block;
width: 140px;
color: #888;
}
.component-icon {
position: relative;
padding: 8px;
margin-bottom: 6px;
background: #1e1e1e;
color: white;
border: 1px solid #444;
border-radius: 6px;
cursor: grab;
}
.component-icon .tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
top: 100%;
left: 0;
z-index: 10;
background: #333;
color: #fff;
padding: 6px 8px;
border-radius: 4px;
white-space: nowrap;
font-size: 12px;
line-height: 1.4;
margin-top: 4px;
transition: opacity 0.2s;
}
.component-icon:hover .tooltip {
visibility: visible;
opacity: 1;
}
.component-icon.dragging .tooltip {
display: none;
}
</style>
</head>
<body>
<div id="info-panel">
<div id="constraints-panel">
<div class="panel-title">Level Constraints</div>
<div class="panel-metric"><span class="label">🎯 Target RPS:</span> <span id="constraint-rps"></span></div>
<div class="panel-metric"><span class="label"> Max P95 Latency:</span> <span id="constraint-latency"></span></div>
<div class="panel-metric"><span class="label">💸 Max Cost:</span> <span id="constraint-cost"></span></div>
<div class="panel-metric"><span class="label">🔒 Availability:</span> <span id="constraint-availability"></span></div>
</div>
<div id="score-panel">
<div class="panel-title">Simulation Results</div>
<div class="panel-metric"><span class="label">✅ Cost:</span> <span id="score-cost"></span></div>
<div class="panel-metric"><span class="label">⚡ P95 Latency:</span> <span id="score-p95"></span></div>
<div class="panel-metric"><span class="label">📈 Achieved RPS:</span> <span id="score-rps"></span></div>
<div class="panel-metric"><span class="label">🛡 Availability:</span> <span id="score-availability"></span></div>
</div>
</div>
<div id="sidebar">
<div class="component-icon" draggable="true" data-type="Client">
Client/User
<span class="tooltip">Simulates user traffic</span>
</div>
<div class="component-icon" draggable="true" data-type="LoadBalancer">
Load Balancer
<span class="tooltip">Cost: $5/mo<br>Distributes traffic evenly<br>Latency: 5 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="WebServerSmall">
Web Server (Small)
<span class="tooltip">Cost: $10/mo<br>Capacity: 100 RPS<br>Base Latency: 50 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="WebServerMedium">
Web Server (Medium)
<span class="tooltip">Cost: $20/mo<br>Capacity: 200 RPS<br>Base Latency: 40 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="Database">
Database
<span class="tooltip">Cost: $20/mo<br>Read Capacity: 150 RPS<br>Base Latency: 80 ms<br>Supports replication</span>
</div>
<div class="component-icon" draggable="true" data-type="CacheStandard">
Cache (Standard)
<span class="tooltip">Cost: $10/mo<br>Capacity: 100 RPS<br>Latency: 5 ms<br>80% hit rate with 1hr TTL</span>
</div>
<div class="component-icon" draggable="true" data-type="CacheLarge">
Cache (Large)
<span class="tooltip">Cost: $20/mo<br>Capacity: 200 RPS<br>Latency: 5 ms<br>Higher hit rate for large datasets</span>
</div>
<div class="component-icon" draggable="true" data-type="MessageQueue">
Message Queue
<span class="tooltip">Cost: $15/mo<br>Decouples components<br>Useful for batching writes</span>
</div>
<div class="component-icon" draggable="true" data-type="CDN">
CDN/Edge Cache
<span class="tooltip">Cost: $0.03/GB<br>Improves global latency<br>Caches static content</span>
</div>
<div class="component-icon" draggable="true" data-type="Microservice">
Microservice Node
<span class="tooltip">Cost: $10/mo<br>Stateless container<br>Use for modular logic</span>
</div>
<div class="component-icon" draggable="true" data-type="DataPipeline">
Data Pipeline
<span class="tooltip">Cost: $25/mo<br>Stream or batch processing<br>Used for analytics / ETL</span>
</div>
<div class="component-icon" draggable="true" data-type="Monitoring">
Monitoring/Alerting
<span class="tooltip">Cost: $5/mo<br>Health checks + logs<br>Alerts on failures</span>
</div>
<div class="component-icon" draggable="true" data-type="ThirdParty">
Third-Party Service
<span class="tooltip">External APIs<br>Latency + cost vary<br>Examples: Payment, Email, Search</span>
</div>
<div id="arrow-tool">Arrow Tool</div>
<button id="run-button" disabled>Run Simulation</button>
</div>
<div id="canvas-container">
<svg id="canvas">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#333" />
</marker>
</defs>
</svg>
<div id="node-props-panel">
<h3>Node Properties</h3>
<div id="label-group">
<label>Label:<input type="text" name="label" /></label>
</div>
<div id="db-group" class="prop-group">
<label>Replication Factor:<input type="number" name="replication" min="1" step="1" /></label>
</div>
<div id="cache-group" class="prop-group">
<label>Cache TTL (secs):<input type="number" name="cacheTTL" min="0" step="60" /></label>
</div>
<button id="node-props-save" disabled>Save</button>
</div>
</div>
<script>
let nodeIdCounter = 1;
function generateNodeId() {
return `node-${nodeIdCounter++}`;
}
function createSVGElement(tag, attrs) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (let key in attrs) {
elem.setAttribute(key, attrs[key]);
}
return elem;
}
class Node {
constructor(type, x, y, app) {
this.id = generateNodeId();
this.type = type;
this.app = app;
this.props = {
label: type,
replication: 1,
cacheTTL: 0
};
this.group = createSVGElement('g', { class: 'dropped', 'data-type': type });
const rect = createSVGElement('rect', {
x, y,
width: 0,
height: app.componentSize.height,
fill: '#e0e0e0',
stroke: '#333',
'stroke-width': 1,
rx: 4, ry: 4
});
this.group.appendChild(rect);
this.text = createSVGElement('text', {
x: x + app.componentSize.width / 2,
y: y + app.componentSize.height / 2 + 5,
'text-anchor': 'middle',
'font-size': 14,
fill: '#000'
});
this.text.textContent = this.props.label;
this.app.canvas.appendChild(this.text); // temporarily append to measure
const textWidth = this.text.getBBox().width;
const padding = 20;
const finalWidth = textWidth + padding;
rect.setAttribute('width', finalWidth);
this.text.setAttribute('x', x + finalWidth / 2);
this.group.appendChild(this.text);
this.group.__nodeObj = this;
this.initDrag();
this.group.addEventListener('click', (e) => {
e.stopPropagation();
if (app.arrowMode) {
app.handleConnectionClick(this);
} else {
app.clearSelection();
this.select();
app.showPropsPanel(this);
}
});
app.canvas.appendChild(this.group);
app.placedComponents.push(this);
app.runButton.disabled = false;
}
initDrag() {
this.id = generateNodeId();
let offsetX, offsetY;
const onMouseMove = (e) => {
const pt = this.app.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.app.canvas.getScreenCTM().inverse());
const newX = svgP.x - offsetX;
const newY = svgP.y - offsetY;
this.group.setAttribute('transform', `translate(${newX}, ${newY})`);
this.app.connections.forEach(conn => {
if (conn.start === this || conn.end === this) {
conn.updatePosition();
}
});
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
this.group.addEventListener('mousedown', (e) => {
e.preventDefault();
const pt = this.app.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.app.canvas.getScreenCTM().inverse());
const ctm = this.group.getCTM();
offsetX = svgP.x - ctm.e;
offsetY = svgP.y - ctm.f;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
}
updateLabel(newLabel) {
this.props.label = newLabel;
this.text.textContent = newLabel;
const textWidth = this.text.getBBox().width;
const padding = 20;
const finalWidth = textWidth + padding;
this.group.querySelector('rect').setAttribute('width', finalWidth);
this.text.setAttribute('x', parseFloat(this.group.querySelector('rect').getAttribute('x')) + finalWidth / 2);
}
getCenter() {
const bbox = this.group.getBBox();
const ctm = this.group.getCTM();
const x = ctm.e + bbox.x + bbox.width / 2;
const y = ctm.f + bbox.y + bbox.height / 2;
return { x, y };
}
select() {
this.app.clearSelection();
this.group.classList.add('selected');
this.app.selectedNode = this;
}
deselect() {
this.group.classList.remove('selected');
if (this.app.selectedNode === this) {
this.app.selectedNode = null;
}
}
}
class Connection {
constructor(startNode, endNode, label, app) {
this.start = startNode;
this.end = endNode;
this.app = app;
this.line = createSVGElement('line', {
stroke: '#333', 'stroke-width': 2, 'marker-end': 'url(#arrowhead)'
});
this.text = createSVGElement('text', {
'text-anchor': 'middle', 'font-size': 12, fill: '#333'
});
this.text.textContent = label;
app.canvas.appendChild(this.line);
app.canvas.appendChild(this.text);
this.updatePosition();
this.selected = false;
this.line.addEventListener('click', (e) => {
e.stopPropagation();
this.app.clearSelection();
this.select();
});
}
updatePosition() {
const s = this.start.getCenter();
const e = this.end.getCenter();
this.line.setAttribute('x1', s.x);
this.line.setAttribute('y1', s.y);
this.line.setAttribute('x2', e.x);
this.line.setAttribute('y2', e.y);
const midX = (s.x + e.x) / 2;
const midY = (s.y + e.y) / 2;
this.text.setAttribute('x', midX);
this.text.setAttribute('y', midY - 5);
}
select() {
this.app.clearSelection();
this.selected = true;
this.line.setAttribute('stroke', '#007bff');
this.line.setAttribute('stroke-width', 3);
this.app.selectedConnection = this;
}
deselect() {
this.selected = false;
this.line.setAttribute('stroke', '#333');
this.line.setAttribute('stroke-width', 2);
}
}
class CanvasApp {
constructor() {
this.placedComponents = [];
this.connections = [];
this.componentSize = { width: 120, height: 40 };
this.arrowMode = false;
this.connectionStart = null;
this.activeNode = null;
this.selectedConnection = null;
this.sidebar = document.getElementById('sidebar');
this.arrowTool = document.getElementById('arrow-tool');
this.canvasContainer = document.getElementById('canvas-container');
this.canvas = document.getElementById('canvas');
this.runButton = document.getElementById('run-button');
this.nodePropsPanel = document.getElementById('node-props-panel');
this.propsSaveBtn = document.getElementById('node-props-save');
this.labelGroup = document.getElementById('label-group');
this.dbGroup = document.getElementById('db-group');
this.cacheGroup = document.getElementById('cache-group');
this.selectedNode = null;
this.initEventHandlers();
}
initEventHandlers() {
this.arrowTool.addEventListener('click', () => {
this.arrowMode = !this.arrowMode;
if (this.arrowMode) {
this.arrowTool.classList.add('active');
this.hidePropsPanel();
} else {
this.arrowTool.classList.remove('active');
if (this.connectionStart) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
}
});
this.sidebar.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('component-icon')) {
e.dataTransfer.setData('text/plain', e.target.getAttribute('data-type'));
e.target.classList.add('dragging');
}
});
this.sidebar.addEventListener('dragend', (e) => {
if (e.target.classList.contains('component-icon')) {
e.target.classList.remove('dragging');
}
});
this.canvasContainer.addEventListener('dragover', (e) => e.preventDefault());
this.canvasContainer.addEventListener('drop', (e) => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
const pt = this.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.canvas.getScreenCTM().inverse());
const x = svgP.x - this.componentSize.width / 2;
const y = svgP.y - this.componentSize.height / 2;
new Node(type, x, y, this);
});
this.runButton.addEventListener('click', () => {
const designData = this.exportDesign();
alert(JSON.stringify(designData));
});
this.canvas.addEventListener('click', () => {
if (this.connectionStart) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
this.hidePropsPanel();
this.clearSelection();
});
this.propsSaveBtn.addEventListener('click', () => {
if (!this.activeNode) return;
const nodeObj = this.activeNode;
const panel = this.nodePropsPanel;
const newLabel = panel.querySelector("input[name='label']").value;
nodeObj.updateLabel(newLabel);
if (nodeObj.type === 'Database') {
nodeObj.props.replication = parseInt(panel.querySelector("input[name='replication']").value, 10);
}
if (nodeObj.type === 'CacheStandard' || nodeObj.type === 'CacheLarge') {
nodeObj.props.cacheTTL = parseInt(panel.querySelector("input[name='cacheTTL']").value, 10);
}
this.hidePropsPanel();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
if (this.selectedConnection) {
this.canvas.removeChild(this.selectedConnection.line);
this.canvas.removeChild(this.selectedConnection.text);
const index = this.connections.indexOf(this.selectedConnection);
if (index !== -1) this.connections.splice(index, 1);
this.selectedConnection = null;
} else if (this.selectedNode) {
this.canvas.removeChild(this.selectedNode.group);
this.placedComponents = this.placedComponents.filter(n => n !== this.selectedNode);
this.connections = this.connections.filter(conn => {
if (conn.start === this.selectedNode || conn.end === this.selectedNode) {
this.canvas.removeChild(conn.line);
this.canvas.removeChild(conn.text);
return false;
}
return true;
});
this.selectedNode = null;
this.activeNode = null;
this.hidePropsPanel();
}
}
});
}
handleConnectionClick(nodeObj) {
if (!this.connectionStart) {
this.connectionStart = nodeObj;
nodeObj.group.classList.add('selected');
} else if (this.connectionStart === nodeObj) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
} else {
const defaultLabel = 'Read traffic';
const label = prompt('Enter connection label:', defaultLabel);
if (label) {
const conn = new Connection(this.connectionStart, nodeObj, label, this);
this.connections.push(conn);
}
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
}
showPropsPanel(nodeObj) {
// ... unchanged ...
this.activeNode = nodeObj;
const panel = this.nodePropsPanel;
// Position the panel (optional, or you can use fixed top/right)
const bbox = nodeObj.group.getBBox();
const ctm = nodeObj.group.getCTM();
const screenX = ctm.e + bbox.x;
const screenY = ctm.f + bbox.y + bbox.height;
panel.style.left = (screenX + this.canvasContainer.getBoundingClientRect().left) + 'px';
panel.style.top = (screenY + this.canvasContainer.getBoundingClientRect().top) + 'px';
// Always show label group
this.labelGroup.style.display = 'block';
panel.querySelector("input[name='label']").value = nodeObj.props.label;
// Show DB fields if it's a Database
this.dbGroup.style.display = nodeObj.type === 'Database' ? 'block' : 'none';
if (nodeObj.type === 'Database') {
this.dbGroup.querySelector("input[name='replication']").value = nodeObj.props.replication;
}
// Show cache fields if it's a cache
const isCache = nodeObj.type === 'CacheStandard' || nodeObj.type === 'CacheLarge';
this.cacheGroup.style.display = isCache ? 'block' : 'none';
if (isCache) {
this.cacheGroup.querySelector("input[name='cacheTTL']").value = nodeObj.props.cacheTTL;
}
this.propsSaveBtn.disabled = false;
panel.style.display = 'block';
}
hidePropsPanel() {
this.nodePropsPanel.style.display = 'none';
this.propsSaveBtn.disabled = true;
this.activeNode = null;
}
updateConnectionsFor(movedNode) {
this.connections.forEach(conn => {
if (conn.start === movedNode || conn.end === movedNode) {
conn.updatePosition();
}
});
}
clearSelection() {
if (this.selectedConnection) {
this.selectedConnection.deselect();
this.selectedConnection = null;
}
if (this.selectedNode) {
this.selectedNode.deselect();
this.selectedNode = null;
this.hidePropsPanel();
}
}
exportDesign() {
const nodes = this.placedComponents.map(n => ({
id: n.id,
type: n.type,
x: n.x,
y: n.y,
props: n.props
}));
const connections = this.connections.map(c => ({
source: c.start.id,
target: c.end.id,
label: c.label || ''
}));
return { nodes, connections };
}
}
const app = new CanvasApp();
</script>
</body>
</html>

286
internals/simulation/simulation.go

@ -0,0 +1,286 @@ @@ -0,0 +1,286 @@
package simulation
import (
"math"
)
type LoadBalancerSpec struct {
Capacity float64
BaseLatency int
Cost int
}
type WebServerSmall struct {
Capacity int
BaseLatency int
PenaltyPerRPS float64
Cost int
}
type WebServerMedium struct {
Capacity int
BaseLatency int
PenaltyPerRPS float64
Cost int
}
type CacheStandard struct {
Capacity int
BaseLatency int
PenaltyPer10RPS float64
HitRates map[string]float64
Cost int
}
type CacheLarge struct {
Capacity int
BaseLatency int
PenaltyPer10RPS float64
HitRates map[string]float64
Cost int
}
type DbReadReplica struct {
ReadCapacity int // RPS
BaseReadLatency int // ms
PenaltyPer10RPS float64
Cost int
}
type ComponentSpec struct {
LoadBalancer LoadBalancerSpec
WebServerSmall WebServerSmall
WebServerMedium WebServerMedium
CacheStandard CacheStandard
CacheLarge CacheLarge
DbReadReplica DbReadReplica
}
type Design struct {
NumWebServerSmall int
NumWebServerMedium int
CacheType CacheType // Enum (see below)
CacheTTL string
NumDbReplicas int
PromotionDelaySeconds int
}
type FailureEvent struct {
Type string
Time int
}
type Level struct {
ID int
Description string
TargetRPS int
MaxP95Latency int
MaxMonthlyCost int
RequiredAvailability int
FailureEvents []FailureEvent
ComponentSpec ComponentSpec
SimulatedDurationSeconds int
}
type Metrics struct {
Cost int `json:"cost"`
P95 float64 `json:"p95"`
AchievedRPS int `json:"achievedRPS"`
Availability float64 `json:"availability"`
}
type EvaluationResult struct {
Pass bool `json:"pass"`
Metrics Metrics `json:"metrics"`
}
type Latencies struct {
L95WS float64
L95Cache float64
L95DBRead float64
L95TotalRead float64
}
type ValidationMetrics struct {
Cost int
P95 float64
AchievedRPS int
Availability float64
}
type ValidationResults struct {
Pass bool
Reason *string
Metrics *ValidationMetrics
}
type CacheType string
const (
CacheStandardType CacheType = "cacheStandard"
CacheLargeType CacheType = "cacheLarge"
)
type LevelSimulator struct {
Level Level
Design Design
Specs ComponentSpec
}
func (ls *LevelSimulator) ComputeCost() int {
s := ls.Specs
d := ls.Design
costLb := s.LoadBalancer.Cost
costWSSmall := d.NumWebServerSmall * s.WebServerSmall.Cost
costWSMedium := d.NumWebServerMedium * s.WebServerSmall.Cost
var costCache int
if d.CacheType == CacheStandardType {
costCache = s.CacheStandard.Cost
} else {
costCache = s.CacheLarge.Cost
}
costDB := s.DbReadReplica.Cost * (1 + d.NumDbReplicas)
return costLb + costWSSmall + costWSMedium + costCache + costDB
}
func (ls *LevelSimulator) ComputeRPS() (float64, float64) {
s := ls.Specs
d := ls.Design
l := ls.Level
totalRPS := l.TargetRPS
var hitRate float64
if d.CacheType == CacheStandardType {
hitRate = s.CacheStandard.HitRates[d.CacheTTL]
} else if d.CacheType == CacheLargeType {
hitRate = s.CacheLarge.HitRates[d.CacheTTL]
} else {
hitRate = 0.0
}
hitRPS := float64(totalRPS) * hitRate
missesRPS := float64(totalRPS) * (1 - hitRate)
return hitRPS, missesRPS
}
func (ls *LevelSimulator) ComputeLatencies() Latencies {
s := ls.Specs
d := ls.Design
_, missesRPS := ls.ComputeRPS()
capSmall := s.WebServerSmall.Capacity
capMedium := s.WebServerMedium.Capacity
weightedCount := d.NumWebServerSmall + (2 * d.NumWebServerSmall)
var L95WS float64
if weightedCount == 0 {
L95WS = math.Inf(1)
} else {
loadPerWeighted := missesRPS / float64(weightedCount)
L95WSSmall := 0.0
if d.NumWebServerSmall > 0 {
if loadPerWeighted <= float64(capSmall) {
L95WSSmall = float64(s.WebServerSmall.BaseLatency)
} else {
L95WSSmall = (float64(s.WebServerSmall.BaseLatency) + s.WebServerSmall.PenaltyPerRPS*(loadPerWeighted-float64(capSmall)))
}
}
L95WSMedium := 0.0
if d.NumWebServerMedium > 0 {
if loadPerWeighted <= float64(capMedium) {
L95WSMedium = float64(s.WebServerSmall.BaseLatency)
} else {
L95WSMedium = (float64(s.WebServerSmall.BaseLatency) + s.WebServerMedium.PenaltyPerRPS*(loadPerWeighted-float64(capMedium)))
}
}
L95WS = math.Max(L95WSSmall, L95WSMedium)
}
var L95Cache float64
if d.CacheType == CacheStandardType {
L95Cache = float64(s.CacheStandard.BaseLatency)
} else if d.CacheType == CacheLargeType {
L95Cache = float64(s.CacheLarge.BaseLatency)
} else {
L95Cache = 0.0
}
readCap := s.DbReadReplica.ReadCapacity
baseReadLat := s.DbReadReplica.BaseReadLatency
penPer10 := s.DbReadReplica.PenaltyPer10RPS
numReps := d.NumDbReplicas
var L95DbRead float64
if numReps == 0 {
if missesRPS <= float64(readCap) {
L95DbRead = float64(baseReadLat)
} else {
excess := missesRPS - float64(readCap)
L95DbRead = float64(baseReadLat) + penPer10*(excess/10.0)
}
} else {
loadPerRep := missesRPS / float64(numReps)
if loadPerRep <= float64(readCap) {
L95DbRead = float64(baseReadLat)
} else {
loadPerRep := missesRPS / float64(numReps)
if loadPerRep <= float64(readCap) {
L95DbRead = float64(baseReadLat)
} else {
excess := loadPerRep - loadPerRep - float64(readCap)
L95DbRead = float64(baseReadLat) + penPer10*(excess/10.0)
}
}
}
LLb := s.LoadBalancer.BaseLatency
missPath := LLb + int(L95WS) + int(L95DbRead)
L95TotalRead := missPath
return Latencies{
L95WS: L95WS,
L95Cache: L95Cache,
L95DBRead: L95DbRead,
L95TotalRead: float64(L95TotalRead),
}
}
func (ls *LevelSimulator) ComputeAvailability() float64 {
simDuration := ls.Level.SimulatedDurationSeconds
totalDownTime := 0
for _, event := range ls.Level.FailureEvents {
tCrash := event.Time
if event.Type == "DB_MASTER_CRASH" {
if ls.Design.NumDbReplicas == 0 {
totalDownTime += (simDuration - tCrash)
} else {
delay := ls.Design.PromotionDelaySeconds
totalDownTime += delay
}
}
}
return (float64(simDuration) - float64(totalDownTime)) / float64(simDuration) * 100
}
func (ls *LevelSimulator) Validate() ValidationResults {
totalCost := ls.ComputeCost()
if totalCost > ls.Level.MaxMonthlyCost {
return ValidationResults{
Pass: false,
Metrics: Metrics{
Cost: totalCost,
P95: la,
},
}
}
}

140
main.go

@ -1,133 +1,47 @@ @@ -1,133 +1,47 @@
package main
import (
"fmt"
"context"
"html/template"
"net/http"
"os"
"strconv"
"strings"
"github.com/gofrs/uuid"
"os/signal"
"time"
)
const startupMessage = `                                                                           
                                                                           
                                                                           
                                                        
                                                                
                                     
                                               
                                          
                                
                                                            
                                                           
                                                                    
                                                       
                                                   
                                                                  
`
func logRequest(r *http.Request) {
uri := r.RequestURI
method := r.Method
fmt.Println("Got request!", method, uri)
}
var tmpl = template.Must(template.ParseFiles("static/index.html"))
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
logRequest(r)
fmt.Fprintf(w, "Hello! you've requested %s\n", r.URL.Path)
})
http.HandleFunc("/cached", func(w http.ResponseWriter, r *http.Request) {
logRequest(r)
maxAgeParams, ok := r.URL.Query()["max-age"]
if ok && len(maxAgeParams) > 0 {
maxAge, _ := strconv.Atoi(maxAgeParams[0])
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", maxAge))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", index)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
responseHeaderParams, ok := r.URL.Query()["headers"]
if ok {
for _, header := range responseHeaderParams {
h := strings.Split(header, ":")
w.Header().Set(h[0], strings.TrimSpace(h[1]))
}
}
statusCodeParams, ok := r.URL.Query()["status"]
if ok {
statusCode, _ := strconv.Atoi(statusCodeParams[0])
w.WriteHeader(statusCode)
}
requestID := uuid.Must(uuid.NewV4())
fmt.Fprint(w, requestID.String())
})
http.HandleFunc("/headers", func(w http.ResponseWriter, r *http.Request) {
logRequest(r)
keys, ok := r.URL.Query()["key"]
if ok && len(keys) > 0 {
fmt.Fprint(w, r.Header.Get(keys[0]))
return
}
headers := []string{}
headers = append(headers, fmt.Sprintf("host=%s", r.Host))
for key, values := range r.Header {
headers = append(headers, fmt.Sprintf("%s=%s", key, strings.Join(values, ",")))
}
fmt.Fprint(w, strings.Join(headers, "\n"))
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
http.HandleFunc("/env", func(w http.ResponseWriter, r *http.Request) {
logRequest(r)
keys, ok := r.URL.Query()["key"]
if ok && len(keys) > 0 {
fmt.Fprint(w, os.Getenv(keys[0]))
return
}
envs := []string{}
envs = append(envs, os.Environ()...)
fmt.Fprint(w, strings.Join(envs, "\n"))
})
go func() {
srv.ListenAndServe()
}()
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
logRequest(r)
codeParams, ok := r.URL.Query()["code"]
if ok && len(codeParams) > 0 {
statusCode, _ := strconv.Atoi(codeParams[0])
if statusCode >= 200 && statusCode < 600 {
w.WriteHeader(statusCode)
}
}
requestID := uuid.Must(uuid.NewV4())
fmt.Fprint(w, requestID.String())
})
<-ctx.Done()
stop()
port := os.Getenv("PORT")
if port == "" {
port = "80"
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, encodedRoute := range strings.Split(os.Getenv("ROUTES"), ",") {
if encodedRoute == "" {
continue
}
pathAndBody := strings.SplitN(encodedRoute, "=", 2)
path, body := pathAndBody[0], pathAndBody[1]
http.HandleFunc("/"+path, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
})
srv.Shutdown(shutdownCtx)
}
bindAddr := fmt.Sprintf(":%s", port)
lines := strings.Split(startupMessage, "\n")
fmt.Println()
for _, line := range lines {
fmt.Println(line)
func index(w http.ResponseWriter, r *http.Request) {
data := struct {
Title string
}{
Title: "Title",
}
fmt.Println()
fmt.Printf("==> Server listening at %s 🚀\n", bindAddr)
if err := http.ListenAndServe(bindAddr, nil); err != nil {
panic(err)
}
tmpl.Execute(w, data)
}

1051
old.html

File diff suppressed because it is too large Load Diff

1047
static/index.html

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save