Cookbook
Things to make with know.sh and the AI you already use, each shown as it looks in know.sh.
Books and writing 9 · Study and exams 7 · Teaching and courses 7 · Labs and workshops 7 · Work and teams 6 · Faith and study 2 · Kitchen and table 4 · Home and family 3 · Research and civic life 6 · Hobbies and play 9
Showcases
-
31An AI model trackerWork and teams
Seven models on four benchmarks, September 2026
Model SWE-bench GPQA HLE ARC-AGI-2 $/M tok Ridge 2, in use 74.9 84.2 26.3 24.0 6.00 Ridge 3 82.4 88.1 34.7 41.0 6.00 ✓Kestrel 2.5 80.6 86.9 31.2 36.5 2.40 Lumen 4 85.1 90.3 41.5 52.0 14.00 Lumen 4 Mini 74.6 81.0 22.9 19.5 0.90 Weft 72B 71.3 78.4 18.6 12.0 0.45 Sable 1 80.9 87.5 35.0 38.0 3.10 Scores are per cent. SWE-bench Verified: share of 500 human-checked GitHub issues resolved. GPQA Diamond: 198 graduate-level science questions. HLE: Humanity’s Last Exam, 2,500 expert-written questions, answered without tools. ARC-AGI-2: grid puzzles easy for people and hard for models. Price blends three input tokens to one output. Sable 1’s figures are the lab’s own.
Seven models on four benchmarks, September 2026
Model SWE-bench GPQA HLE ARC-AGI-2 $/M tok Ridge 2, in use 74.9 84.2 26.3 24.0 6.00 Ridge 3 82.4 88.1 34.7 41.0 6.00 ✓ Kestrel 2.5 80.6 86.9 31.2 36.5 2.40 Lumen 4 85.1 90.3 41.5 52.0 14.00 Lumen 4 Mini 74.6 81.0 22.9 19.5 0.90 Weft 72B 71.3 78.4 18.6 12.0 0.45 Sable 1 80.9 87.5 35.0 38.0 3.10 Scores are per cent. SWE-bench Verified: share of 500 human-checked GitHub issues resolved. GPQA Diamond: 198 graduate-level science questions. HLE: Humanity’s Last Exam, 2,500 expert-written questions, answered without tools. ARC-AGI-2: grid puzzles easy for people and hard for models. Price blends three input tokens to one output. Sable 1’s figures are the lab’s own.
Every model that shipped this month in one table, its score plotted against its price, and the team’s call on which one to try next. See the recipe
-
44A group trip guideHome and family
Portugal, June 2027: where we sleep and the days with a fixed time
Lisbon
- 10–14 June 2027
Lisbon, the apartment in Alfama
Arrive Thursday; flights by household in Day 1.
Sintra, then Santo António
Timed palace tickets. Avó and Avô may stay in the old town; everyone meets for the street parties that evening (Day 3).
Porto
The morning train to Porto
Train, not a hired car: the Decision section says why (Day 5).
- 14–17 June 2027
Porto, the house in Miragaia
Dinner for ten near the Ribeira booked ahead each night; the port tasting is still a Question.
The Douro
- 17–19 June 2027
The Douro, the quinta above Pinhão
Up the Douro line by train; the boat on the river if the research holds up.
Home
Back to Porto, the airport hotel
Flights home
Times by household in the last day’s section.
Portugal, June 2027: where we sleep and the days with a fixed time
- Lisbon
- 10–14 June 2027 — Lisbon, the apartment in Alfama
Arrive Thursday; flights by household in Day 1.
- 12 June 2027 — Sintra, then Santo António
Timed palace tickets. Avó and Avô may stay in the old town; everyone meets for the street parties that evening (Day 3).
- 10–14 June 2027 — Lisbon, the apartment in Alfama
- Porto
- 14 June 2027 — The morning train to Porto
Train, not a hired car: the Decision section says why (Day 5).
- 14–17 June 2027 — Porto, the house in Miragaia
Dinner for ten near the Ribeira booked ahead each night; the port tasting is still a Question.
- 14 June 2027 — The morning train to Porto
- The Douro
- 17–19 June 2027 — The Douro, the quinta above Pinhão
Up the Douro line by train; the boat on the river if the research holds up.
- 17–19 June 2027 — The Douro, the quinta above Pinhão
- Home
- 19 June 2027 — Back to Porto, the airport hotel
- 20 June 2027 — Flights home
Times by household in the last day’s section.
Ten people, eleven days, one link: the itinerary on a timeline, every day and booking on its own page, and the family reading it on their phones. See the recipe
- 10–14 June 2027
-
45A story for a road tripHome and family
The drive, leg by leg
Leg Miles Chapter Listen To Vail Pass 95 1. The map that would not stay folded 16 min To Glenwood 65 2. The river that went through the mountain 17 min To Grand Junction 90 3. The monument at sunset 15 min To Moab 115 4. The window in the rock 18 min From Denver, by I-70 and US-191; miles are approximate.
The drive, leg by leg
Leg Miles Chapter Listen To Vail Pass 95 1. The map that would not stay folded 16 min To Glenwood 65 2. The river that went through the mountain 17 min To Grand Junction 90 3. The monument at sunset 15 min To Moab 115 4. The window in the rock 18 min From Denver, by I-70 and US-191; miles are approximate.
A story written for your route, a chapter a leg, with the pass, the canyon and the arches in it, read aloud in the car by a voice made on your phone. See the recipe
-
32Incident runbooks and a postmortem libraryWork and teams
First checks for LedgerQueueLagHigh, in order First checks for LedgerQueueLagHigh, in order
Check the database pool first: at 100 per cent, escalate without restarting. Otherwise check the consumer is running and restart it if not. If a deploy went out in the last hour, ask its owner to roll back. If not, watch the lag for 20 minutes and escalate if it is still rising.
- LedgerQueueLagHigh pages
- Leads to Pool at 100%?
- Pool at 100%?
- Leads to Escalate, yes
- Leads to Consumer up?, no
- Consumer up?
- Leads to Restart the consumer, no
- Leads to Deploy this hour?, yes
- Restart the consumer
- Deploy this hour?
- Leads to Ask for a rollback, yes
- Leads to Watch for 20 minutes, no
- Ask for a rollback
- Watch for 20 minutes
- Leads to Escalate, still rising
- Escalate
Runbooks named exactly as your alerts page, first checks drawn as a flowchart, and every postmortem linked to the runbook that should have caught it. See the recipe
- LedgerQueueLagHigh pages
-
17A course syllabus and course bookTeaching and courses
SOC 101, autumn 2026: units, reading responses and exams SOC 101, autumn 2026: units, reading responses and exams
Sixteen weeks from 24 August to 11 December. Foundations runs for weeks 1 to 3, research methods weeks 4 and 5, theoretical perspectives weeks 6 and 7, communities weeks 9 to 11 and inequality and power weeks 12 to 15. Reading responses are due on Fridays 4, 11 and 25 September and 20 November. The midterm is on Thursday 15 October, the community observation paper is due Thursday 5 November, and the final exam is on Thursday 10 December. There is no class on Thursday 26 November, Thanksgiving.
Task Section Starts Ends Status Thanksgiving, no class 2026-11-26 0 days Marker Foundations Units 2026-08-24 2026-09-11 Research methods Units 2026-09-14 2026-09-25 Theoretical perspectives Units 2026-09-28 2026-10-09 Communities Units 2026-10-19 2026-11-06 Inequality and power Units 2026-11-09 2026-12-04 Response 1 Reading responses 2026-09-04 0 days Milestone Response 2 Reading responses 2026-09-11 0 days Milestone Response 3 Reading responses 2026-09-25 0 days Milestone Response 4 Reading responses 2026-11-20 0 days Milestone Midterm Exams and the paper 2026-10-15 0 days Milestone Observation paper due Exams and the paper 2026-11-05 0 days Milestone Final exam Exams and the paper 2026-12-10 0 days Milestone Weeks start on Monday. End dates are the last day of the task.
The syllabus and the weekly course book in one document: the term on a Gantt chart, every objective checked against an assessment, one link for students. See the recipe
-
24A chemistry lab manualLabs and workshops
An acid–base titration. Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.
An acid–base titration. Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.
<div class="tit"> <div class="setup"> <label class="wide">Acid in the flask <select id="acid"> <option value="hcl">Hydrochloric acid, strong</option> <option value="ethanoic" selected>Ethanoic (acetic) acid, weak, pKa 4.76</option> <option value="methanoic">Methanoic (formic) acid, weak, pKa 3.75</option> </select> </label> <label>Acid concentration <select id="ca"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select> </label> <label>Acid volume, by pipette <select id="va"><option value="10">10.00 mL</option><option value="20">20.00 mL</option><option value="25" selected>25.00 mL</option></select> </label> <label>NaOH in the burette <select id="cb"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select> </label> </div> <svg id="plot" role="img" aria-label="Titration curve"></svg> <div class="burette"> <label class="slide"><span>NaOH added <b id="vbOut">0.00 mL</b></span> <input id="vb" type="range" min="0" max="50" step="0.05" value="0" aria-describedby="say"> </label> <div class="adds" role="group" aria-label="Add sodium hydroxide from the burette"> <button type="button" data-add="0.05">One drop</button> <button type="button" data-add="0.5">+0.5 mL</button> <button type="button" data-add="1">+1 mL</button> <button type="button" data-add="5">+5 mL</button> <label class="check"><input type="checkbox" id="ahead" checked> Show the curve to come</label> <button type="button" id="again">Start again</button> </div> </div> <dl class="figures"> <div><dt>pH now</dt><dd id="pOut">–</dd></div> <div><dt>Equivalence point</dt><dd id="eqOut">–</dd></div> <div><dt>Half-equivalence</dt><dd id="halfOut">–</dd></div> <div><dt>Indicator</dt><dd id="indOut">–</dd></div> </dl> <p class="say" id="say" aria-live="polite"></p> <p class="ind" id="ind" aria-live="polite"></p> </div>An acid–base titration. Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.
.tit { font-family: var(--sans); } .setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 16px; } .setup .wide { grid-column: 1 / -1; } .setup label, .slide { display: grid; gap: 3px; } .setup select { width: 100%; min-height: 36px; } #plot { display: block; width: 100%; margin-top: 12px; overflow: visible; } .axis { stroke: var(--ink); stroke-width: 1; fill: none; } .grid { stroke: var(--rule); stroke-width: 1; } .tick { font: 400 11px var(--sans); fill: var(--ink-3); font-variant-numeric: tabular-nums; } .atitle { font: 400 11.5px var(--sans); fill: var(--ink-2); } .band { fill: var(--wash); } .bandlab { font: italic 400 11.5px var(--serif); fill: var(--ink-2); } .ahead { fill: none; stroke: var(--ink-3); stroke-width: 1.2; stroke-dasharray: 3 3; } .done { fill: none; stroke: var(--ink); stroke-width: 2.2; stroke-linejoin: round; } .mark { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .now { fill: var(--ink); } .guide { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 1 3; } .marklab { font: 500 11.5px var(--sans); fill: var(--ink); } .halo { stroke: var(--paper); stroke-width: 4px; stroke-linejoin: round; paint-order: stroke; } .burette { margin-top: 6px; } .slide b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; } .slide input { width: 100%; } .adds { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; } .adds button { font: 500 13.5px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); padding: 9px 11px; min-height: 38px; } .adds button:active { background: var(--ink); color: var(--paper); } .adds #again { margin-left: auto; border-color: var(--rule); color: var(--ink-2); } .check { display: flex; align-items: center; gap: 6px; margin-left: 6px; } .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 12px 0 0; border-top: 1px solid var(--ink); } .figures div { padding: 6px 0; border-bottom: 1px solid var(--rule); } .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); } .figures dd { margin: 2px 0 0; font: 400 16px/1.3 var(--serif); color: var(--ink); font-variant-numeric: tabular-nums; } .say, .ind { margin: 8px 0 0; font: 400 15px/1.45 var(--serif); color: var(--ink); } .ind { color: var(--ink-2); } .say i, .ind i { font-style: italic; } button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } @media (max-width: 420px) { .adds #again { margin-left: 0; } }An acid–base titration. Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.
(function () { var $ = function (id) { return document.getElementById(id); }; var svg = $('plot'); var KW = 1e-14, DROP = 0.05, NEAR = 0.1, BURETTE = 50; var ACIDS = { hcl: { name: 'hydrochloric acid', strong: true, salt: 'sodium chloride' }, ethanoic: { name: 'ethanoic acid', pKa: 4.76, ion: 'ethanoate', salt: 'sodium ethanoate' }, methanoic: { name: 'methanoic acid', pKa: 3.75, ion: 'methanoate', salt: 'sodium methanoate' } }; // Transition ranges as school data books give them, with the colour on each side. var INDICATORS = [ { name: 'Methyl orange', lo: 3.1, hi: 4.4, acid: 'red', base: 'yellow' }, { name: 'Methyl red', lo: 4.4, hi: 6.2, acid: 'red', base: 'yellow' }, { name: 'Bromothymol blue', lo: 6.0, hi: 7.6, acid: 'yellow', base: 'blue' }, { name: 'Phenolphthalein', lo: 8.2, hi: 10.0, acid: 'colourless', base: 'pink' }, { name: 'Thymolphthalein', lo: 9.3, hi: 10.5, acid: 'colourless', base: 'blue' } ]; var s = { acid: 'ethanoic', ca: 0.1, va: 25, cb: 0.1, vb: 0, ahead: true }; var cache = null; function acid() { return ACIDS[s.acid]; } function ka() { var a = acid(); return a.strong ? Infinity : Math.pow(10, -a.pKa); } function veq() { return s.ca * s.va / s.cb; } function f2(x) { return (Math.round(x * 100) / 100).toFixed(2); } function mmol(x) { return x < 0.1 ? x.toFixed(3) : f2(x); } // The exact pH, ignoring activities: solve the charge balance // [H+] + [Na+] = [OH-] + [A-], with [A-] = C·Ka/(Ka + [H+]) for a weak acid // and [A-] = C for a strong one. The left side minus the right rises with // [H+], so bisection on pH always finds the one root. function pHAt(vb) { var vt = s.va + vb, c = s.ca * s.va / vt, na = s.cb * vb / vt, k = ka(), lo = -2, hi = 16; for (var i = 0; i < 70; i++) { var mid = (lo + hi) / 2, h = Math.pow(10, -mid); var a = k === Infinity ? c : c * k / (k + h); if (h + na - KW / h - a > 0) lo = mid; else hi = mid; } return (lo + hi) / 2; } function sci(x) { if (x >= 0.001) return x.toPrecision(3); var e = Math.floor(Math.log10(x)), m = x / Math.pow(10, e); if (m >= 9.995) { m /= 10; e += 1; } var sup = { '-': '⁻', '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹' }; return m.toFixed(2) + ' × 10' + String(e).split('').map(function (ch) { return sup[ch]; }).join(''); } // The route a student takes by hand in each region, and its answer. function byHand(vb) { var a = acid(), nA = s.ca * s.va, nB = s.cb * vb, vt = s.va + vb, rel = (nB - nA) / nA, excess, conc, p; var tot = 'total volume ' + f2(vt) + ' mL'; if (Math.abs(rel) < 1e-9) { if (a.strong) return { p: 7, text: '<b>At equivalence.</b> The flask holds only ' + a.salt + ' and water, a neutral salt, so pH = 7.00.' }; var c = nA / vt, kb = KW / ka(), oh = Math.sqrt(kb * c); p = 14 + Math.log10(oh); return { p: p, text: '<b>At equivalence.</b> Every acid molecule has become ' + a.ion + ', a weak base that takes H⁺ from water. By hand: K<sub>b</sub> = K<sub>w</sub> ÷ K<sub>a</sub> = ' + sci(kb) + ', c = ' + mmol(nA) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(c) + ' mol/L, [OH⁻] = √(K<sub>b</sub>·c) = ' + sci(oh) + ', so pH = 14 − pOH = ' + f2(p) + '.' }; } if (nB > nA) { excess = nB - nA; conc = excess / vt; p = 14 + Math.log10(conc); return { p: p, text: '<b>After equivalence.</b> Excess sodium hydroxide sets the pH' + (a.strong ? '' : ' and swamps the ' + a.ion) + '. By hand: [OH⁻] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = 14 − pOH = ' + f2(p) + '.' }; } if (a.strong) { excess = nA - nB; conc = excess / vt; p = -Math.log10(conc); return { p: p, text: (vb === 0 ? '<b>Acid alone.</b> Hydrochloric acid ionises completely' : '<b>Before equivalence.</b> Hydroxide has neutralised part of the acid; the rest ionises completely') + '. By hand: [H⁺] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = ' + f2(p) + '.' }; } if (vb === 0) { p = 0.5 * (a.pKa - Math.log10(s.ca)); return { p: p, text: '<b>Acid alone.</b> A weak acid ionises only a little. By hand: [H⁺] ≈ √(K<sub>a</sub>·c), so pH ≈ ½(pK<sub>a</sub> − log c) = ½(' + a.pKa.toFixed(2) + ' − log ' + s.ca.toFixed(3) + ') = ' + f2(p) + '.' }; } p = a.pKa + Math.log10(nB / (nA - nB)); return { p: p, text: '<b>Buffer region.</b> The flask holds ' + a.name + ' and ' + a.ion + ' together. By hand (Henderson–Hasselbalch): pH = pK<sub>a</sub> + log(' + a.ion + ' ÷ acid) = ' + a.pKa.toFixed(2) + ' + log(' + mmol(nB) + ' mmol ÷ ' + mmol(nA - nB) + ' mmol) = ' + f2(p) + '.' }; } function setup() { var v = veq(), a = acid(), xmax = Math.max(10, Math.ceil(2 * v / 5) * 5), xs = [], i, d; for (i = 0; i <= 320; i++) xs.push(xmax * i / 320); [1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 2e-3, 5e-3, 1e-2, 2e-2, 4e-2, 7e-2].forEach(function (d) { xs.push(v * (1 - d)); xs.push(v * (1 + d)); xs.push(v * d); }); xs.push(v); xs = xs.filter(function (x) { return x >= 0 && x <= xmax; }).sort(function (p, q) { return p - q; }); var pts = xs.map(function (x) { return [x, pHAt(x)]; }); // An indicator suits the titration when its mid-colour is reached within // 0.10 mL (two drops) either side of equivalence. var lo = pHAt(Math.max(0, v - NEAR)), hi = pHAt(v + NEAR), pe = pHAt(v); var mid = function (n) { return (n.lo + n.hi) / 2; }; var fits = INDICATORS.filter(function (n) { return mid(n) >= lo && mid(n) <= hi; }); var closest = function (list) { return list.slice().sort(function (p, q) { return Math.abs((p.lo + p.hi) / 2 - pe) - Math.abs((q.lo + q.hi) / 2 - pe); })[0]; }; var pick = fits.length ? closest(fits) : closest(INDICATORS); cache = { v: v, xmax: xmax, pts: pts, jump: [lo, hi], pe: pe, fits: fits, pick: pick, half: a.strong ? null : pHAt(v / 2) }; var slider = $('vb'); slider.max = xmax; if (s.vb > xmax) s.vb = xmax; } function draw() { var c = cache, a = acid(); var W = Math.max(260, Math.round(svg.getBoundingClientRect().width) || 588), H = W < 440 ? 230 : 252; var L = 30, R = 12, T = 12, B = 34, pw = W - L - R, ph = H - T - B; var X = function (v) { return L + v / c.xmax * pw; }; var Y = function (p) { return T + (14 - Math.max(0, Math.min(14, p))) / 14 * ph; }; var out = [], i; svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('height', H); var n = c.pick; out.push('<rect class="band" x="' + L + '" y="' + Y(n.hi) + '" width="' + pw + '" height="' + (Y(n.lo) - Y(n.hi)) + '"/>'); for (i = 0; i <= 14; i += 2) { out.push('<line class="grid" x1="' + L + '" x2="' + (L + pw) + '" y1="' + Y(i) + '" y2="' + Y(i) + '"/>'); out.push('<text class="tick" x="' + (L - 6) + '" y="' + (Y(i) + 4) + '" text-anchor="end">' + i + '</text>'); } var steps = [1, 2, 5, 10, 20, 25, 50], step = steps.filter(function (st) { return c.xmax / st <= (W < 440 ? 5 : 8); })[0] || 50; for (var x = 0; x <= c.xmax + 1e-9; x += step) { out.push('<line class="axis" x1="' + X(x) + '" x2="' + X(x) + '" y1="' + (T + ph) + '" y2="' + (T + ph + 4) + '"/>'); out.push('<text class="tick" x="' + X(x) + '" y="' + (T + ph + 16) + '" text-anchor="middle">' + x + '</text>'); } out.push('<path class="axis" d="M' + L + ' ' + T + 'V' + (T + ph) + 'H' + (L + pw) + '"/>'); out.push('<text class="atitle" x="' + (L + pw) + '" y="' + (H - 2) + '" text-anchor="end">NaOH added, mL</text>'); out.push('<text class="atitle" x="' + (L + 4) + '" y="' + (T + 10) + '">pH</text>'); out.push('<text class="bandlab halo" x="' + (L + 6) + '" y="' + (Y(n.hi) - 4) + '">' + n.name.toLowerCase() + ' changes colour</text>'); var line = function (pts) { return pts.map(function (p, j) { return (j ? 'L' : 'M') + X(p[0]).toFixed(1) + ' ' + Y(p[1]).toFixed(1); }).join(''); }; var pNow = pHAt(s.vb); var done = c.pts.filter(function (p) { return p[0] < s.vb; }).concat([[s.vb, pNow]]); var rest = [[s.vb, pNow]].concat(c.pts.filter(function (p) { return p[0] > s.vb; })); if (s.ahead && rest.length > 1) out.push('<path class="ahead" d="' + line(rest) + '"/>'); if (done.length > 1) out.push('<path class="done" d="' + line(done) + '"/>'); var ex = X(c.v), ey = Y(c.pe), right = ex < L + pw * 0.62; out.push('<line class="guide" x1="' + ex + '" x2="' + ex + '" y1="' + T + '" y2="' + (T + ph) + '"/>'); out.push('<circle class="mark" cx="' + ex + '" cy="' + ey + '" r="4.5"/>'); out.push('<text class="marklab halo" x="' + (ex + (right ? 7 : -7)) + '" y="' + (T + 12) + '" text-anchor="' + (right ? 'start' : 'end') + '">Equivalence</text>'); if (c.half !== null) { var hx = X(c.v / 2), hy = Y(c.half); out.push('<line class="guide" x1="' + L + '" x2="' + hx + '" y1="' + hy + '" y2="' + hy + '"/>'); out.push('<circle class="mark" cx="' + hx + '" cy="' + hy + '" r="4.5"/>'); out.push('<text class="marklab halo" x="' + (hx + 7) + '" y="' + (hy + 16) + '">Half-equivalence: pH = pKa</text>'); } out.push('<circle class="now" cx="' + X(s.vb) + '" cy="' + Y(pNow) + '" r="5"/>'); svg.innerHTML = out.join(''); svg.setAttribute('aria-label', 'Titration curve of pH against NaOH added, 0 to ' + c.xmax + ' mL, for ' + s.ca.toFixed(3) + ' mol/L ' + a.name + '. It rises steeply at the equivalence point, ' + f2(c.v) + ' mL, pH ' + f2(c.pe) + '. Now at ' + f2(s.vb) + ' mL, pH ' + f2(pNow) + '.'); return pNow; } function colourIn(n, p) { if (p < n.lo) return n.acid; if (p > n.hi) return n.base; return 'changing, between ' + n.acid + ' and ' + n.base; } function render() { var c = cache, a = acid(), p = draw(), hand = byHand(s.vb), n = c.pick; $('vb').value = s.vb; $('vbOut').textContent = f2(s.vb) + ' mL'; $('pOut').textContent = f2(p); $('eqOut').textContent = f2(c.v) + ' mL, pH ' + f2(c.pe); $('halfOut').textContent = c.half === null ? 'None: strong acid' : f2(c.v / 2) + ' mL, pH ' + f2(c.half); $('indOut').textContent = n.name + ', ' + n.lo.toFixed(1) + '–' + n.hi.toFixed(1); var say = hand.text; if (Math.abs(hand.p - p) >= 0.05) say += ' <i>The shortcut is off by ' + Math.abs(hand.p - p).toFixed(2) + ' here: so close to the start or to equivalence it leaves out the acid’s own ionisation or water’s. The exact pH, ' + f2(p) + ', allows for both.</i>'; if (c.v > BURETTE) say += ' <i>Equivalence needs more than one 50 mL burette: take a smaller aliquot of acid or a stronger NaOH.</i>'; $('say').innerHTML = say; var others = c.fits.filter(function (m) { return m !== n; }).map(function (m) { return m.name.toLowerCase(); }); var why = c.fits.length ? n.name + ' suits it best: its mid-colour, pH ' + ((n.lo + n.hi) / 2).toFixed(1) + ', is nearest the equivalence pH and falls inside the jump, where 0.10 mL (two drops) either side of equivalence takes the pH from ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '.' + (others.length ? ' Also inside it: ' + others.join(', ') + '.' : '') : 'No common indicator reaches its mid-colour within 0.10 mL of equivalence (pH ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '); ' + n.name.toLowerCase() + ' comes closest. Add the last millilitre drop by drop, or use a pH meter.'; $('ind').textContent = why + ' In the flask now: ' + colourIn(n, p) + '.'; } function set(v) { s.vb = Math.max(0, Math.min(cache.xmax, Math.round(v / DROP) * DROP)); s.vb = Math.round(s.vb * 100) / 100; render(); } ['acid', 'ca', 'va', 'cb'].forEach(function (id) { $(id).addEventListener('change', function () { s[id] = id === 'acid' ? this.value : Number(this.value); s.vb = 0; setup(); render(); }); }); $('vb').addEventListener('input', function () { set(Number(this.value)); }); document.querySelectorAll('[data-add]').forEach(function (b) { b.addEventListener('click', function () { set(s.vb + Number(b.getAttribute('data-add'))); }); }); $('again').addEventListener('click', function () { set(0); }); $('ahead').addEventListener('change', function () { s.ahead = this.checked; render(); }); document.addEventListener('know-tokens', render); var lastW = 0; if (window.ResizeObserver) new ResizeObserver(function (en) { var w = Math.round(en[0].contentRect.width); if (w !== lastW) { lastW = w; requestAnimationFrame(render); } }).observe(svg.parentNode); else window.addEventListener('resize', render); setup(); render(); })();An acid–base titration
Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.
<div class="tit"> <div class="setup"> <label class="wide">Acid in the flask <select id="acid"> <option value="hcl">Hydrochloric acid, strong</option> <option value="ethanoic" selected>Ethanoic (acetic) acid, weak, pKa 4.76</option> <option value="methanoic">Methanoic (formic) acid, weak, pKa 3.75</option> </select> </label> <label>Acid concentration <select id="ca"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select> </label> <label>Acid volume, by pipette <select id="va"><option value="10">10.00 mL</option><option value="20">20.00 mL</option><option value="25" selected>25.00 mL</option></select> </label> <label>NaOH in the burette <select id="cb"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select> </label> </div> <svg id="plot" role="img" aria-label="Titration curve"></svg> <div class="burette"> <label class="slide"><span>NaOH added <b id="vbOut">0.00 mL</b></span> <input id="vb" type="range" min="0" max="50" step="0.05" value="0" aria-describedby="say"> </label> <div class="adds" role="group" aria-label="Add sodium hydroxide from the burette"> <button type="button" data-add="0.05">One drop</button> <button type="button" data-add="0.5">+0.5 mL</button> <button type="button" data-add="1">+1 mL</button> <button type="button" data-add="5">+5 mL</button> <label class="check"><input type="checkbox" id="ahead" checked> Show the curve to come</label> <button type="button" id="again">Start again</button> </div> </div> <dl class="figures"> <div><dt>pH now</dt><dd id="pOut">–</dd></div> <div><dt>Equivalence point</dt><dd id="eqOut">–</dd></div> <div><dt>Half-equivalence</dt><dd id="halfOut">–</dd></div> <div><dt>Indicator</dt><dd id="indOut">–</dd></div> </dl> <p class="say" id="say" aria-live="polite"></p> <p class="ind" id="ind" aria-live="polite"></p> </div>.tit { font-family: var(--sans); } .setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 16px; } .setup .wide { grid-column: 1 / -1; } .setup label, .slide { display: grid; gap: 3px; } .setup select { width: 100%; min-height: 36px; } #plot { display: block; width: 100%; margin-top: 12px; overflow: visible; } .axis { stroke: var(--ink); stroke-width: 1; fill: none; } .grid { stroke: var(--rule); stroke-width: 1; } .tick { font: 400 11px var(--sans); fill: var(--ink-3); font-variant-numeric: tabular-nums; } .atitle { font: 400 11.5px var(--sans); fill: var(--ink-2); } .band { fill: var(--wash); } .bandlab { font: italic 400 11.5px var(--serif); fill: var(--ink-2); } .ahead { fill: none; stroke: var(--ink-3); stroke-width: 1.2; stroke-dasharray: 3 3; } .done { fill: none; stroke: var(--ink); stroke-width: 2.2; stroke-linejoin: round; } .mark { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .now { fill: var(--ink); } .guide { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 1 3; } .marklab { font: 500 11.5px var(--sans); fill: var(--ink); } .halo { stroke: var(--paper); stroke-width: 4px; stroke-linejoin: round; paint-order: stroke; } .burette { margin-top: 6px; } .slide b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; } .slide input { width: 100%; } .adds { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; } .adds button { font: 500 13.5px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); padding: 9px 11px; min-height: 38px; } .adds button:active { background: var(--ink); color: var(--paper); } .adds #again { margin-left: auto; border-color: var(--rule); color: var(--ink-2); } .check { display: flex; align-items: center; gap: 6px; margin-left: 6px; } .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 12px 0 0; border-top: 1px solid var(--ink); } .figures div { padding: 6px 0; border-bottom: 1px solid var(--rule); } .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); } .figures dd { margin: 2px 0 0; font: 400 16px/1.3 var(--serif); color: var(--ink); font-variant-numeric: tabular-nums; } .say, .ind { margin: 8px 0 0; font: 400 15px/1.45 var(--serif); color: var(--ink); } .ind { color: var(--ink-2); } .say i, .ind i { font-style: italic; } button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } @media (max-width: 420px) { .adds #again { margin-left: 0; } }(function () { var $ = function (id) { return document.getElementById(id); }; var svg = $('plot'); var KW = 1e-14, DROP = 0.05, NEAR = 0.1, BURETTE = 50; var ACIDS = { hcl: { name: 'hydrochloric acid', strong: true, salt: 'sodium chloride' }, ethanoic: { name: 'ethanoic acid', pKa: 4.76, ion: 'ethanoate', salt: 'sodium ethanoate' }, methanoic: { name: 'methanoic acid', pKa: 3.75, ion: 'methanoate', salt: 'sodium methanoate' } }; // Transition ranges as school data books give them, with the colour on each side. var INDICATORS = [ { name: 'Methyl orange', lo: 3.1, hi: 4.4, acid: 'red', base: 'yellow' }, { name: 'Methyl red', lo: 4.4, hi: 6.2, acid: 'red', base: 'yellow' }, { name: 'Bromothymol blue', lo: 6.0, hi: 7.6, acid: 'yellow', base: 'blue' }, { name: 'Phenolphthalein', lo: 8.2, hi: 10.0, acid: 'colourless', base: 'pink' }, { name: 'Thymolphthalein', lo: 9.3, hi: 10.5, acid: 'colourless', base: 'blue' } ]; var s = { acid: 'ethanoic', ca: 0.1, va: 25, cb: 0.1, vb: 0, ahead: true }; var cache = null; function acid() { return ACIDS[s.acid]; } function ka() { var a = acid(); return a.strong ? Infinity : Math.pow(10, -a.pKa); } function veq() { return s.ca * s.va / s.cb; } function f2(x) { return (Math.round(x * 100) / 100).toFixed(2); } function mmol(x) { return x < 0.1 ? x.toFixed(3) : f2(x); } // The exact pH, ignoring activities: solve the charge balance // [H+] + [Na+] = [OH-] + [A-], with [A-] = C·Ka/(Ka + [H+]) for a weak acid // and [A-] = C for a strong one. The left side minus the right rises with // [H+], so bisection on pH always finds the one root. function pHAt(vb) { var vt = s.va + vb, c = s.ca * s.va / vt, na = s.cb * vb / vt, k = ka(), lo = -2, hi = 16; for (var i = 0; i < 70; i++) { var mid = (lo + hi) / 2, h = Math.pow(10, -mid); var a = k === Infinity ? c : c * k / (k + h); if (h + na - KW / h - a > 0) lo = mid; else hi = mid; } return (lo + hi) / 2; } function sci(x) { if (x >= 0.001) return x.toPrecision(3); var e = Math.floor(Math.log10(x)), m = x / Math.pow(10, e); if (m >= 9.995) { m /= 10; e += 1; } var sup = { '-': '⁻', '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹' }; return m.toFixed(2) + ' × 10' + String(e).split('').map(function (ch) { return sup[ch]; }).join(''); } // The route a student takes by hand in each region, and its answer. function byHand(vb) { var a = acid(), nA = s.ca * s.va, nB = s.cb * vb, vt = s.va + vb, rel = (nB - nA) / nA, excess, conc, p; var tot = 'total volume ' + f2(vt) + ' mL'; if (Math.abs(rel) < 1e-9) { if (a.strong) return { p: 7, text: '<b>At equivalence.</b> The flask holds only ' + a.salt + ' and water, a neutral salt, so pH = 7.00.' }; var c = nA / vt, kb = KW / ka(), oh = Math.sqrt(kb * c); p = 14 + Math.log10(oh); return { p: p, text: '<b>At equivalence.</b> Every acid molecule has become ' + a.ion + ', a weak base that takes H⁺ from water. By hand: K<sub>b</sub> = K<sub>w</sub> ÷ K<sub>a</sub> = ' + sci(kb) + ', c = ' + mmol(nA) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(c) + ' mol/L, [OH⁻] = √(K<sub>b</sub>·c) = ' + sci(oh) + ', so pH = 14 − pOH = ' + f2(p) + '.' }; } if (nB > nA) { excess = nB - nA; conc = excess / vt; p = 14 + Math.log10(conc); return { p: p, text: '<b>After equivalence.</b> Excess sodium hydroxide sets the pH' + (a.strong ? '' : ' and swamps the ' + a.ion) + '. By hand: [OH⁻] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = 14 − pOH = ' + f2(p) + '.' }; } if (a.strong) { excess = nA - nB; conc = excess / vt; p = -Math.log10(conc); return { p: p, text: (vb === 0 ? '<b>Acid alone.</b> Hydrochloric acid ionises completely' : '<b>Before equivalence.</b> Hydroxide has neutralised part of the acid; the rest ionises completely') + '. By hand: [H⁺] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = ' + f2(p) + '.' }; } if (vb === 0) { p = 0.5 * (a.pKa - Math.log10(s.ca)); return { p: p, text: '<b>Acid alone.</b> A weak acid ionises only a little. By hand: [H⁺] ≈ √(K<sub>a</sub>·c), so pH ≈ ½(pK<sub>a</sub> − log c) = ½(' + a.pKa.toFixed(2) + ' − log ' + s.ca.toFixed(3) + ') = ' + f2(p) + '.' }; } p = a.pKa + Math.log10(nB / (nA - nB)); return { p: p, text: '<b>Buffer region.</b> The flask holds ' + a.name + ' and ' + a.ion + ' together. By hand (Henderson–Hasselbalch): pH = pK<sub>a</sub> + log(' + a.ion + ' ÷ acid) = ' + a.pKa.toFixed(2) + ' + log(' + mmol(nB) + ' mmol ÷ ' + mmol(nA - nB) + ' mmol) = ' + f2(p) + '.' }; } function setup() { var v = veq(), a = acid(), xmax = Math.max(10, Math.ceil(2 * v / 5) * 5), xs = [], i, d; for (i = 0; i <= 320; i++) xs.push(xmax * i / 320); [1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 2e-3, 5e-3, 1e-2, 2e-2, 4e-2, 7e-2].forEach(function (d) { xs.push(v * (1 - d)); xs.push(v * (1 + d)); xs.push(v * d); }); xs.push(v); xs = xs.filter(function (x) { return x >= 0 && x <= xmax; }).sort(function (p, q) { return p - q; }); var pts = xs.map(function (x) { return [x, pHAt(x)]; }); // An indicator suits the titration when its mid-colour is reached within // 0.10 mL (two drops) either side of equivalence. var lo = pHAt(Math.max(0, v - NEAR)), hi = pHAt(v + NEAR), pe = pHAt(v); var mid = function (n) { return (n.lo + n.hi) / 2; }; var fits = INDICATORS.filter(function (n) { return mid(n) >= lo && mid(n) <= hi; }); var closest = function (list) { return list.slice().sort(function (p, q) { return Math.abs((p.lo + p.hi) / 2 - pe) - Math.abs((q.lo + q.hi) / 2 - pe); })[0]; }; var pick = fits.length ? closest(fits) : closest(INDICATORS); cache = { v: v, xmax: xmax, pts: pts, jump: [lo, hi], pe: pe, fits: fits, pick: pick, half: a.strong ? null : pHAt(v / 2) }; var slider = $('vb'); slider.max = xmax; if (s.vb > xmax) s.vb = xmax; } function draw() { var c = cache, a = acid(); var W = Math.max(260, Math.round(svg.getBoundingClientRect().width) || 588), H = W < 440 ? 230 : 252; var L = 30, R = 12, T = 12, B = 34, pw = W - L - R, ph = H - T - B; var X = function (v) { return L + v / c.xmax * pw; }; var Y = function (p) { return T + (14 - Math.max(0, Math.min(14, p))) / 14 * ph; }; var out = [], i; svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('height', H); var n = c.pick; out.push('<rect class="band" x="' + L + '" y="' + Y(n.hi) + '" width="' + pw + '" height="' + (Y(n.lo) - Y(n.hi)) + '"/>'); for (i = 0; i <= 14; i += 2) { out.push('<line class="grid" x1="' + L + '" x2="' + (L + pw) + '" y1="' + Y(i) + '" y2="' + Y(i) + '"/>'); out.push('<text class="tick" x="' + (L - 6) + '" y="' + (Y(i) + 4) + '" text-anchor="end">' + i + '</text>'); } var steps = [1, 2, 5, 10, 20, 25, 50], step = steps.filter(function (st) { return c.xmax / st <= (W < 440 ? 5 : 8); })[0] || 50; for (var x = 0; x <= c.xmax + 1e-9; x += step) { out.push('<line class="axis" x1="' + X(x) + '" x2="' + X(x) + '" y1="' + (T + ph) + '" y2="' + (T + ph + 4) + '"/>'); out.push('<text class="tick" x="' + X(x) + '" y="' + (T + ph + 16) + '" text-anchor="middle">' + x + '</text>'); } out.push('<path class="axis" d="M' + L + ' ' + T + 'V' + (T + ph) + 'H' + (L + pw) + '"/>'); out.push('<text class="atitle" x="' + (L + pw) + '" y="' + (H - 2) + '" text-anchor="end">NaOH added, mL</text>'); out.push('<text class="atitle" x="' + (L + 4) + '" y="' + (T + 10) + '">pH</text>'); out.push('<text class="bandlab halo" x="' + (L + 6) + '" y="' + (Y(n.hi) - 4) + '">' + n.name.toLowerCase() + ' changes colour</text>'); var line = function (pts) { return pts.map(function (p, j) { return (j ? 'L' : 'M') + X(p[0]).toFixed(1) + ' ' + Y(p[1]).toFixed(1); }).join(''); }; var pNow = pHAt(s.vb); var done = c.pts.filter(function (p) { return p[0] < s.vb; }).concat([[s.vb, pNow]]); var rest = [[s.vb, pNow]].concat(c.pts.filter(function (p) { return p[0] > s.vb; })); if (s.ahead && rest.length > 1) out.push('<path class="ahead" d="' + line(rest) + '"/>'); if (done.length > 1) out.push('<path class="done" d="' + line(done) + '"/>'); var ex = X(c.v), ey = Y(c.pe), right = ex < L + pw * 0.62; out.push('<line class="guide" x1="' + ex + '" x2="' + ex + '" y1="' + T + '" y2="' + (T + ph) + '"/>'); out.push('<circle class="mark" cx="' + ex + '" cy="' + ey + '" r="4.5"/>'); out.push('<text class="marklab halo" x="' + (ex + (right ? 7 : -7)) + '" y="' + (T + 12) + '" text-anchor="' + (right ? 'start' : 'end') + '">Equivalence</text>'); if (c.half !== null) { var hx = X(c.v / 2), hy = Y(c.half); out.push('<line class="guide" x1="' + L + '" x2="' + hx + '" y1="' + hy + '" y2="' + hy + '"/>'); out.push('<circle class="mark" cx="' + hx + '" cy="' + hy + '" r="4.5"/>'); out.push('<text class="marklab halo" x="' + (hx + 7) + '" y="' + (hy + 16) + '">Half-equivalence: pH = pKa</text>'); } out.push('<circle class="now" cx="' + X(s.vb) + '" cy="' + Y(pNow) + '" r="5"/>'); svg.innerHTML = out.join(''); svg.setAttribute('aria-label', 'Titration curve of pH against NaOH added, 0 to ' + c.xmax + ' mL, for ' + s.ca.toFixed(3) + ' mol/L ' + a.name + '. It rises steeply at the equivalence point, ' + f2(c.v) + ' mL, pH ' + f2(c.pe) + '. Now at ' + f2(s.vb) + ' mL, pH ' + f2(pNow) + '.'); return pNow; } function colourIn(n, p) { if (p < n.lo) return n.acid; if (p > n.hi) return n.base; return 'changing, between ' + n.acid + ' and ' + n.base; } function render() { var c = cache, a = acid(), p = draw(), hand = byHand(s.vb), n = c.pick; $('vb').value = s.vb; $('vbOut').textContent = f2(s.vb) + ' mL'; $('pOut').textContent = f2(p); $('eqOut').textContent = f2(c.v) + ' mL, pH ' + f2(c.pe); $('halfOut').textContent = c.half === null ? 'None: strong acid' : f2(c.v / 2) + ' mL, pH ' + f2(c.half); $('indOut').textContent = n.name + ', ' + n.lo.toFixed(1) + '–' + n.hi.toFixed(1); var say = hand.text; if (Math.abs(hand.p - p) >= 0.05) say += ' <i>The shortcut is off by ' + Math.abs(hand.p - p).toFixed(2) + ' here: so close to the start or to equivalence it leaves out the acid’s own ionisation or water’s. The exact pH, ' + f2(p) + ', allows for both.</i>'; if (c.v > BURETTE) say += ' <i>Equivalence needs more than one 50 mL burette: take a smaller aliquot of acid or a stronger NaOH.</i>'; $('say').innerHTML = say; var others = c.fits.filter(function (m) { return m !== n; }).map(function (m) { return m.name.toLowerCase(); }); var why = c.fits.length ? n.name + ' suits it best: its mid-colour, pH ' + ((n.lo + n.hi) / 2).toFixed(1) + ', is nearest the equivalence pH and falls inside the jump, where 0.10 mL (two drops) either side of equivalence takes the pH from ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '.' + (others.length ? ' Also inside it: ' + others.join(', ') + '.' : '') : 'No common indicator reaches its mid-colour within 0.10 mL of equivalence (pH ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '); ' + n.name.toLowerCase() + ' comes closest. Add the last millilitre drop by drop, or use a pH meter.'; $('ind').textContent = why + ' In the flask now: ' + colourIn(n, p) + '.'; } function set(v) { s.vb = Math.max(0, Math.min(cache.xmax, Math.round(v / DROP) * DROP)); s.vb = Math.round(s.vb * 100) / 100; render(); } ['acid', 'ca', 'va', 'cb'].forEach(function (id) { $(id).addEventListener('change', function () { s[id] = id === 'acid' ? this.value : Number(this.value); s.vb = 0; setup(); render(); }); }); $('vb').addEventListener('input', function () { set(Number(this.value)); }); document.querySelectorAll('[data-add]').forEach(function (b) { b.addEventListener('click', function () { set(s.vb + Number(b.getAttribute('data-add'))); }); }); $('again').addEventListener('click', function () { set(0); }); $('ahead').addEventListener('change', function () { s.ahead = this.checked; render(); }); document.addEventListener('know-tokens', render); var lastW = 0; if (window.ResizeObserver) new ResizeObserver(function (en) { var w = Math.round(en[0].contentRect.width); if (w !== lastW) { lastW = w; requestAnimationFrame(render); } }).observe(svg.parentNode); else window.addEventListener('resize', render); setup(); render(); })();Every practical in the same six parts, every hazard checked against your risk assessment, and a titration model the class watches jump at equivalence. See the recipe
-
27A mechanical engineering design notebookLabs and workshops
A disc brake caliper, in section. Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.
A disc brake caliper, in section. Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.
<div class="brake"> <svg viewBox="0 0 660 330" role="img" aria-labelledby="brake-desc"> <desc id="brake-desc">Cross-section of a disc brake caliper beside a face-on view of the disc. The labels and figures below it describe the same state in words.</desc> <defs> <pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" class="hatch"/></pattern> <pattern id="fluid" width="6" height="6" patternUnits="userSpaceOnUse"><circle cx="3" cy="3" r="1" class="dot"/></pattern> <pattern id="friction" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" class="grit"/><circle cx="1" cy="1" r=".8" class="dot"/><circle cx="3" cy="3" r=".8" class="dot"/></pattern> <marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" class="headfill"/></marker> </defs> <g data-part="bracket"> <rect x="150" y="10" width="300" height="8" class="fixed"/> <line x1="170" y1="14" x2="430" y2="14" class="pin"/> <text x="155" y="34" class="lab">Bracket and slide pins, bolted to the knuckle</text> </g> <g id="body" data-part="body"> <path id="bodyShape" class="body" fill="url(#hatch)"/> </g> <g data-part="fluid"><rect id="fluid" y="138" height="64" fill="url(#fluid)" class="fluidbox"/></g> <g data-part="fluid"><path id="hose" class="hose"/></g> <g data-part="piston"><rect id="pistonL" y="140" height="60" class="piston"/><rect id="pistonR" y="140" height="60" class="piston"/></g> <g data-part="seal"><path id="sealL" class="seal"/><path id="sealR" class="seal"/></g> <g data-part="pads"> <rect id="padInBack" y="112" width="6" height="116" class="backing"/> <rect id="padIn" y="118" height="104" fill="url(#friction)" class="padface"/> <rect id="padOutBack" y="112" width="6" height="116" class="backing"/> <rect id="padOut" y="118" height="104" fill="url(#friction)" class="padface"/> </g> <g data-part="disc"> <rect x="318" y="60" width="24" height="230" class="disc"/> <text x="330" y="310" text-anchor="middle" class="lab">Disc</text> </g> <g id="arrows" class="arrows"> <line id="clampIn" y1="170" y2="170" marker-end="url(#head)"/> <line id="clampOut" y1="170" y2="170" marker-end="url(#head)"/> </g> <text x="200" y="92" class="lab">Caliper body</text> <text x="372" y="256" class="lab">Pads</text> <text x="36" y="306" class="lab">Inboard (car side)</text> <text x="420" y="306" class="lab" text-anchor="end">Outboard</text> <text x="40" y="258" class="lab">Piston and square seal</text> <g transform="translate(560 170)"> <g id="rotor" data-part="disc"> <circle r="84" class="discface"/> <circle r="30" class="hub"/> <g id="vents"></g> </g> <path d="M-34 -96 A100 100 0 0 1 34 -96 L26 -70 A74 74 0 0 0 -26 -70 Z" class="padarc" fill="url(#friction)" data-part="pads"/> <line id="frictionArrow" x1="-40" y1="-110" x2="40" y2="-110" marker-end="url(#head)" class="arrow"/> <text y="120" text-anchor="middle" class="lab">Face on</text> </g> </svg> <div class="controls"> <label>Pedal force <b id="pedalOut">0 N</b><input id="pedal" type="range" min="0" max="400" step="5" value="0"></label> <label>Pad left <b id="wearOut">10 mm</b><input id="wear" type="range" min="2" max="10" step="0.5" value="10"></label> <div class="switches"> <button type="button" id="kind" aria-pressed="false">Fixed caliper</button> <button type="button" id="boost" aria-pressed="true">Booster on</button> <button type="button" id="drive">Drive at 100 km/h</button> </div> </div> <dl class="figures"> <div><dt>Line pressure</dt><dd id="pOut">0 bar</dd></div> <div><dt>Clamp force</dt><dd id="fOut">0 kN</dd></div> <div><dt>Friction at the pads</dt><dd id="frOut">0 kN</dd></div> <div><dt>Brake torque, one wheel</dt><dd id="tOut">0 N·m</dd></div> <div><dt>Car</dt><dd id="vOut">100 km/h</dd></div> </dl> <p class="say" id="say" aria-live="polite"></p> <div class="walk"> <button type="button" id="prev">Back</button> <span id="stepNo">Walk through</span> <button type="button" id="next">Next</button> </div> <p class="step" id="stepText"></p> </div>A disc brake caliper, in section. Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.
.brake svg { display: block; width: 100%; height: auto; } .brake text { font: 400 11px var(--sans); fill: var(--ink-2); } .lab { font-size: 11px; } .hatch { stroke: var(--ink-3); stroke-width: 1; } .dot { fill: var(--ink-2); } .grit { fill: var(--wash); } .headfill { fill: var(--ink); } .fixed { fill: var(--wash); stroke: var(--ink); stroke-width: 1; } .pin { stroke: var(--ink); stroke-width: 3; } .body { stroke: var(--ink); stroke-width: 1.5; } .fluidbox { stroke: none; } .hose { fill: none; stroke: var(--ink); stroke-width: 6; } .piston { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .seal { fill: var(--ink); } .backing { fill: var(--ink); } .padface { stroke: var(--ink); stroke-width: 1; } .disc { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .discface { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .hub { fill: var(--wash); stroke: var(--ink); stroke-width: 1; } .vent { stroke: var(--ink-3); stroke-width: 1.5; } .padarc { stroke: var(--ink); stroke-width: 1; } .arrows line, .arrow { stroke: var(--ink); stroke-width: 2; } [data-part] { transition: opacity 120ms; } .brake.focus [data-part] { opacity: .22; } .brake.focus [data-part].on { opacity: 1; } .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; margin-top: 10px; font: 400 14px/1.3 var(--sans); } .controls label { display: grid; gap: 4px; } .controls b { font-weight: 600; font-variant-numeric: tabular-nums; } .controls input { width: 100%; accent-color: var(--ink); } .switches { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; } button { font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; padding: 10px 12px; min-height: 40px; cursor: pointer; } button[aria-pressed='true'] { background: var(--ink); color: var(--paper); } button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 14px 0 0; border-top: 1px solid var(--ink); } .figures div { padding: 7px 0; border-bottom: 1px solid var(--rule); } .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); } .figures dd { margin: 2px 0 0; font: 400 20px/1.2 var(--serif); font-variant-numeric: tabular-nums; } .say { min-height: 1.4em; margin: 8px 0 0; font: italic 400 16px/1.45 var(--serif); } .walk { display: flex; align-items: center; gap: 12px; margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--rule); font: 400 13px var(--sans); color: var(--ink-3); } .walk span { flex: 1; text-align: center; } .step { min-height: 3em; margin: 8px 0 0; font: 400 16px/1.5 var(--serif); } @media (max-width: 520px) { .controls { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { [data-part] { transition: none; } }A disc brake caliper, in section. Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.
(function () { var $ = function (id) { return document.getElementById(id); }; var root = document.querySelector('.brake'); // Millimetres to drawing units; the running clearance is drawn ten times larger than life. var MM = 2.2, CLEAR = 0.15 * 10 * MM, DISC_L = 318, DISC_R = 342, BACK = 6; var PEDAL_RATIO = 4, BOOST = 3, MC_BORE = 22, MU = 0.4, R_EFF = 0.13, TYRE_R = 0.31, MASS = 1400, REAR_SHARE = 0.6, GRIP = 9.81; var CALIPERS = { floating: { name: 'floating', pistonBore: 54, pistons: 1 }, fixed: { name: 'fixed', pistonBore: 40, pistons: 2 } }; var state = { pedal: 0, wear: 10, kind: 'floating', boost: true, v: 100, step: -1 }; var area = function (d) { return Math.PI * d * d / 4; }; function physics() { var cal = CALIPERS[state.kind]; var force = state.pedal * PEDAL_RATIO * (state.boost ? BOOST : 1); var p = force / area(MC_BORE); // N/mm² = MPa var pistonArea = area(cal.pistonBore) * cal.pistons; // per side, mm² var clamp = p > 0.05 ? p * pistonArea : 0; // N, on each pad var friction = 2 * MU * clamp; // two faces var torque = friction * R_EFF; // N·m var decel = torque * 2 * (1 + REAR_SHARE) / (TYRE_R * MASS); // m/s², front pair plus a lighter rear pair return { p: p, clamp: clamp, friction: friction, torque: torque, decel: decel, locked: decel > GRIP }; } function layout(ph) { var fixed = state.kind === 'fixed'; var pad = state.wear * MM, contact = ph.clamp > 0 ? 1 : 0, wearTake = (10 - state.wear) * MM; var inFace = DISC_L - CLEAR * (1 - contact); // inner pad's friction face var outFace = DISC_R + CLEAR * (1 - contact); var padInX = inFace - pad, backInX = padInX - BACK; var padOutX = outFace, backOutX = padOutX + pad; // A floating body is pulled inboard by as much as the outer pad needs; a fixed body never moves. var shift = fixed ? 0 : -(wearTake + CLEAR * contact); $('padIn').setAttribute('x', padInX); $('padIn').setAttribute('width', pad); $('padInBack').setAttribute('x', backInX); $('padOut').setAttribute('x', padOutX); $('padOut').setAttribute('width', pad); $('padOutBack').setAttribute('x', backOutX); var leftWall = 175 + shift, rightWall = backOutX + BACK; var pistonX = Math.min(leftWall + 40, backInX - 70); $('pistonL').setAttribute('x', pistonX); $('pistonL').setAttribute('width', backInX - pistonX); var fluidX = leftWall + 12, fluidW = Math.max(4, pistonX - fluidX); $('fluid').setAttribute('x', fluidX); $('fluid').setAttribute('width', fluidW); var body; if (fixed) { var rWall = backOutX + BACK + 70; $('pistonR').style.display = ''; $('pistonR').setAttribute('x', backOutX + BACK); $('pistonR').setAttribute('width', rWall - 12 - (backOutX + BACK)); body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (backOutX + BACK + 4) + ' V100 H' + rWall + ' V240 H' + (backOutX + BACK + 4) + ' V230 H' + (backOutX + BACK) + ' V110 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z'; } else { $('pistonR').style.display = 'none'; body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (rightWall + 26) + ' V240 H' + rightWall + ' V112 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z'; } $('bodyShape').setAttribute('d', body); var sealX = pistonX + 18, tilt = contact ? 4 : 0; $('sealL').setAttribute('d', 'M' + sealX + ' 134 l8 ' + (-tilt) + ' v6 l-8 ' + tilt + ' z M' + sealX + ' 206 l8 ' + tilt + ' v-6 l-8 ' + (-tilt) + ' z'); $('sealR').setAttribute('d', fixed ? 'M' + (backOutX + BACK + 40) + ' 134 l-8 ' + (-tilt) + ' v6 l8 ' + tilt + ' z' : ''); $('hose').setAttribute('d', 'M20 170 H' + (fluidX + 2)); var len = Math.min(70, 8 + ph.clamp / 500); var show = ph.clamp > 0 ? '' : 'none'; $('clampIn').style.display = show; $('clampOut').style.display = show; $('clampIn').setAttribute('x1', backInX - len - 8); $('clampIn').setAttribute('x2', backInX - 6); $('clampOut').setAttribute('x1', backOutX + BACK + len + 30); $('clampOut').setAttribute('x2', backOutX + BACK + 24); $('frictionArrow').style.display = ph.clamp > 0 && state.v > 0 ? '' : 'none'; } function fmt(n, d) { return n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d }); } function figures(ph) { $('pedalOut').textContent = state.pedal + ' N'; $('wearOut').textContent = fmt(state.wear, 1).replace('.0', '') + ' mm'; $('pOut').textContent = fmt(ph.p * 10, 0) + ' bar'; $('fOut').textContent = fmt(ph.clamp / 1000, 1) + ' kN'; $('frOut').textContent = fmt(ph.friction / 1000, 1) + ' kN'; $('tOut').textContent = fmt(ph.torque, 0) + ' N·m'; $('vOut').textContent = fmt(state.v, 0) + ' km/h'; var msg = ''; if (state.v <= 0 && state.pedal > 0) msg = 'Stopped.'; else if (ph.locked && state.v > 0) msg = 'The brakes ask for ' + fmt(ph.decel / 9.81, 1) + ' g; the tyres give about 1 g. Without ABS the wheels lock and slide.'; else if (ph.clamp > 0) msg = 'Slowing at ' + fmt(ph.decel / 9.81, 2) + ' g.'; else if (state.v > 0) msg = 'Rolling. Press the pedal.'; $('say').textContent = msg; } var STEPS = [ ['bracket', 'The bracket is bolted to the steering knuckle and never moves. The caliper body rides on two greased slide pins fixed to it.'], ['fluid', 'The pedal pushes the master cylinder. Brake fluid does not compress, so the pressure it makes arrives unchanged in the caliper bore: pressure equals force over the master cylinder\'s area.'], ['piston', 'That pressure acts on the piston\'s much larger face. Clamp force is pressure times piston area, which is where the hydraulic advantage comes from: a 54 mm piston has six times the area of a 22 mm master cylinder bore.'], ['body', 'In a floating caliper the piston pushes the inner pad onto the disc; the reaction slides the whole body inboard on its pins, pulling the outer pad against the other face. A fixed caliper has pistons on both sides and its body does not move.'], ['pads', 'Both pads rub the disc. Friction is twice (two faces) the pad coefficient times the clamp force, and braking torque is that friction times the effective radius, here 130 mm.'], ['seal', 'On release, the square-section seal, twisted as the piston moved, springs back and draws the piston a fraction of a millimetre away from the pad. That is the running clearance; as the pads wear the piston slides further through the seal, and the fluid in the bore makes up the difference.'] ]; function showStep() { var s = state.step; root.classList.toggle('focus', s >= 0); root.querySelectorAll('[data-part]').forEach(function (el) { el.classList.toggle('on', s >= 0 && el.getAttribute('data-part') === STEPS[s][0]); }); $('stepNo').textContent = s < 0 ? 'Walk through' : 'Step ' + (s + 1) + ' of ' + STEPS.length; $('stepText').textContent = s < 0 ? '' : STEPS[s][1]; $('prev').disabled = s < 0; $('next').textContent = s === STEPS.length - 1 ? 'Done' : 'Next'; } function render() { var ph = physics(); layout(ph); figures(ph); return ph; } var angle = 0, last = 0; (function vents() { var g = $('vents'), out = ''; for (var i = 0; i < 12; i++) { var a = i * Math.PI / 6; out += '<line class="vent" x1="' + (Math.cos(a) * 42) + '" y1="' + (Math.sin(a) * 42) + '" x2="' + (Math.cos(a) * 76) + '" y2="' + (Math.sin(a) * 76) + '"/>'; } g.innerHTML = out; })(); function tick(t) { var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t; var ph = physics(); if (state.v > 0 && ph.clamp > 0) state.v = Math.max(0, state.v - Math.min(ph.decel, GRIP) * dt * 3.6); var omega = (state.v / 3.6) / TYRE_R; // rad/s angle = (angle + omega * dt * 57.3 * 0.25) % 360; // drawn at a quarter speed, so it can be seen $('rotor').setAttribute('transform', 'rotate(' + angle + ')'); figures(ph); requestAnimationFrame(tick); } $('pedal').addEventListener('input', function (e) { state.pedal = Number(e.target.value); render(); }); $('wear').addEventListener('input', function (e) { state.wear = Number(e.target.value); render(); }); $('kind').addEventListener('click', function () { state.kind = state.kind === 'fixed' ? 'floating' : 'fixed'; this.setAttribute('aria-pressed', String(state.kind === 'fixed')); render(); }); $('boost').addEventListener('click', function () { state.boost = !state.boost; this.setAttribute('aria-pressed', String(state.boost)); this.textContent = state.boost ? 'Booster on' : 'Booster off'; render(); }); $('drive').addEventListener('click', function () { state.v = 100; render(); }); $('next').addEventListener('click', function () { state.step = state.step >= STEPS.length - 1 ? -1 : state.step + 1; showStep(); }); $('prev').addEventListener('click', function () { state.step = Math.max(-1, state.step - 1); showStep(); }); render(); showStep(); requestAnimationFrame(tick); })();A disc brake caliper, in section
Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.
<div class="brake"> <svg viewBox="0 0 660 330" role="img" aria-labelledby="brake-desc"> <desc id="brake-desc">Cross-section of a disc brake caliper beside a face-on view of the disc. The labels and figures below it describe the same state in words.</desc> <defs> <pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" class="hatch"/></pattern> <pattern id="fluid" width="6" height="6" patternUnits="userSpaceOnUse"><circle cx="3" cy="3" r="1" class="dot"/></pattern> <pattern id="friction" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" class="grit"/><circle cx="1" cy="1" r=".8" class="dot"/><circle cx="3" cy="3" r=".8" class="dot"/></pattern> <marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" class="headfill"/></marker> </defs> <g data-part="bracket"> <rect x="150" y="10" width="300" height="8" class="fixed"/> <line x1="170" y1="14" x2="430" y2="14" class="pin"/> <text x="155" y="34" class="lab">Bracket and slide pins, bolted to the knuckle</text> </g> <g id="body" data-part="body"> <path id="bodyShape" class="body" fill="url(#hatch)"/> </g> <g data-part="fluid"><rect id="fluid" y="138" height="64" fill="url(#fluid)" class="fluidbox"/></g> <g data-part="fluid"><path id="hose" class="hose"/></g> <g data-part="piston"><rect id="pistonL" y="140" height="60" class="piston"/><rect id="pistonR" y="140" height="60" class="piston"/></g> <g data-part="seal"><path id="sealL" class="seal"/><path id="sealR" class="seal"/></g> <g data-part="pads"> <rect id="padInBack" y="112" width="6" height="116" class="backing"/> <rect id="padIn" y="118" height="104" fill="url(#friction)" class="padface"/> <rect id="padOutBack" y="112" width="6" height="116" class="backing"/> <rect id="padOut" y="118" height="104" fill="url(#friction)" class="padface"/> </g> <g data-part="disc"> <rect x="318" y="60" width="24" height="230" class="disc"/> <text x="330" y="310" text-anchor="middle" class="lab">Disc</text> </g> <g id="arrows" class="arrows"> <line id="clampIn" y1="170" y2="170" marker-end="url(#head)"/> <line id="clampOut" y1="170" y2="170" marker-end="url(#head)"/> </g> <text x="200" y="92" class="lab">Caliper body</text> <text x="372" y="256" class="lab">Pads</text> <text x="36" y="306" class="lab">Inboard (car side)</text> <text x="420" y="306" class="lab" text-anchor="end">Outboard</text> <text x="40" y="258" class="lab">Piston and square seal</text> <g transform="translate(560 170)"> <g id="rotor" data-part="disc"> <circle r="84" class="discface"/> <circle r="30" class="hub"/> <g id="vents"></g> </g> <path d="M-34 -96 A100 100 0 0 1 34 -96 L26 -70 A74 74 0 0 0 -26 -70 Z" class="padarc" fill="url(#friction)" data-part="pads"/> <line id="frictionArrow" x1="-40" y1="-110" x2="40" y2="-110" marker-end="url(#head)" class="arrow"/> <text y="120" text-anchor="middle" class="lab">Face on</text> </g> </svg> <div class="controls"> <label>Pedal force <b id="pedalOut">0 N</b><input id="pedal" type="range" min="0" max="400" step="5" value="0"></label> <label>Pad left <b id="wearOut">10 mm</b><input id="wear" type="range" min="2" max="10" step="0.5" value="10"></label> <div class="switches"> <button type="button" id="kind" aria-pressed="false">Fixed caliper</button> <button type="button" id="boost" aria-pressed="true">Booster on</button> <button type="button" id="drive">Drive at 100 km/h</button> </div> </div> <dl class="figures"> <div><dt>Line pressure</dt><dd id="pOut">0 bar</dd></div> <div><dt>Clamp force</dt><dd id="fOut">0 kN</dd></div> <div><dt>Friction at the pads</dt><dd id="frOut">0 kN</dd></div> <div><dt>Brake torque, one wheel</dt><dd id="tOut">0 N·m</dd></div> <div><dt>Car</dt><dd id="vOut">100 km/h</dd></div> </dl> <p class="say" id="say" aria-live="polite"></p> <div class="walk"> <button type="button" id="prev">Back</button> <span id="stepNo">Walk through</span> <button type="button" id="next">Next</button> </div> <p class="step" id="stepText"></p> </div>.brake svg { display: block; width: 100%; height: auto; } .brake text { font: 400 11px var(--sans); fill: var(--ink-2); } .lab { font-size: 11px; } .hatch { stroke: var(--ink-3); stroke-width: 1; } .dot { fill: var(--ink-2); } .grit { fill: var(--wash); } .headfill { fill: var(--ink); } .fixed { fill: var(--wash); stroke: var(--ink); stroke-width: 1; } .pin { stroke: var(--ink); stroke-width: 3; } .body { stroke: var(--ink); stroke-width: 1.5; } .fluidbox { stroke: none; } .hose { fill: none; stroke: var(--ink); stroke-width: 6; } .piston { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .seal { fill: var(--ink); } .backing { fill: var(--ink); } .padface { stroke: var(--ink); stroke-width: 1; } .disc { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .discface { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } .hub { fill: var(--wash); stroke: var(--ink); stroke-width: 1; } .vent { stroke: var(--ink-3); stroke-width: 1.5; } .padarc { stroke: var(--ink); stroke-width: 1; } .arrows line, .arrow { stroke: var(--ink); stroke-width: 2; } [data-part] { transition: opacity 120ms; } .brake.focus [data-part] { opacity: .22; } .brake.focus [data-part].on { opacity: 1; } .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; margin-top: 10px; font: 400 14px/1.3 var(--sans); } .controls label { display: grid; gap: 4px; } .controls b { font-weight: 600; font-variant-numeric: tabular-nums; } .controls input { width: 100%; accent-color: var(--ink); } .switches { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; } button { font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; padding: 10px 12px; min-height: 40px; cursor: pointer; } button[aria-pressed='true'] { background: var(--ink); color: var(--paper); } button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 14px 0 0; border-top: 1px solid var(--ink); } .figures div { padding: 7px 0; border-bottom: 1px solid var(--rule); } .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); } .figures dd { margin: 2px 0 0; font: 400 20px/1.2 var(--serif); font-variant-numeric: tabular-nums; } .say { min-height: 1.4em; margin: 8px 0 0; font: italic 400 16px/1.45 var(--serif); } .walk { display: flex; align-items: center; gap: 12px; margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--rule); font: 400 13px var(--sans); color: var(--ink-3); } .walk span { flex: 1; text-align: center; } .step { min-height: 3em; margin: 8px 0 0; font: 400 16px/1.5 var(--serif); } @media (max-width: 520px) { .controls { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { [data-part] { transition: none; } }(function () { var $ = function (id) { return document.getElementById(id); }; var root = document.querySelector('.brake'); // Millimetres to drawing units; the running clearance is drawn ten times larger than life. var MM = 2.2, CLEAR = 0.15 * 10 * MM, DISC_L = 318, DISC_R = 342, BACK = 6; var PEDAL_RATIO = 4, BOOST = 3, MC_BORE = 22, MU = 0.4, R_EFF = 0.13, TYRE_R = 0.31, MASS = 1400, REAR_SHARE = 0.6, GRIP = 9.81; var CALIPERS = { floating: { name: 'floating', pistonBore: 54, pistons: 1 }, fixed: { name: 'fixed', pistonBore: 40, pistons: 2 } }; var state = { pedal: 0, wear: 10, kind: 'floating', boost: true, v: 100, step: -1 }; var area = function (d) { return Math.PI * d * d / 4; }; function physics() { var cal = CALIPERS[state.kind]; var force = state.pedal * PEDAL_RATIO * (state.boost ? BOOST : 1); var p = force / area(MC_BORE); // N/mm² = MPa var pistonArea = area(cal.pistonBore) * cal.pistons; // per side, mm² var clamp = p > 0.05 ? p * pistonArea : 0; // N, on each pad var friction = 2 * MU * clamp; // two faces var torque = friction * R_EFF; // N·m var decel = torque * 2 * (1 + REAR_SHARE) / (TYRE_R * MASS); // m/s², front pair plus a lighter rear pair return { p: p, clamp: clamp, friction: friction, torque: torque, decel: decel, locked: decel > GRIP }; } function layout(ph) { var fixed = state.kind === 'fixed'; var pad = state.wear * MM, contact = ph.clamp > 0 ? 1 : 0, wearTake = (10 - state.wear) * MM; var inFace = DISC_L - CLEAR * (1 - contact); // inner pad's friction face var outFace = DISC_R + CLEAR * (1 - contact); var padInX = inFace - pad, backInX = padInX - BACK; var padOutX = outFace, backOutX = padOutX + pad; // A floating body is pulled inboard by as much as the outer pad needs; a fixed body never moves. var shift = fixed ? 0 : -(wearTake + CLEAR * contact); $('padIn').setAttribute('x', padInX); $('padIn').setAttribute('width', pad); $('padInBack').setAttribute('x', backInX); $('padOut').setAttribute('x', padOutX); $('padOut').setAttribute('width', pad); $('padOutBack').setAttribute('x', backOutX); var leftWall = 175 + shift, rightWall = backOutX + BACK; var pistonX = Math.min(leftWall + 40, backInX - 70); $('pistonL').setAttribute('x', pistonX); $('pistonL').setAttribute('width', backInX - pistonX); var fluidX = leftWall + 12, fluidW = Math.max(4, pistonX - fluidX); $('fluid').setAttribute('x', fluidX); $('fluid').setAttribute('width', fluidW); var body; if (fixed) { var rWall = backOutX + BACK + 70; $('pistonR').style.display = ''; $('pistonR').setAttribute('x', backOutX + BACK); $('pistonR').setAttribute('width', rWall - 12 - (backOutX + BACK)); body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (backOutX + BACK + 4) + ' V100 H' + rWall + ' V240 H' + (backOutX + BACK + 4) + ' V230 H' + (backOutX + BACK) + ' V110 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z'; } else { $('pistonR').style.display = 'none'; body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (rightWall + 26) + ' V240 H' + rightWall + ' V112 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z'; } $('bodyShape').setAttribute('d', body); var sealX = pistonX + 18, tilt = contact ? 4 : 0; $('sealL').setAttribute('d', 'M' + sealX + ' 134 l8 ' + (-tilt) + ' v6 l-8 ' + tilt + ' z M' + sealX + ' 206 l8 ' + tilt + ' v-6 l-8 ' + (-tilt) + ' z'); $('sealR').setAttribute('d', fixed ? 'M' + (backOutX + BACK + 40) + ' 134 l-8 ' + (-tilt) + ' v6 l8 ' + tilt + ' z' : ''); $('hose').setAttribute('d', 'M20 170 H' + (fluidX + 2)); var len = Math.min(70, 8 + ph.clamp / 500); var show = ph.clamp > 0 ? '' : 'none'; $('clampIn').style.display = show; $('clampOut').style.display = show; $('clampIn').setAttribute('x1', backInX - len - 8); $('clampIn').setAttribute('x2', backInX - 6); $('clampOut').setAttribute('x1', backOutX + BACK + len + 30); $('clampOut').setAttribute('x2', backOutX + BACK + 24); $('frictionArrow').style.display = ph.clamp > 0 && state.v > 0 ? '' : 'none'; } function fmt(n, d) { return n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d }); } function figures(ph) { $('pedalOut').textContent = state.pedal + ' N'; $('wearOut').textContent = fmt(state.wear, 1).replace('.0', '') + ' mm'; $('pOut').textContent = fmt(ph.p * 10, 0) + ' bar'; $('fOut').textContent = fmt(ph.clamp / 1000, 1) + ' kN'; $('frOut').textContent = fmt(ph.friction / 1000, 1) + ' kN'; $('tOut').textContent = fmt(ph.torque, 0) + ' N·m'; $('vOut').textContent = fmt(state.v, 0) + ' km/h'; var msg = ''; if (state.v <= 0 && state.pedal > 0) msg = 'Stopped.'; else if (ph.locked && state.v > 0) msg = 'The brakes ask for ' + fmt(ph.decel / 9.81, 1) + ' g; the tyres give about 1 g. Without ABS the wheels lock and slide.'; else if (ph.clamp > 0) msg = 'Slowing at ' + fmt(ph.decel / 9.81, 2) + ' g.'; else if (state.v > 0) msg = 'Rolling. Press the pedal.'; $('say').textContent = msg; } var STEPS = [ ['bracket', 'The bracket is bolted to the steering knuckle and never moves. The caliper body rides on two greased slide pins fixed to it.'], ['fluid', 'The pedal pushes the master cylinder. Brake fluid does not compress, so the pressure it makes arrives unchanged in the caliper bore: pressure equals force over the master cylinder\'s area.'], ['piston', 'That pressure acts on the piston\'s much larger face. Clamp force is pressure times piston area, which is where the hydraulic advantage comes from: a 54 mm piston has six times the area of a 22 mm master cylinder bore.'], ['body', 'In a floating caliper the piston pushes the inner pad onto the disc; the reaction slides the whole body inboard on its pins, pulling the outer pad against the other face. A fixed caliper has pistons on both sides and its body does not move.'], ['pads', 'Both pads rub the disc. Friction is twice (two faces) the pad coefficient times the clamp force, and braking torque is that friction times the effective radius, here 130 mm.'], ['seal', 'On release, the square-section seal, twisted as the piston moved, springs back and draws the piston a fraction of a millimetre away from the pad. That is the running clearance; as the pads wear the piston slides further through the seal, and the fluid in the bore makes up the difference.'] ]; function showStep() { var s = state.step; root.classList.toggle('focus', s >= 0); root.querySelectorAll('[data-part]').forEach(function (el) { el.classList.toggle('on', s >= 0 && el.getAttribute('data-part') === STEPS[s][0]); }); $('stepNo').textContent = s < 0 ? 'Walk through' : 'Step ' + (s + 1) + ' of ' + STEPS.length; $('stepText').textContent = s < 0 ? '' : STEPS[s][1]; $('prev').disabled = s < 0; $('next').textContent = s === STEPS.length - 1 ? 'Done' : 'Next'; } function render() { var ph = physics(); layout(ph); figures(ph); return ph; } var angle = 0, last = 0; (function vents() { var g = $('vents'), out = ''; for (var i = 0; i < 12; i++) { var a = i * Math.PI / 6; out += '<line class="vent" x1="' + (Math.cos(a) * 42) + '" y1="' + (Math.sin(a) * 42) + '" x2="' + (Math.cos(a) * 76) + '" y2="' + (Math.sin(a) * 76) + '"/>'; } g.innerHTML = out; })(); function tick(t) { var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t; var ph = physics(); if (state.v > 0 && ph.clamp > 0) state.v = Math.max(0, state.v - Math.min(ph.decel, GRIP) * dt * 3.6); var omega = (state.v / 3.6) / TYRE_R; // rad/s angle = (angle + omega * dt * 57.3 * 0.25) % 360; // drawn at a quarter speed, so it can be seen $('rotor').setAttribute('transform', 'rotate(' + angle + ')'); figures(ph); requestAnimationFrame(tick); } $('pedal').addEventListener('input', function (e) { state.pedal = Number(e.target.value); render(); }); $('wear').addEventListener('input', function (e) { state.wear = Number(e.target.value); render(); }); $('kind').addEventListener('click', function () { state.kind = state.kind === 'fixed' ? 'floating' : 'fixed'; this.setAttribute('aria-pressed', String(state.kind === 'fixed')); render(); }); $('boost').addEventListener('click', function () { state.boost = !state.boost; this.setAttribute('aria-pressed', String(state.boost)); this.textContent = state.boost ? 'Booster on' : 'Booster off'; render(); }); $('drive').addEventListener('click', function () { state.v = 100; render(); }); $('next').addEventListener('click', function () { state.step = state.step >= STEPS.length - 1 ? -1 : state.step + 1; showStep(); }); $('prev').addEventListener('click', function () { state.step = Math.max(-1, state.step - 1); showStep(); }); render(); showStep(); requestAnimationFrame(tick); })();Pedal to pad in six worked steps, a brake caliper you can squeeze, and every design choice kept with the figures that made it. See the recipe
-
39A family cookbookKitchen and table
Pound cake with lemon glazeBaking, 7
- Cook
- Nana Rose, from 1961
- Pan
- Tube pan, by Aunt Jo; two loaf pans, by Pete
- Oven
- 325°F (160°C), started cold
- Time
- About 90 minutes
- The card
- In Rose’s hand, the glaze in pencil; in the box behind the Baking tab
- Contains
- Milk, eggs, wheat
The cold oven is not on the card; see What the card does not say.
Pound cake with lemon glaze · Baking, 7
Cook: Nana Rose, from 1961
Pan: Tube pan, by Aunt Jo; two loaf pans, by Pete
Oven: 325°F (160°C), started cold
Time: About 90 minutes
The card: In Rose’s hand, the glaze in pencil; in the box behind the Baking tab
Contains: Milk, eggs, wheat
The cold oven is not on the card; see What the card does not say.
Photograph the recipe box, let your assistant file every card as a recipe, add the stories only the family knows, and send everyone a link. See the recipe
-
30A service-mesh workshopLabs and workshops
Kubernetes and Istio simulator. Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.
Kubernetes and Istio simulator. Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.
<div class="kubectl-sim" tabindex="0" aria-label="Kubernetes and Istio cluster simulator"> <header class="bar"> <div class="bar-title"> <span class="cluster-ctx">Cluster <b>mesh-lab</b></span> <span class="ns-ctx">Namespace <b id="activeNs">shop</b></span> </div> <div class="bar-actions"> <button type="button" id="btnLoad">Load (100 reqs)</button> <button type="button" id="btnReset">Reset</button> <button type="button" id="btnHelp">Help</button> </div> </header> <div class="sim-body"> <section class="term-pane" aria-label="Terminal"> <div class="term-log" id="termLog" role="log" aria-live="polite"></div> <div class="term-form" id="termForm"> <label for="termInput" class="term-prompt">$</label> <input id="termInput" class="term-input" type="text" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-label="kubectl command input" /> </div> </section> <aside class="side-pane"> <section class="panel exercises-panel"> <h3 class="panel-title">Guided exercises</h3> <ol class="exercise-list" id="exerciseList"> <li data-step="1"><span class="check" id="chk1">○</span> <span class="step-text">Enable injection on namespace shop</span></li> <li data-step="2"><span class="check" id="chk2">○</span> <span class="step-text">Restart reviews to inject sidecars (2/2)</span></li> <li data-step="3"><span class="check" id="chk3">○</span> <span class="step-text">Split reviews traffic 50/50 (v2/v3)</span></li> <li data-step="4"><span class="check" id="chk4">○</span> <span class="step-text">Add 2 s delay to ratings (20% reqs)</span></li> <li data-step="5"><span class="check" id="chk5">○</span> <span class="step-text">Turn on strict mTLS</span></li> </ol> <div class="hint-bar"> <button type="button" id="btnHint">Hint</button> <span class="hint-text" id="hintOutput">Type hint or click for guidance.</span> </div> </section> <section class="panel mesh-panel"> <div class="panel-header"> <h3 class="panel-title">Traffic topology</h3> <span class="mtls-status" id="mtlsStatus">mTLS permissive</span> </div> <svg class="mesh-svg" id="meshSvg" viewBox="0 0 380 180" role="img" aria-label="Bookinfo service topology diagram"></svg> <div class="traffic-stats" id="trafficStats"> <span class="stat">Total: <b id="statTotal">0</b></span> <span class="stat">v1: <b id="statV1">34%</b></span> <span class="stat">v2: <b id="statV2">33%</b></span> <span class="stat">v3: <b id="statV3">33%</b></span> <span class="stat">Ratings: <b id="statRatings">0</b></span> <span class="stat">Latency: <b id="statLatency">12 ms</b></span> </div> </section> </aside> </div> </div>Kubernetes and Istio simulator. Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.
.kubectl-sim { position: relative; outline: none; font-family: var(--sans); color: var(--ink); background: var(--paper); } .kubectl-sim:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; } .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--rule); background: var(--paper); font: 400 13px/1.3 var(--sans); color: var(--ink-2); } .bar b { font-weight: 500; color: var(--ink); } .bar-title { display: flex; align-items: center; gap: 16px; } .bar-actions { display: flex; gap: 8px; } .bar-actions button, .hint-bar button { min-height: 28px; padding: 4px 10px; font: 500 13px/1 var(--sans); background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; cursor: pointer; } .bar-actions button:hover, .hint-bar button:hover, .bar-actions button:focus-visible, .hint-bar button:focus-visible { background: var(--wash); } .sim-body { display: flex; flex-direction: column; gap: 16px; margin-top: 12px; } @media (min-width: 860px) { .sim-body { flex-direction: row; align-items: flex-start; } .term-pane { flex: 1 1 56%; } .side-pane { flex: 1 1 44%; } } .term-pane { display: flex; flex-direction: column; background: var(--paper); border: 1px solid var(--rule); } .term-log { flex: 1; max-height: 380px; min-height: 240px; overflow-y: auto; overflow-x: hidden; padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-line { font-weight: 600; color: var(--ink); margin-top: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-out { color: var(--ink-2); margin-bottom: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-err { color: var(--ink); font-style: italic; margin-bottom: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-form { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-top: 1px solid var(--rule); background: var(--paper); } .term-prompt { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--ink); user-select: none; } .term-input { flex: 1; font-family: var(--mono); font-size: 12.5px; color: var(--ink); background: transparent; border: none; outline: none; min-height: 28px; } .side-pane { display: flex; flex-direction: column; gap: 16px; } .panel { padding-bottom: 14px; border-bottom: 1px solid var(--rule); } .panel:last-child { border-bottom: none; } .panel-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; } .panel-title { margin: 0 0 8px; font: 600 13px/1.3 var(--sans); color: var(--ink); } .exercise-list { list-style: none; padding: 0; margin: 0; } .exercise-list li { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; font: 400 13px/1.3 var(--sans); color: var(--ink-2); } .exercise-list .check { font-family: var(--mono); font-size: 13px; font-weight: 600; line-height: 1; color: var(--ink-2); } .exercise-list li.done { color: var(--ink); font-weight: 500; } .exercise-list li.done .check { color: var(--ink); } .hint-bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rule); } .hint-text { font: italic 400 12px/1.3 var(--serif); color: var(--ink-2); flex: 1; } .mtls-status { font: 400 12px/1.3 var(--sans); color: var(--ink-2); } .mtls-status.strict { font-weight: 600; color: var(--ink); } .mesh-svg { display: block; width: 100%; height: auto; max-height: 190px; background: var(--paper); } .traffic-stats { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; font: 400 12px/1.3 var(--sans); color: var(--ink-2); } .traffic-stats b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; } @media (pointer: coarse) { .bar-actions button, .hint-bar button { min-height: 44px; padding: 8px 14px; } .term-input { min-height: 44px; } }Kubernetes and Istio simulator. Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.
(function () { var BUILTIN_MANIFESTS = { 'destination-rule.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: DestinationRule\nmetadata:\n name: reviews\n namespace: shop\nspec:\n host: reviews\n subsets:\n - name: v1\n labels:\n version: v1\n - name: v2\n labels:\n version: v2\n - name: v3\n labels:\n version: v3', 'reviews-v2-v3-split.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n name: reviews\n namespace: shop\nspec:\n hosts:\n - reviews\n http:\n - route:\n - destination:\n host: reviews\n subset: v2\n weight: 50\n - destination:\n host: reviews\n subset: v3\n weight: 50', 'fault-delay.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n name: ratings\n namespace: shop\nspec:\n hosts:\n - ratings\n http:\n - fault:\n delay:\n percentage:\n value: 20\n fixedDelay: 2s\n route:\n - destination:\n host: ratings\n subset: v1', 'mtls-strict.yaml': 'apiVersion: security.istio.io/v1beta1\nkind: PeerAuthentication\nmetadata:\n name: default\n namespace: shop\nspec:\n mtls:\n mode: STRICT' }; var RESOURCE_KINDS = { deploy: 'deployment', deployment: 'deployment', deployments: 'deployment', 'deploy.apps': 'deployment', 'deployment.apps': 'deployment', 'deployments.apps': 'deployment', po: 'pod', pod: 'pod', pods: 'pod', svc: 'service', service: 'service', services: 'service', ns: 'namespace', namespace: 'namespace', namespaces: 'namespace', rs: 'replicaset', replicaset: 'replicaset', replicasets: 'replicaset', 'replicasets.apps': 'replicaset', no: 'node', node: 'node', nodes: 'node', vs: 'virtualservice', virtualservice: 'virtualservice', virtualservices: 'virtualservice', 'virtualservice.networking.istio.io': 'virtualservice', 'virtualservices.networking.istio.io': 'virtualservice', dr: 'destinationrule', destinationrule: 'destinationrule', destinationrules: 'destinationrule', 'destinationrule.networking.istio.io': 'destinationrule', 'destinationrules.networking.istio.io': 'destinationrule', pa: 'peerauthentication', peerauthentication: 'peerauthentication', peerauthentications: 'peerauthentication', 'peerauthentication.security.istio.io': 'peerauthentication', 'peerauthentications.security.istio.io': 'peerauthentication', gw: 'gateway', gateway: 'gateway', gateways: 'gateway', 'gateway.networking.istio.io': 'gateway', 'gateways.networking.istio.io': 'gateway' }; var ERROR_TYPE_NAMES = { deployment: 'deployments.apps', pod: 'pods', service: 'services', namespace: 'namespaces', replicaset: 'replicasets.apps', node: 'nodes', virtualservice: 'virtualservices.networking.istio.io', destinationrule: 'destinationrules.networking.istio.io', peerauthentication: 'peerauthentications.security.istio.io', gateway: 'gateways.networking.istio.io' }; function formatAge(seconds) { if (seconds < 60) return Math.max(0, Math.floor(seconds)) + 's'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm'; if (seconds < 86400) return Math.floor(seconds / 3600) + 'h'; return Math.floor(seconds / 86400) + 'd'; } function formatColumns(headers, rows) { if (rows.length === 0) return headers.join(' '); var colCount = headers.length; var colWidths = headers.map(function (h) { return h.length; }); for (var r = 0; r < rows.length; r++) { for (var c = 0; c < colCount; c++) { var cell = rows[r][c] || ''; if (cell.length > colWidths[c]) colWidths[c] = cell.length; } } function renderRow(cells) { return cells.map(function (cell, idx) { if (idx === colCount - 1) return cell; var w = colWidths[idx] || 0; var str = cell || ''; while (str.length < w + 3) str += ' '; return str; }).join(''); } return [renderRow(headers)].concat(rows.map(renderRow)).join('\n'); } function createInitialCluster() { var baseAge = 2520; function mkDep(name, app, ver, ns, img, cont) { ns = ns || "shop"; return { name: name, namespace: ns, replicas: 1, readyReplicas: 1, updatedReplicas: 1, availableReplicas: 1, image: img || ("docker.io/istio/examples-bookinfo-" + name + ":1.19.0"), containers: cont || [app], labels: { app: app, version: ver }, selector: { app: app, version: ver }, sidecarInjected: false, ageSeconds: baseAge }; } function mkPod(name, depName, ip, node, app, ver, ns, cont) { ns = ns || "shop"; return { name: name, namespace: ns, ready: "1/1", status: "Running", restarts: 0, ageSeconds: baseAge, ip: ip, node: node, labels: { app: app, version: ver }, sidecar: false, deploymentName: depName, containerNames: cont || [app] }; } function mkSvc(name, ip, ns, type, ports) { ns = ns || "shop"; return { name: name, namespace: ns, type: type || "ClusterIP", clusterIP: ip, externalIP: "<none>", ports: ports || "9080/TCP", selector: { app: name }, ageSeconds: baseAge }; } function mkRs(name, dep, ns) { return { name: name, namespace: ns || "shop", desired: 1, current: 1, ready: 1, deploymentName: dep, ageSeconds: baseAge }; } return { currentNamespace: "shop", namespaces: { "default": { name: "default", status: "Active", labels: { "kubernetes.io/metadata.name": "default" }, ageSeconds: baseAge }, "istio-system": { name: "istio-system", status: "Active", labels: { "kubernetes.io/metadata.name": "istio-system" }, ageSeconds: baseAge }, "shop": { name: "shop", status: "Active", labels: { "kubernetes.io/metadata.name": "shop" }, ageSeconds: baseAge } }, nodes: [ { name: "node-1", status: "Ready", roles: "control-plane,worker", ageSeconds: baseAge, version: "v1.28.2" }, { name: "node-2", status: "Ready", roles: "worker", ageSeconds: baseAge, version: "v1.28.2" } ], deployments: { "details-v1": mkDep("details-v1", "details", "v1"), "productpage-v1": mkDep("productpage-v1", "productpage", "v1"), "ratings-v1": mkDep("ratings-v1", "ratings", "v1"), "reviews-v1": mkDep("reviews-v1", "reviews", "v1"), "reviews-v2": mkDep("reviews-v2", "reviews", "v2"), "reviews-v3": mkDep("reviews-v3", "reviews", "v3"), "istiod": mkDep("istiod", "istiod", "pilot", "istio-system", "docker.io/istio/pilot:1.22.0", ["discovery"]), "istio-ingressgateway": mkDep("istio-ingressgateway", "istio-ingressgateway", "ingressgateway", "istio-system", "docker.io/istio/proxyv2:1.22.0", ["istio-proxy"]) }, services: { details: mkSvc("details", "10.96.10.2"), productpage: mkSvc("productpage", "10.96.10.1"), ratings: mkSvc("ratings", "10.96.10.3"), reviews: mkSvc("reviews", "10.96.10.4"), istiod: mkSvc("istiod", "10.96.0.10", "istio-system", "ClusterIP", "15010/TCP,15012/TCP,443/TCP"), "istio-ingressgateway": { name: "istio-ingressgateway", namespace: "istio-system", type: "LoadBalancer", clusterIP: "10.96.0.20", externalIP: "192.168.1.100", ports: "80:30080/TCP,443:30443/TCP", selector: { app: "istio-ingressgateway" }, ageSeconds: baseAge } }, replicaSets: { "details-v1-66b6955995": mkRs("details-v1-66b6955995", "details-v1"), "productpage-v1-564d4686f": mkRs("productpage-v1-564d4686f", "productpage-v1"), "ratings-v1-6484c4d8bb": mkRs("ratings-v1-6484c4d8bb", "ratings-v1"), "reviews-v1-55b668b89": mkRs("reviews-v1-55b668b89", "reviews-v1"), "reviews-v2-68c5b5dd7b": mkRs("reviews-v2-68c5b5dd7b", "reviews-v2"), "reviews-v3-74b888b5dc": mkRs("reviews-v3-74b888b5dc", "reviews-v3"), "istiod-75f8f5c98d": mkRs("istiod-75f8f5c98d", "istiod", "istio-system"), "istio-ingressgateway-6d8fc9f4": mkRs("istio-ingressgateway-6d8fc9f4", "istio-ingressgateway", "istio-system") }, pods: [ mkPod("details-v1-66b6955995-m8q2x", "details-v1", "10.244.1.12", "node-2", "details", "v1"), mkPod("productpage-v1-564d4686f-9zk4b", "productpage-v1", "10.244.0.18", "node-1", "productpage", "v1"), mkPod("ratings-v1-6484c4d8bb-s8w5k", "ratings-v1", "10.244.1.14", "node-2", "ratings", "v1"), mkPod("reviews-v1-55b668b89-h6dfk", "reviews-v1", "10.244.0.22", "node-1", "reviews", "v1"), mkPod("reviews-v2-68c5b5dd7b-p7m2s", "reviews-v2", "10.244.1.25", "node-2", "reviews", "v2"), mkPod("reviews-v3-74b888b5dc-d4j8w", "reviews-v3", "10.244.0.31", "node-1", "reviews", "v3"), mkPod("istiod-75f8f5c98d-j2k9l", "istiod", "10.244.0.5", "node-1", "istiod", "pilot", "istio-system", ["discovery"]), mkPod("istio-ingressgateway-6d8fc9f4-x9q7s", "istio-ingressgateway", "10.244.1.8", "node-2", "istio-ingressgateway", "ingressgateway", "istio-system", ["istio-proxy"]) ], virtualServices: {}, destinationRules: {}, peerAuthentications: {}, gateways: { "bookinfo-gateway": { name: "bookinfo-gateway", namespace: "shop", hosts: ["*"], port: 80, ageSeconds: baseAge } }, traffic: { total: 0, productpage: 0, details: 0, reviewsV1: 0, reviewsV2: 0, reviewsV3: 0, ratings: 0, lastSplitV1: 34, lastSplitV2: 33, lastSplitV3: 33, avgLatencyMs: 12 }, elapsedSeconds: 0 }; } function parseCommand(line) { var trimmed = line.trim(); var execParts = trimmed.split(/\s+--\s+/); var mainPart = execParts[0] || ''; var execExtra = execParts.length > 1 ? execParts.slice(1).join(' -- ').trim().split(/\s+/) : undefined; var rawTokens = mainPart.split(/\s+/).filter(Boolean); if (rawTokens.length === 0) { return { raw: trimmed, tool: '', subCmd: '', args: [], flags: {}, execExtra: execExtra }; } var tool = rawTokens[0]; var flags = {}; var positional = []; for (var i = 1; i < rawTokens.length; i++) { var token = rawTokens[i]; if (token.indexOf('--') === 0) { var eqIdx = token.indexOf('='); if (eqIdx !== -1) { flags[token.slice(2, eqIdx)] = token.slice(eqIdx + 1); } else { var k = token.slice(2); var next = rawTokens[i + 1]; if (next && next.indexOf('-') !== 0) { flags[k] = next; i++; } else { flags[k] = true; } } } else if (token.indexOf('-') === 0) { var eqIdx2 = token.indexOf('='); if (eqIdx2 !== -1) { flags[token.slice(1, eqIdx2)] = token.slice(eqIdx2 + 1); } else { var k2 = token.slice(1); if (k2 === 'A') { flags['A'] = true; } else { var next2 = rawTokens[i + 1]; if (next2 && next2.indexOf('-') !== 0) { flags[k2] = next2; i++; } else { flags[k2] = true; } } } } else { positional.push(token); } } return { raw: trimmed, tool: tool, subCmd: positional[0] || '', args: positional.slice(1), flags: flags, execExtra: execExtra }; } function resolveNamespace(state, flags) { if (flags['A'] || flags['all-namespaces']) return null; var ns = flags['n'] || flags['namespace']; if (ns && typeof ns === 'string') return ns; return state.currentNamespace; } function parseResourceTargets(args) { if (!args || args.length === 0) return { error: 'you must specify a resource' }; if (args[0].indexOf(',') !== -1 && args[0].indexOf('/') === -1) { var types = args[0].split(','); var multiTargets = []; for (var m = 0; m < types.length; m++) { var rawM = types[m].toLowerCase(); var kM = RESOURCE_KINDS[rawM]; if (!kM) return { errorType: types[m] }; multiTargets.push({ kind: kM, name: null, rawType: rawM }); } return { targets: multiTargets }; } if (args[0].indexOf('/') !== -1) { var targets = []; for (var i = 0; i < args.length; i++) { var slashIdx = args[i].indexOf('/'); if (slashIdx === -1) { var spelling = args[i].toLowerCase(); var k = RESOURCE_KINDS[spelling]; if (!k) return { errorType: args[i] }; targets.push({ kind: k, name: null, rawType: spelling }); } else { var rawT = args[i].slice(0, slashIdx).toLowerCase(); var name = args[i].slice(slashIdx + 1); var k2 = RESOURCE_KINDS[rawT]; if (!k2) return { errorType: rawT }; targets.push({ kind: k2, name: name, rawType: rawT }); } } return { targets: targets }; } var rawType = args[0].toLowerCase(); var kind = RESOURCE_KINDS[rawType]; if (!kind) return { errorType: args[0] }; var names = args.slice(1); if (names.length === 0) return { targets: [{ kind: kind, name: null, rawType: rawType }] }; var resTargets = []; for (var j = 0; j < names.length; j++) { var n = names[j]; if (n.indexOf('/') !== -1) n = n.slice(n.indexOf('/') + 1); resTargets.push({ kind: kind, name: n, rawType: rawType }); } return { targets: resTargets }; } function checkExercises(state) { var step1 = Boolean(state.namespaces['shop'] && state.namespaces['shop'].labels['istio-injection'] === 'enabled'); var reviewsPods = state.pods.filter(function (p) { return p.namespace === 'shop' && p.deploymentName.indexOf('reviews-') === 0; }); var step2 = Boolean(step1 && reviewsPods.length >= 3 && reviewsPods.every(function (p) { return p.sidecar && p.ready === '2/2' && p.status === 'Running'; })); var hasDr = Boolean(state.destinationRules['reviews'] && state.destinationRules['reviews'].subsets); var reviewsVs = state.virtualServices['reviews']; var hasSplit = Boolean(reviewsVs && reviewsVs.routes && reviewsVs.routes.some(function (r) { return r.subset === 'v2' && r.weight === 50; }) && reviewsVs.routes.some(function (r) { return r.subset === 'v3' && r.weight === 50; })); var step3 = Boolean(hasDr && hasSplit); var ratingsVs = state.virtualServices['ratings']; var step4 = Boolean(ratingsVs && ratingsVs.fault && ratingsVs.fault.delaySeconds === 2 && ratingsVs.fault.delayPercentage === 20); var defaultPa = state.peerAuthentications['default']; var step5 = Boolean(defaultPa && defaultPa.namespace === 'shop' && defaultPa.mode === 'STRICT'); return [step1, step2, step3, step4, step5]; } var SERVICE_GRAPH = [ { from: 'ingress', to: 'productpage' }, { from: 'productpage', to: 'details' }, { from: 'productpage', to: 'reviews-v1' }, { from: 'productpage', to: 'reviews-v2' }, { from: 'productpage', to: 'reviews-v3' }, { from: 'reviews-v2', to: 'ratings' }, { from: 'reviews-v3', to: 'ratings' } ]; function getReviewsWeights(state) { var rVs = state.virtualServices && state.virtualServices['reviews']; if (rVs && rVs.routes && rVs.routes.length > 0) { var rMap = {}; for (var i = 0; i < rVs.routes.length; i++) rMap[rVs.routes[i].subset] = rVs.routes[i].weight; return { v1: rMap['v1'] !== undefined ? rMap['v1'] : 0, v2: rMap['v2'] !== undefined ? rMap['v2'] : 0, v3: rMap['v3'] !== undefined ? rMap['v3'] : 0 }; } return { v1: 34, v2: 33, v3: 33 }; } function computeTopology(state) { var weights = getReviewsWeights(state); var isMtlsStrict = Boolean(state.peerAuthentications && state.peerAuthentications['default'] && state.peerAuthentications['default'].mode === 'STRICT'); var lockGlyph = isMtlsStrict ? ' 🔒' : ''; var faultActive = Boolean(state.virtualServices && state.virtualServices['ratings'] && state.virtualServices['ratings'].fault); var nodes = [ { id: 'ingress', title: 'Ingress', sub: ':80', x: 10, y: 75, width: 54, height: 26 }, { id: 'productpage', title: 'productpage', sub: 'v1' + lockGlyph, x: 94, y: 74, width: 80, height: 28 }, { id: 'details', title: 'details-v1', sub: 'v1' + lockGlyph, x: 226, y: 10, width: 72, height: 26 }, { id: 'reviews-v1', title: 'reviews-v1', sub: 'v1' + lockGlyph, x: 226, y: 52, width: 72, height: 26 }, { id: 'reviews-v2', title: 'reviews-v2', sub: 'v2' + lockGlyph, x: 226, y: 94, width: 72, height: 26 }, { id: 'reviews-v3', title: 'reviews-v3', sub: 'v3' + lockGlyph, x: 226, y: 136, width: 72, height: 26 }, { id: 'ratings', title: 'ratings-v1', sub: 'v1' + (faultActive ? ' (2s delay)' : '') + lockGlyph, x: 308, y: 114, width: 66, height: 28 } ]; var edges = [ { from: 'ingress', to: 'productpage', d: 'M 64 88 L 94 88' }, { from: 'productpage', to: 'details', d: 'M 174 80 L 186 23 L 226 23' }, { from: 'productpage', to: 'reviews-v1', d: 'M 174 84 L 186 65 L 226 65', weight: weights.v1, labelBox: { x: 191, y: 59, width: 24, height: 12, text: weights.v1 + '%' } }, { from: 'productpage', to: 'reviews-v2', d: 'M 174 88 L 186 107 L 226 107', weight: weights.v2, labelBox: { x: 191, y: 101, width: 24, height: 12, text: weights.v2 + '%' } }, { from: 'productpage', to: 'reviews-v3', d: 'M 174 92 L 186 149 L 226 149', weight: weights.v3, labelBox: { x: 191, y: 143, width: 24, height: 12, text: weights.v3 + '%' } }, { from: 'reviews-v2', to: 'ratings', d: 'M 298 107 L 308 122' }, { from: 'reviews-v3', to: 'ratings', d: 'M 298 149 L 308 134' } ]; return { viewBox: { width: 380, height: 180 }, nodes: nodes, edges: edges }; } function simulateTraffic(state, count) { if (typeof count !== 'number') count = 100; state.traffic.total += count; var weights = getReviewsWeights(state); var splitV1 = weights.v1, splitV2 = weights.v2, splitV3 = weights.v3; state.traffic.lastSplitV1 = splitV1; state.traffic.lastSplitV2 = splitV2; state.traffic.lastSplitV3 = splitV3; state.traffic.productpage += count; state.traffic.details += count; state.traffic.reviewsV1 += Math.round(count * (splitV1 / 100)); state.traffic.reviewsV2 += Math.round(count * (splitV2 / 100)); state.traffic.reviewsV3 += Math.round(count * (splitV3 / 100)); state.traffic.ratings += Math.round(count * ((splitV2 + splitV3) / 100)); var ratingsVs = state.virtualServices['ratings']; if (ratingsVs && ratingsVs.fault) { var faultDelay = ratingsVs.fault.delaySeconds || 2; var faultPct = ratingsVs.fault.delayPercentage || 20; state.traffic.avgLatencyMs = Math.round(12 + (faultDelay * 1000 * (faultPct / 100))); } else { state.traffic.avgLatencyMs = 12; } var lines = [ 'Simulated ' + count + ' incoming requests to productpage:9080/productpage', ' productpage: ' + state.traffic.productpage + ' total', ' details: ' + state.traffic.details + ' total', ' reviews v1: ' + splitV1 + '% (' + state.traffic.reviewsV1 + ')', ' reviews v2: ' + splitV2 + '% (' + state.traffic.reviewsV2 + ')', ' reviews v3: ' + splitV3 + '% (' + state.traffic.reviewsV3 + ')', ' ratings: ' + state.traffic.ratings + ' total (avg latency ' + state.traffic.avgLatencyMs + 'ms)' ]; return { output: lines.join('\n'), counts: state.traffic }; } function executeRolloutDep(state, dep, action) { if (action === 'status') return { line: 'deployment "' + dep.name + '" successfully rolled out' }; if (action === 'restart') { var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled'); dep.sidecarInjected = isNsInjected; state.pods = state.pods.filter(function (pod) { return pod.deploymentName !== dep.name; }); for (var i = 0; i < dep.replicas; i++) { var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: dep.name + '-rollout-' + randSuffix, namespace: dep.namespace, ready: isNsInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: '10.244.0.' + (20 + Math.floor(Math.random() * 40)), node: i % 2 === 0 ? 'node-1' : 'node-2', labels: Object.assign({}, dep.labels), sidecar: isNsInjected, deploymentName: dep.name, containerNames: isNsInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice() }); } return { line: 'deployment.apps/' + dep.name + ' restarted' }; } if (action === 'undo') { if (dep.previousImage) { dep.image = dep.previousImage; dep.previousImage = undefined; } for (var p = 0; p < state.pods.length; p++) { if (state.pods[p].deploymentName === dep.name) { state.pods[p].status = 'Running'; state.pods[p].ready = state.pods[p].sidecar ? '2/2' : '1/1'; } } return { line: 'deployment.apps/' + dep.name + ' rolled back' }; } } function handleKubectlRollout(state, p) { var action = p.args[0]; if (action !== 'status' && action !== 'restart' && action !== 'undo') { return { output: 'error: rollout action "' + action + '" not recognized. Supported: status, restart, undo', state: state, error: true }; } var targetArgs = p.args.slice(1); if (targetArgs.length === 0) return { output: 'error: you must specify the resource to ' + action, state: state, error: true }; var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (t.kind !== 'deployment') { return { output: 'error: cannot ' + action + ' ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true }; } if (t.name === null) { var nsDeps = Object.values(state.deployments).filter(function (d) { return d.namespace === ns; }); if (nsDeps.length === 0) outputs.push('No resources found in ' + ns + ' namespace.'); else { for (var d = 0; d < nsDeps.length; d++) outputs.push(executeRolloutDep(state, nsDeps[d], action).line); } } else { var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; outputs.push(executeRolloutDep(state, dep, action).line); } } return { output: outputs.join('\n'), state: state }; } function handleKubectlScale(state, p) { var rawReplicas = p.flags['replicas']; if (rawReplicas === undefined) return { output: 'error: --replicas is required', state: state, error: true }; var replicas = parseInt(String(rawReplicas), 10); if (isNaN(replicas) || replicas < 0) return { output: 'error: invalid replicas count: ' + rawReplicas, state: state, error: true }; if (p.args.length === 0) return { output: 'error: you must specify a resource to scale', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (t.kind !== 'deployment') { return { output: 'error: cannot scale ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true }; } if (!t.name) return { output: 'error: you must specify a deployment name to scale', state: state, error: true }; var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; var oldReplicas = dep.replicas; dep.replicas = replicas; dep.readyReplicas = replicas; dep.availableReplicas = replicas; dep.updatedReplicas = replicas; if (replicas > oldReplicas) { var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled'); for (var r = oldReplicas; r < replicas; r++) { var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: dep.name + '-scale-' + randSuffix, namespace: dep.namespace, ready: isNsInjected && dep.sidecarInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: '10.244.1.' + (30 + Math.floor(Math.random() * 40)), node: r % 2 === 0 ? 'node-1' : 'node-2', labels: Object.assign({}, dep.labels), sidecar: isNsInjected && dep.sidecarInjected, deploymentName: dep.name, containerNames: isNsInjected && dep.sidecarInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice() }); } } else if (replicas < oldReplicas) { var toRemove = oldReplicas - replicas; var removedCount = 0; state.pods = state.pods.filter(function (pod) { if (pod.deploymentName === dep.name && removedCount < toRemove) { removedCount++; return false; } return true; }); } outputs.push('deployment.apps/' + dep.name + ' scaled'); } return { output: outputs.join('\n'), state: state }; } function handleKubectlSet(state, p) { var sub = p.args[0]; if (sub !== 'image') return { output: 'error: set "' + sub + '" not supported. Use "kubectl set image deploy/<name> <container>=<image>"', state: state, error: true }; var rest = p.args.slice(1); if (rest.length === 0) return { output: 'error: you must specify a resource and container=image spec', state: state, error: true }; var specIdx = -1; for (var i = 0; i < rest.length; i++) { if (rest[i].indexOf('=') !== -1) { specIdx = i; break; } } if (specIdx === -1) return { output: 'error: you must specify a container image in format <container>=<image>', state: state, error: true }; var targetArgs = rest.slice(0, specIdx); var spec = rest[specIdx]; var specParts = spec.split('='); var containerName = specParts[0]; var newImage = specParts[1]; var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a deployment name', state: state, error: true }; var target = targets[0]; if (target.kind !== 'deployment') return { output: 'error: cannot set image on ' + target.rawType + ': the server doesn\'t have a resource type "' + target.rawType + '"', state: state, error: true }; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var dep = state.deployments[target.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === target.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + target.name + '" not found', state: state, error: true }; dep.previousImage = dep.image; dep.image = newImage; var isBroken = newImage.indexOf('bad') !== -1 || newImage.indexOf('invalid') !== -1 || newImage.indexOf('fail') !== -1; for (var podIdx = 0; podIdx < state.pods.length; podIdx++) { var pod = state.pods[podIdx]; if (pod.deploymentName === dep.name) { if (isBroken) { pod.status = 'ImagePullBackOff'; pod.ready = pod.sidecar ? '0/2' : '0/1'; } else { pod.status = 'Running'; pod.ready = pod.sidecar ? '2/2' : '1/1'; } } } return { output: 'deployment.apps/' + dep.name + ' image updated', state: state }; } function handleKubectlDelete(state, p) { var file = p.flags['f'] || p.flags['filename']; if (file) { var manifestName = String(file); if (!BUILTIN_MANIFESTS[manifestName]) return { output: 'error: the path "' + manifestName + '" does not exist', state: state, error: true }; if (manifestName === 'destination-rule.yaml') { delete state.destinationRules['reviews']; return { output: 'destinationrule.networking.istio.io "reviews" deleted', state: state }; } if (manifestName === 'reviews-v2-v3-split.yaml') { delete state.virtualServices['reviews']; return { output: 'virtualservice.networking.istio.io "reviews" deleted', state: state }; } if (manifestName === 'fault-delay.yaml') { if (state.virtualServices['ratings']) delete state.virtualServices['ratings'].fault; return { output: 'virtualservice.networking.istio.io "ratings" deleted', state: state }; } if (manifestName === 'mtls-strict.yaml') { delete state.peerAuthentications['default']; return { output: 'peerauthentication.security.istio.io "default" deleted', state: state }; } } if (p.args.length === 0) return { output: 'error: you must specify a resource to delete', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (!t.name) return { output: 'error: you must specify a resource name to delete', state: state, error: true }; if (t.kind === 'pod') { var podIdx = state.pods.findIndex(function (pod) { return (pod.namespace === ns || ns === null) && pod.name === t.name; }); if (podIdx === -1) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true }; var oldPod = state.pods[podIdx]; state.pods.splice(podIdx, 1); var depObj = state.deployments[oldPod.deploymentName]; if (depObj) { var isNsInjected = Boolean(state.namespaces[oldPod.namespace] && state.namespaces[oldPod.namespace].labels['istio-injection'] === 'enabled'); var sidecar = Boolean(isNsInjected && depObj.sidecarInjected); var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: oldPod.deploymentName + '-repl-' + randSuffix, namespace: oldPod.namespace, ready: sidecar ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: oldPod.ip, node: oldPod.node, labels: Object.assign({}, oldPod.labels), sidecar: sidecar, deploymentName: oldPod.deploymentName, containerNames: sidecar ? depObj.containers.concat(['istio-proxy']) : depObj.containers.slice() }); } outputs.push('pod "' + t.name + '" deleted'); } else if (t.kind === 'deployment') { var dep = state.deployments[t.name]; if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; delete state.deployments[t.name]; state.pods = state.pods.filter(function (p) { return p.deploymentName !== t.name; }); outputs.push('deployment.apps "' + t.name + '" deleted'); } else { var mapKey = { virtualservice: 'virtualServices', destinationrule: 'destinationRules', peerauthentication: 'peerAuthentications', service: 'services' }[t.kind]; if (mapKey) { if (!state[mapKey][t.name]) return { output: 'Error from server (NotFound): ' + ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" not found', state: state, error: true }; delete state[mapKey][t.name]; outputs.push(ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" deleted'); } else { return { output: 'error: cannot delete ' + t.rawType + ': unsupported resource type', state: state, error: true }; } } } return { output: outputs.join('\n'), state: state }; } function handleKubectlLabel(state, p) { var specIdx = -1; for (var i = 0; i < p.args.length; i++) { if (p.args[i].indexOf('=') !== -1) { specIdx = i; break; } } if (specIdx === -1) return { output: 'error: you must specify a label in key=value format', state: state, error: true }; var targetArgs = p.args.slice(0, specIdx); var spec = p.args[specIdx]; var eqIdx = spec.indexOf('='); var key = spec.slice(0, eqIdx); var val = spec.slice(eqIdx + 1); var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a resource name to label', state: state, error: true }; var target = targets[0]; if (target.kind === 'namespace') { var nsObj = state.namespaces[target.name]; if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + target.name + '" not found', state: state, error: true }; nsObj.labels[key] = val; return { output: 'namespace/' + target.name + ' labeled', state: state }; } if (target.kind === 'pod') { var podObj = state.pods.find(function (pod) { return pod.name === target.name; }); if (!podObj) return { output: 'Error from server (NotFound): pods "' + target.name + '" not found', state: state, error: true }; podObj.labels[key] = val; return { output: 'pod/' + target.name + ' labeled', state: state }; } return { output: 'error: cannot label ' + target.rawType + ': unsupported resource type', state: state, error: true }; } function handleKubectlApply(state, p) { var file = p.flags['f'] || p.flags['filename'] || p.args[0]; if (!file) return { output: 'error: must specify one of -f and -k', state: state, error: true }; var filename = String(file); if (!BUILTIN_MANIFESTS[filename]) return { output: 'error: the path "' + filename + '" does not exist', state: state, error: true }; if (filename === 'destination-rule.yaml') { state.destinationRules['reviews'] = { name: 'reviews', namespace: 'shop', host: 'reviews', subsets: [{ name: 'v1', labels: { version: 'v1' } }, { name: 'v2', labels: { version: 'v2' } }, { name: 'v3', labels: { version: 'v3' } }], ageSeconds: 1 }; return { output: 'destinationrule.networking.istio.io/reviews created', state: state }; } if (filename === 'reviews-v2-v3-split.yaml') { state.virtualServices['reviews'] = { name: 'reviews', namespace: 'shop', hosts: ['reviews'], routes: [{ subset: 'v2', weight: 50 }, { subset: 'v3', weight: 50 }], ageSeconds: 1 }; return { output: 'virtualservice.networking.istio.io/reviews configured', state: state }; } if (filename === 'fault-delay.yaml') { if (!state.virtualServices['ratings']) { state.virtualServices['ratings'] = { name: 'ratings', namespace: 'shop', hosts: ['ratings'], routes: [{ subset: 'v1', weight: 100 }], ageSeconds: 1 }; } state.virtualServices['ratings'].fault = { delayPercentage: 20, delaySeconds: 2 }; return { output: 'virtualservice.networking.istio.io/ratings configured', state: state }; } if (filename === 'mtls-strict.yaml') { state.peerAuthentications['default'] = { name: 'default', namespace: 'shop', mode: 'STRICT', ageSeconds: 1 }; return { output: 'peerauthentication.security.istio.io/default created', state: state }; } return { output: 'applied ' + filename, state: state }; } function handleKubectlLogs(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a pod to get logs from', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var podName = targets[0] && targets[0].name ? targets[0].name : p.args[0]; if (podName.indexOf('/') !== -1) podName = podName.slice(podName.indexOf('/') + 1); var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podName; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + podName + '" not found', state: state, error: true }; var cName = p.flags['c'] || p.flags['container'] || pod.containerNames[0]; if (cName === 'istio-proxy') { return { output: [ '2026-09-26T10:00:00.123456Z info Envoy proxy initialized', '2026-09-26T10:00:00.234567Z info Pilot discovery connected: 10.96.0.10:15012', '2026-09-26T10:00:00.345678Z info CDS: route clusters synchronized', '2026-09-26T10:00:00.456789Z info mTLS: connection established using SPIFFE identity' ].join('\n'), state: state }; } return { output: [ '[Server] Listening on port 9080', '[Route] GET /reviews/0 HTTP/1.1 200 OK', '[Route] GET /ratings/0 HTTP/1.1 200 OK (latency: 14ms)' ].join('\n'), state: state }; } function handleKubectlExec(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a pod to exec into', state: state, error: true }; var podTarget = p.args[0]; if (podTarget.indexOf('/') !== -1) podTarget = podTarget.slice(podTarget.indexOf('/') + 1); var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podTarget; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + podTarget + '" not found', state: state, error: true }; var rVs = state.virtualServices['reviews']; var stars = '(no stars)'; if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v2'; })) { stars = '★★★★☆ (black stars from ratings)'; } else if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v3'; })) { stars = '★★★★★ (red stars from ratings)'; } return { output: [ 'HTTP/1.1 200 OK', 'content-type: text/html; charset=utf-8', 'content-length: 1824', '', '<!DOCTYPE html><html><title>Simple Bookstore App</title><body>', '<h1>Product: The Comedy of Errors</h1>', '<p>Reviews: ' + stars + '</p>', '</body></html>' ].join('\n'), state: state }; } function handleKubectlDescribe(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a resource to describe', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var t = targets[0]; if (!t.name) return { output: 'error: you must specify a resource name', state: state, error: true }; if (t.kind === 'pod') { var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === t.name; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true }; return { output: [ 'Name: ' + pod.name, 'Namespace: ' + pod.namespace, 'Node: ' + pod.node, 'Status: ' + pod.status, 'IP: ' + pod.ip, 'Containers: ' + pod.containerNames.join(', '), 'Sidecar: ' + (pod.sidecar ? 'injected' : 'none'), 'Ready: ' + pod.ready ].join('\n'), state: state }; } if (t.kind === 'deployment') { var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return (d.namespace === ns || ns === null) && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; return { output: [ 'Name: ' + dep.name, 'Namespace: ' + dep.namespace, 'Replicas: ' + dep.replicas + ' desired | ' + dep.readyReplicas + ' ready', 'Image: ' + dep.image, 'Sidecar injection: ' + (dep.sidecarInjected ? 'injected' : 'none') ].join('\n'), state: state }; } if (t.kind === 'service') { var svc = state.services[t.name]; if (!svc) return { output: 'Error from server (NotFound): services "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + svc.name, 'Namespace: ' + svc.namespace, 'Type: ' + svc.type, 'IP: ' + svc.clusterIP].join('\n'), state: state }; } if (t.kind === 'namespace') { var nsObj = state.namespaces[t.name]; if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + nsObj.name, 'Status: ' + nsObj.status].join('\n'), state: state }; } if (t.kind === 'virtualservice') { var vs = state.virtualServices[t.name]; if (!vs) return { output: 'Error from server (NotFound): virtualservices.networking.istio.io "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + vs.name, 'Hosts: ' + vs.hosts.join(',')].join('\n'), state: state }; } if (t.kind === 'destinationrule') { var dr = state.destinationRules[t.name]; if (!dr) return { output: 'Error from server (NotFound): destinationrules.networking.istio.io "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + dr.name, 'Host: ' + dr.host].join('\n'), state: state }; } return { output: 'error: describe not supported for ' + t.rawType, state: state, error: true }; } function handleKubectlGet(state, p) { if (p.args.length === 0) return { output: 'error: You must specify the type of resource to get.', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags); var wide = Boolean(p.flags['o'] === 'wide' || p.flags['output'] === 'wide'); var nameOnly = Boolean(p.flags['o'] === 'name' || p.flags['output'] === 'name'); var selector = p.flags['l'] || p.flags['selector']; function matchesSelector(labels) { if (!selector) return true; var parts = String(selector).split(','); for (var k = 0; k < parts.length; k++) { var pair = parts[k].split('='); var key = (pair[0] || '').trim(); var val = (pair[1] || '').trim(); if (!labels[key] || labels[key] !== val) return false; } return true; } function renderTable(items, headers, rowFn, notFoundKind, targetName) { if (targetName !== null && items.length === 0) { return { err: 'Error from server (NotFound): ' + notFoundKind + ' "' + targetName + '" not found' }; } if (items.length === 0) { return { out: ns ? 'No resources found in ' + ns + ' namespace.' : 'No resources found.' }; } if (nameOnly) { return { out: items.map(function (it) { return it.name; }).join('\n') }; } return { out: formatColumns(headers, items.map(rowFn)) }; } var outputs = []; for (var tIdx = 0; tIdx < targets.length; tIdx++) { var target = targets[tIdx]; if (target.kind === 'pod') { var podList = state.pods.filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (!matchesSelector(item.labels)) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var pHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE'] : ['NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE']; if (wide) pHeaders.push('IP', 'NODE'); var rP = renderTable(podList, pHeaders, function (pod) { var row = ns === null ? [pod.namespace, pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)] : [pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)]; if (wide) row.push(pod.ip, pod.node); return row; }, 'pods', target.name); if (rP.err) return { output: rP.err, state: state, error: true }; outputs.push(rP.out); } else if (target.kind === 'deployment') { var depList = Object.values(state.deployments).filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var dHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE'] : ['NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE']; var rD = renderTable(depList, dHeaders, function (dep) { var rStr = dep.readyReplicas + '/' + dep.replicas; return ns === null ? [dep.namespace, dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)] : [dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)]; }, 'deployments.apps', target.name); if (rD.err) return { output: rD.err, state: state, error: true }; outputs.push(rD.out); } else if (target.kind === 'service') { var svcList = Object.values(state.services).filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var sHeaders = ns === null ? ['NAMESPACE', 'NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE'] : ['NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE']; var rS = renderTable(svcList, sHeaders, function (svc) { return ns === null ? [svc.namespace, svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)] : [svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)]; }, 'services', target.name); if (rS.err) return { output: rS.err, state: state, error: true }; outputs.push(rS.out); } else if (target.kind === 'namespace') { var nsList = Object.values(state.namespaces).filter(function (item) { if (target.name !== null && item.name !== target.name) return false; return true; }); var rN = renderTable(nsList, ['NAME', 'STATUS', 'AGE'], function (item) { return [item.name, item.status, formatAge(item.ageSeconds)]; }, 'namespaces', target.name); if (rN.err) return { output: rN.err, state: state, error: true }; outputs.push(rN.out); } else { var genericMap = { virtualservice: { map: 'virtualServices', cols: ['NAME', 'GATEWAYS', 'HOSTS', 'AGE'], fn: function (vs) { return [vs.name, '[mesh]', vs.hosts.join(','), formatAge(vs.ageSeconds)]; } }, destinationrule: { map: 'destinationRules', cols: ['NAME', 'HOST', 'AGE'], fn: function (dr) { return [dr.name, dr.host, formatAge(dr.ageSeconds)]; } }, peerauthentication: { map: 'peerAuthentications', cols: ['NAME', 'MODE', 'AGE'], fn: function (pa) { return [pa.name, pa.mode, formatAge(pa.ageSeconds)]; } }, gateway: { map: 'gateways', cols: ['NAME', 'AGE'], fn: function (gw) { return [gw.name, formatAge(gw.ageSeconds)]; } }, node: { list: state.nodes, cols: ['NAME', 'STATUS', 'ROLES', 'AGE', 'VERSION'], fn: function (n) { return [n.name, n.status, n.roles, formatAge(n.ageSeconds), n.version]; } }, replicaset: { map: 'replicaSets', cols: ['NAME', 'DESIRED', 'CURRENT', 'READY', 'AGE'], fn: function (rs) { return [rs.name, String(rs.desired), String(rs.current), String(rs.ready), formatAge(rs.ageSeconds)]; } } }[target.kind]; if (genericMap) { var rawItems = genericMap.list || Object.values(state[genericMap.map]); var filtered = rawItems.filter(function (item) { if (genericMap.map && item.namespace && ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var resGen = renderTable(filtered, genericMap.cols, genericMap.fn, ERROR_TYPE_NAMES[target.kind], target.name); if (resGen.err) return { output: resGen.err, state: state, error: true }; outputs.push(resGen.out); } } } return { output: outputs.join('\n'), state: state }; } function handleIstioctl(state, p) { var sub = p.subCmd; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; if (sub === 'version') { return { output: [ 'client version: 1.22.0', 'control plane version: 1.22.0', 'data plane version: 1.22.0 (8 proxies)' ].join('\n'), state: state }; } if (sub === 'proxy-status' || sub === 'ps') { var rows = [ ['istio-ingressgateway-6d8fc9f4-x9q7s.istio-system', 'SYNCED', 'SYNCED', 'SYNCED', 'SYNCED', 'istiod-75f8f5c98d-j2k9l', '1.22.0'] ]; for (var i = 0; i < state.pods.length; i++) { var pod = state.pods[i]; if (pod.sidecar) { rows.push([ pod.name + '.' + pod.namespace, 'SYNCED', 'SYNCED', 'SYNCED', 'SYNCED', 'istiod-75f8f5c98d-j2k9l', '1.22.0' ]); } } return { output: formatColumns(['NAME', 'CDS', 'LDS', 'EDS', 'RDS', 'ISTIOD', 'VERSION'], rows), state: state }; } if (sub === 'analyze') { var targetNs = ns || 'shop'; var isNsInjected = Boolean(state.namespaces[targetNs] && state.namespaces[targetNs].labels['istio-injection'] === 'enabled'); if (!isNsInjected) { return { output: [ 'Warning [IST0102] (Namespace ' + targetNs + ') The namespace is not enabled for Istio injection.', ' Run \'kubectl label namespace ' + targetNs + ' istio-injection=enabled\' to enable automatic injection.', 'Info [IST0118] (Service details.shop) Port name details-port (service: details.shop) does not follow Istio naming convention.' ].join('\n'), state: state }; } return { output: '✔ No validation issues found when analyzing namespace: ' + targetNs + '.', state: state }; } if (sub === 'x' && p.args[0] === 'describe') { var pName = p.args[2] || p.args[1] || ''; if (pName.indexOf('/') !== -1) pName = pName.slice(pName.indexOf('/') + 1); var pPod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === pName; }); if (!pPod) return { output: 'Error: pod "' + pName + '" not found in namespace "' + ns + '"', state: state, error: true }; if (!pPod.sidecar) return { output: 'Pod ' + pPod.name + ' has no Istio sidecar injected.', state: state }; return { output: [ 'Pod: ' + pPod.name, 'Namespace: ' + pPod.namespace, 'Sidecar: istio-proxy (docker.io/istio/proxyv2:1.22.0)', 'Inbound Port: 9080/HTTP -> 127.0.0.1:9080', 'mTLS: PERMISSIVE (PeerAuthentication default.shop)', 'VirtualService: ' + (state.virtualServices['reviews'] ? 'reviews.shop (HTTP routes: v2=50%, v3=50%)' : 'none') ].join('\n'), state: state }; } return { output: 'istioctl: unknown subcommand "' + sub + '". Try "istioctl proxy-status" or "istioctl analyze".', state: state, error: true }; } function executeCommand(state, line) { var p = parseCommand(line); if (!p.tool) return { output: '', state: state }; if (p.tool === 'clear') return { output: '__CLEAR__', state: state }; if (p.tool === 'reset') { var fresh = createInitialCluster(); Object.keys(state).forEach(function (k) { delete state[k]; }); Object.assign(state, fresh); return { output: 'Cluster reset to initial state.', state: state }; } if (p.tool === 'help') { return { output: [ 'Supported commands:', ' kubectl get pods|deploy|svc|ns|vs|dr|pa|nodes [-n ns|-A] [-o wide|name] [-l key=val]', ' kubectl describe pod|deploy|svc|ns|vs|dr <name> [-n ns]', ' kubectl logs <pod> [-c container] [-n ns]', ' kubectl scale deploy <name> --replicas=N', ' kubectl delete pod|deploy|svc <name> [-n ns]', ' kubectl rollout status|restart|undo deploy/<name> [-n ns]', ' kubectl set image deploy/<name> <container>=<image>', ' kubectl label namespace <name> <key>=<val>', ' kubectl apply -f <manifest.yaml>', ' kubectl exec <pod> -- curl -s productpage:9080/productpage', ' istioctl version | proxy-status | analyze [-n ns] | x describe pod <p>', ' cat <manifest> | ls | hint | reset | clear' ].join('\n'), state: state }; } if (p.tool === 'ls') { return { output: 'destination-rule.yaml reviews-v2-v3-split.yaml fault-delay.yaml mtls-strict.yaml', state: state }; } if (p.tool === 'cat') { var fileName = p.subCmd || p.args[0]; if (fileName && BUILTIN_MANIFESTS[fileName]) return { output: BUILTIN_MANIFESTS[fileName], state: state }; return { output: 'cat: ' + (fileName || 'missing') + ': No such file or directory', state: state, error: true }; } if (p.tool === 'hint') { var chks = checkExercises(state); if (!chks[0]) return { output: 'Hint (Step 1): Reviews pods show 1/1 READY (no sidecar) because namespace "shop" is not labeled for automatic injection. Run:\n kubectl label namespace shop istio-injection=enabled', state: state }; if (!chks[1]) return { output: 'Hint (Step 2): Namespace injection is enabled, but running pods must be restarted to inject Envoy sidecars (2/2). Restart the deployments:\n kubectl rollout restart deployment reviews-v1 reviews-v2 reviews-v3', state: state }; if (!chks[2]) return { output: 'Hint (Step 3): Apply destination-rule.yaml and reviews-v2-v3-split.yaml to define subsets and split traffic 50/50 between v2 and v3:\n kubectl apply -f destination-rule.yaml\n kubectl apply -f reviews-v2-v3-split.yaml', state: state }; if (!chks[3]) return { output: 'Hint (Step 4): Apply fault-delay.yaml to introduce a 2s delay on 20% of ratings calls:\n kubectl apply -f fault-delay.yaml', state: state }; if (!chks[4]) return { output: 'Hint (Step 5): Apply mtls-strict.yaml to enforce mutual TLS across the shop namespace:\n kubectl apply -f mtls-strict.yaml', state: state }; return { output: 'All 5 guided exercises completed! Try traffic burst or kubectl exec curl.', state: state }; } if (p.tool === 'kubectl') { var sub = p.subCmd; if (sub === 'get') return handleKubectlGet(state, p); if (sub === 'describe') return handleKubectlDescribe(state, p); if (sub === 'delete') return handleKubectlDelete(state, p); if (sub === 'scale') return handleKubectlScale(state, p); if (sub === 'rollout') return handleKubectlRollout(state, p); if (sub === 'set') return handleKubectlSet(state, p); if (sub === 'label') return handleKubectlLabel(state, p); if (sub === 'apply') return handleKubectlApply(state, p); if (sub === 'logs') return handleKubectlLogs(state, p); if (sub === 'exec') return handleKubectlExec(state, p); return { output: 'kubectl: unknown subcommand "' + sub + '". Type "help" for usage.', state: state, error: true }; } if (p.tool === 'istioctl') return handleIstioctl(state, p); return { output: p.tool + ': command not found. Type "help" for usage.', state: state, error: true }; } var root = document.querySelector('.kubectl-sim') || document.body; var termLog = root.querySelector('#termLog'); var termInput = root.querySelector('#termInput'); var mtlsStatus = root.querySelector('#mtlsStatus'); var activeNs = root.querySelector('#activeNs'); var statTotal = root.querySelector('#statTotal'); var statV1 = root.querySelector('#statV1'); var statV2 = root.querySelector('#statV2'); var statV3 = root.querySelector('#statV3'); var statRatings = root.querySelector('#statRatings'); var statLatency = root.querySelector('#statLatency'); var meshSvg = root.querySelector('#meshSvg'); var hintOutput = root.querySelector('#hintOutput'); var btnLoad = root.querySelector('#btnLoad'); var btnReset = root.querySelector('#btnReset'); var btnHelp = root.querySelector('#btnHelp'); var btnHint = root.querySelector('#btnHint'); var cluster = createInitialCluster(); var history = []; var historyIndex = 0; function appendLog(line, type) { if (!termLog) return; var p = document.createElement('div'); p.className = type === 'cmd' ? 'cmd-line' : type === 'err' ? 'cmd-err' : 'cmd-out'; p.textContent = line; termLog.appendChild(p); termLog.scrollTop = termLog.scrollHeight; } function renderExercises() { var checks = checkExercises(cluster); for (var i = 1; i <= 5; i++) { var chk = root.querySelector('#chk' + i); var item = chk ? chk.parentElement : null; if (chk) { if (checks[i - 1]) { chk.innerHTML = '✓'; if (item) item.classList.add('done'); } else { chk.innerHTML = '○'; if (item) item.classList.remove('done'); } } } } function renderTrafficMesh() { var topo = computeTopology(cluster); var isMtlsStrict = Boolean(cluster.peerAuthentications['default'] && cluster.peerAuthentications['default'].mode === 'STRICT'); if (mtlsStatus) { mtlsStatus.textContent = isMtlsStrict ? 'mTLS strict' : 'mTLS permissive'; if (isMtlsStrict) mtlsStatus.classList.add('strict'); else mtlsStatus.classList.remove('strict'); } if (activeNs) activeNs.textContent = cluster.currentNamespace; if (statTotal) statTotal.textContent = String(cluster.traffic.total); if (statV1) statV1.textContent = cluster.traffic.lastSplitV1 + '%'; if (statV2) statV2.textContent = cluster.traffic.lastSplitV2 + '%'; if (statV3) statV3.textContent = cluster.traffic.lastSplitV3 + '%'; if (statRatings) statRatings.textContent = String(cluster.traffic.ratings); if (statLatency) statLatency.textContent = cluster.traffic.avgLatencyMs + ' ms'; if (!meshSvg) return; var parts = [ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 1 L 10 5 L 0 9 z" fill="var(--ink-2)"/></marker></defs>' ]; for (var i = 0; i < topo.edges.length; i++) { var e = topo.edges[i]; parts.push('<path d="' + e.d + '" fill="none" stroke="var(--ink-2)" stroke-width="1.2" marker-end="url(#arrow)"/>'); if (e.labelBox) { var lb = e.labelBox; parts.push('<rect x="' + lb.x + '" y="' + lb.y + '" width="' + lb.width + '" height="' + lb.height + '" fill="var(--paper)"/>'); parts.push('<text x="' + (lb.x + lb.width / 2) + '" y="' + (lb.y + lb.height / 2 + 3) + '" font-family="var(--sans)" font-size="9" text-anchor="middle" fill="var(--ink-2)" stroke="var(--paper)" stroke-width="2.5" paint-order="stroke fill">' + lb.text + '</text>'); } } for (var j = 0; j < topo.nodes.length; j++) { var n = topo.nodes[j]; parts.push('<g class="mesh-node" transform="translate(' + n.x + ', ' + n.y + ')">'); parts.push('<rect width="' + n.width + '" height="' + n.height + '" rx="3" fill="var(--paper)" stroke="var(--ink)" stroke-width="1"/>'); parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 - 1) + '" font-family="var(--sans)" font-size="10" font-weight="600" text-anchor="middle" fill="var(--ink)">' + n.title + '</text>'); parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 + 10) + '" font-family="var(--mono)" font-size="8" text-anchor="middle" fill="var(--ink-2)">' + n.sub + '</text>'); parts.push('</g>'); } meshSvg.innerHTML = parts.join(''); } function renderUi() { renderExercises(); renderTrafficMesh(); } function handleLine(cmd) { if (!cmd.trim()) return; history.push(cmd); historyIndex = history.length; appendLog('$ ' + cmd, 'cmd'); var res = executeCommand(cluster, cmd); if (res.output === '__CLEAR__') { if (termLog) termLog.innerHTML = ''; } else if (res.output) { appendLog(res.output, res.error ? 'err' : 'out'); } renderUi(); } if (termInput) { termInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); var val = termInput.value; termInput.value = ''; handleLine(val); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (history.length > 0) { if (historyIndex > 0) historyIndex--; else historyIndex = 0; termInput.value = history[historyIndex] || ''; } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (historyIndex < history.length - 1) { historyIndex++; termInput.value = history[historyIndex] || ''; } else { historyIndex = history.length; termInput.value = ''; } } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); if (termLog) termLog.innerHTML = ''; } else if (e.key === 'Tab') { e.preventDefault(); var current = termInput.value; var tokens = current.split(' '); var lastTok = tokens[tokens.length - 1] || ''; var candidates = [ 'kubectl', 'istioctl', 'help', 'hint', 'clear', 'reset', 'cat', 'ls', 'get', 'describe', 'logs', 'scale', 'delete', 'rollout', 'set', 'label', 'apply', 'exec', 'pods', 'deployments', 'services', 'virtualservices', 'destinationrules', 'peerauthentications', 'destination-rule.yaml', 'reviews-v2-v3-split.yaml', 'fault-delay.yaml', 'mtls-strict.yaml' ]; Object.keys(cluster.deployments).forEach(function (d) { candidates.push(d); candidates.push('deploy/' + d); candidates.push('deployment/' + d); }); cluster.pods.forEach(function (pod) { candidates.push(pod.name); candidates.push('pod/' + pod.name); }); var matches = candidates.filter(function (c) { return c.indexOf(lastTok) === 0; }); if (matches.length === 1) { tokens[tokens.length - 1] = matches[0]; termInput.value = tokens.join(' ') + ' '; } else if (matches.length > 1) { appendLog(matches.join(' '), 'out'); } } }); } if (btnLoad) { btnLoad.addEventListener('click', function () { var res = simulateTraffic(cluster, 100); appendLog('$ simulate-traffic 100', 'cmd'); appendLog(res.output, 'out'); renderUi(); }); } if (btnReset) { btnReset.addEventListener('click', function () { var res = executeCommand(cluster, 'reset'); if (termLog) termLog.innerHTML = ''; appendLog(res.output, 'out'); renderUi(); }); } if (btnHelp) { btnHelp.addEventListener('click', function () { var res = executeCommand(cluster, 'help'); appendLog('$ help', 'cmd'); appendLog(res.output, 'out'); }); } if (btnHint) { btnHint.addEventListener('click', function () { var res = executeCommand(cluster, 'hint'); if (hintOutput) hintOutput.textContent = res.output; appendLog('$ hint', 'cmd'); appendLog(res.output, 'out'); }); } appendLog('Kubernetes v1.28.2 · Istio 1.22.0 · Bookinfo demo in namespace "shop"', 'out'); appendLog('Type "help" for commands, "hint" for exercises, or click buttons above.', 'out'); renderUi(); })();Kubernetes and Istio simulator
Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.
<div class="kubectl-sim" tabindex="0" aria-label="Kubernetes and Istio cluster simulator"> <header class="bar"> <div class="bar-title"> <span class="cluster-ctx">Cluster <b>mesh-lab</b></span> <span class="ns-ctx">Namespace <b id="activeNs">shop</b></span> </div> <div class="bar-actions"> <button type="button" id="btnLoad">Load (100 reqs)</button> <button type="button" id="btnReset">Reset</button> <button type="button" id="btnHelp">Help</button> </div> </header> <div class="sim-body"> <section class="term-pane" aria-label="Terminal"> <div class="term-log" id="termLog" role="log" aria-live="polite"></div> <div class="term-form" id="termForm"> <label for="termInput" class="term-prompt">$</label> <input id="termInput" class="term-input" type="text" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-label="kubectl command input" /> </div> </section> <aside class="side-pane"> <section class="panel exercises-panel"> <h3 class="panel-title">Guided exercises</h3> <ol class="exercise-list" id="exerciseList"> <li data-step="1"><span class="check" id="chk1">○</span> <span class="step-text">Enable injection on namespace shop</span></li> <li data-step="2"><span class="check" id="chk2">○</span> <span class="step-text">Restart reviews to inject sidecars (2/2)</span></li> <li data-step="3"><span class="check" id="chk3">○</span> <span class="step-text">Split reviews traffic 50/50 (v2/v3)</span></li> <li data-step="4"><span class="check" id="chk4">○</span> <span class="step-text">Add 2 s delay to ratings (20% reqs)</span></li> <li data-step="5"><span class="check" id="chk5">○</span> <span class="step-text">Turn on strict mTLS</span></li> </ol> <div class="hint-bar"> <button type="button" id="btnHint">Hint</button> <span class="hint-text" id="hintOutput">Type hint or click for guidance.</span> </div> </section> <section class="panel mesh-panel"> <div class="panel-header"> <h3 class="panel-title">Traffic topology</h3> <span class="mtls-status" id="mtlsStatus">mTLS permissive</span> </div> <svg class="mesh-svg" id="meshSvg" viewBox="0 0 380 180" role="img" aria-label="Bookinfo service topology diagram"></svg> <div class="traffic-stats" id="trafficStats"> <span class="stat">Total: <b id="statTotal">0</b></span> <span class="stat">v1: <b id="statV1">34%</b></span> <span class="stat">v2: <b id="statV2">33%</b></span> <span class="stat">v3: <b id="statV3">33%</b></span> <span class="stat">Ratings: <b id="statRatings">0</b></span> <span class="stat">Latency: <b id="statLatency">12 ms</b></span> </div> </section> </aside> </div> </div>.kubectl-sim { position: relative; outline: none; font-family: var(--sans); color: var(--ink); background: var(--paper); } .kubectl-sim:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; } .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--rule); background: var(--paper); font: 400 13px/1.3 var(--sans); color: var(--ink-2); } .bar b { font-weight: 500; color: var(--ink); } .bar-title { display: flex; align-items: center; gap: 16px; } .bar-actions { display: flex; gap: 8px; } .bar-actions button, .hint-bar button { min-height: 28px; padding: 4px 10px; font: 500 13px/1 var(--sans); background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; cursor: pointer; } .bar-actions button:hover, .hint-bar button:hover, .bar-actions button:focus-visible, .hint-bar button:focus-visible { background: var(--wash); } .sim-body { display: flex; flex-direction: column; gap: 16px; margin-top: 12px; } @media (min-width: 860px) { .sim-body { flex-direction: row; align-items: flex-start; } .term-pane { flex: 1 1 56%; } .side-pane { flex: 1 1 44%; } } .term-pane { display: flex; flex-direction: column; background: var(--paper); border: 1px solid var(--rule); } .term-log { flex: 1; max-height: 380px; min-height: 240px; overflow-y: auto; overflow-x: hidden; padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-line { font-weight: 600; color: var(--ink); margin-top: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-out { color: var(--ink-2); margin-bottom: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-log .cmd-err { color: var(--ink); font-style: italic; margin-bottom: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } .term-form { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-top: 1px solid var(--rule); background: var(--paper); } .term-prompt { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--ink); user-select: none; } .term-input { flex: 1; font-family: var(--mono); font-size: 12.5px; color: var(--ink); background: transparent; border: none; outline: none; min-height: 28px; } .side-pane { display: flex; flex-direction: column; gap: 16px; } .panel { padding-bottom: 14px; border-bottom: 1px solid var(--rule); } .panel:last-child { border-bottom: none; } .panel-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; } .panel-title { margin: 0 0 8px; font: 600 13px/1.3 var(--sans); color: var(--ink); } .exercise-list { list-style: none; padding: 0; margin: 0; } .exercise-list li { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; font: 400 13px/1.3 var(--sans); color: var(--ink-2); } .exercise-list .check { font-family: var(--mono); font-size: 13px; font-weight: 600; line-height: 1; color: var(--ink-2); } .exercise-list li.done { color: var(--ink); font-weight: 500; } .exercise-list li.done .check { color: var(--ink); } .hint-bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rule); } .hint-text { font: italic 400 12px/1.3 var(--serif); color: var(--ink-2); flex: 1; } .mtls-status { font: 400 12px/1.3 var(--sans); color: var(--ink-2); } .mtls-status.strict { font-weight: 600; color: var(--ink); } .mesh-svg { display: block; width: 100%; height: auto; max-height: 190px; background: var(--paper); } .traffic-stats { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; font: 400 12px/1.3 var(--sans); color: var(--ink-2); } .traffic-stats b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; } @media (pointer: coarse) { .bar-actions button, .hint-bar button { min-height: 44px; padding: 8px 14px; } .term-input { min-height: 44px; } }(function () { var BUILTIN_MANIFESTS = { 'destination-rule.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: DestinationRule\nmetadata:\n name: reviews\n namespace: shop\nspec:\n host: reviews\n subsets:\n - name: v1\n labels:\n version: v1\n - name: v2\n labels:\n version: v2\n - name: v3\n labels:\n version: v3', 'reviews-v2-v3-split.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n name: reviews\n namespace: shop\nspec:\n hosts:\n - reviews\n http:\n - route:\n - destination:\n host: reviews\n subset: v2\n weight: 50\n - destination:\n host: reviews\n subset: v3\n weight: 50', 'fault-delay.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n name: ratings\n namespace: shop\nspec:\n hosts:\n - ratings\n http:\n - fault:\n delay:\n percentage:\n value: 20\n fixedDelay: 2s\n route:\n - destination:\n host: ratings\n subset: v1', 'mtls-strict.yaml': 'apiVersion: security.istio.io/v1beta1\nkind: PeerAuthentication\nmetadata:\n name: default\n namespace: shop\nspec:\n mtls:\n mode: STRICT' }; var RESOURCE_KINDS = { deploy: 'deployment', deployment: 'deployment', deployments: 'deployment', 'deploy.apps': 'deployment', 'deployment.apps': 'deployment', 'deployments.apps': 'deployment', po: 'pod', pod: 'pod', pods: 'pod', svc: 'service', service: 'service', services: 'service', ns: 'namespace', namespace: 'namespace', namespaces: 'namespace', rs: 'replicaset', replicaset: 'replicaset', replicasets: 'replicaset', 'replicasets.apps': 'replicaset', no: 'node', node: 'node', nodes: 'node', vs: 'virtualservice', virtualservice: 'virtualservice', virtualservices: 'virtualservice', 'virtualservice.networking.istio.io': 'virtualservice', 'virtualservices.networking.istio.io': 'virtualservice', dr: 'destinationrule', destinationrule: 'destinationrule', destinationrules: 'destinationrule', 'destinationrule.networking.istio.io': 'destinationrule', 'destinationrules.networking.istio.io': 'destinationrule', pa: 'peerauthentication', peerauthentication: 'peerauthentication', peerauthentications: 'peerauthentication', 'peerauthentication.security.istio.io': 'peerauthentication', 'peerauthentications.security.istio.io': 'peerauthentication', gw: 'gateway', gateway: 'gateway', gateways: 'gateway', 'gateway.networking.istio.io': 'gateway', 'gateways.networking.istio.io': 'gateway' }; var ERROR_TYPE_NAMES = { deployment: 'deployments.apps', pod: 'pods', service: 'services', namespace: 'namespaces', replicaset: 'replicasets.apps', node: 'nodes', virtualservice: 'virtualservices.networking.istio.io', destinationrule: 'destinationrules.networking.istio.io', peerauthentication: 'peerauthentications.security.istio.io', gateway: 'gateways.networking.istio.io' }; function formatAge(seconds) { if (seconds < 60) return Math.max(0, Math.floor(seconds)) + 's'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm'; if (seconds < 86400) return Math.floor(seconds / 3600) + 'h'; return Math.floor(seconds / 86400) + 'd'; } function formatColumns(headers, rows) { if (rows.length === 0) return headers.join(' '); var colCount = headers.length; var colWidths = headers.map(function (h) { return h.length; }); for (var r = 0; r < rows.length; r++) { for (var c = 0; c < colCount; c++) { var cell = rows[r][c] || ''; if (cell.length > colWidths[c]) colWidths[c] = cell.length; } } function renderRow(cells) { return cells.map(function (cell, idx) { if (idx === colCount - 1) return cell; var w = colWidths[idx] || 0; var str = cell || ''; while (str.length < w + 3) str += ' '; return str; }).join(''); } return [renderRow(headers)].concat(rows.map(renderRow)).join('\n'); } function createInitialCluster() { var baseAge = 2520; function mkDep(name, app, ver, ns, img, cont) { ns = ns || "shop"; return { name: name, namespace: ns, replicas: 1, readyReplicas: 1, updatedReplicas: 1, availableReplicas: 1, image: img || ("docker.io/istio/examples-bookinfo-" + name + ":1.19.0"), containers: cont || [app], labels: { app: app, version: ver }, selector: { app: app, version: ver }, sidecarInjected: false, ageSeconds: baseAge }; } function mkPod(name, depName, ip, node, app, ver, ns, cont) { ns = ns || "shop"; return { name: name, namespace: ns, ready: "1/1", status: "Running", restarts: 0, ageSeconds: baseAge, ip: ip, node: node, labels: { app: app, version: ver }, sidecar: false, deploymentName: depName, containerNames: cont || [app] }; } function mkSvc(name, ip, ns, type, ports) { ns = ns || "shop"; return { name: name, namespace: ns, type: type || "ClusterIP", clusterIP: ip, externalIP: "<none>", ports: ports || "9080/TCP", selector: { app: name }, ageSeconds: baseAge }; } function mkRs(name, dep, ns) { return { name: name, namespace: ns || "shop", desired: 1, current: 1, ready: 1, deploymentName: dep, ageSeconds: baseAge }; } return { currentNamespace: "shop", namespaces: { "default": { name: "default", status: "Active", labels: { "kubernetes.io/metadata.name": "default" }, ageSeconds: baseAge }, "istio-system": { name: "istio-system", status: "Active", labels: { "kubernetes.io/metadata.name": "istio-system" }, ageSeconds: baseAge }, "shop": { name: "shop", status: "Active", labels: { "kubernetes.io/metadata.name": "shop" }, ageSeconds: baseAge } }, nodes: [ { name: "node-1", status: "Ready", roles: "control-plane,worker", ageSeconds: baseAge, version: "v1.28.2" }, { name: "node-2", status: "Ready", roles: "worker", ageSeconds: baseAge, version: "v1.28.2" } ], deployments: { "details-v1": mkDep("details-v1", "details", "v1"), "productpage-v1": mkDep("productpage-v1", "productpage", "v1"), "ratings-v1": mkDep("ratings-v1", "ratings", "v1"), "reviews-v1": mkDep("reviews-v1", "reviews", "v1"), "reviews-v2": mkDep("reviews-v2", "reviews", "v2"), "reviews-v3": mkDep("reviews-v3", "reviews", "v3"), "istiod": mkDep("istiod", "istiod", "pilot", "istio-system", "docker.io/istio/pilot:1.22.0", ["discovery"]), "istio-ingressgateway": mkDep("istio-ingressgateway", "istio-ingressgateway", "ingressgateway", "istio-system", "docker.io/istio/proxyv2:1.22.0", ["istio-proxy"]) }, services: { details: mkSvc("details", "10.96.10.2"), productpage: mkSvc("productpage", "10.96.10.1"), ratings: mkSvc("ratings", "10.96.10.3"), reviews: mkSvc("reviews", "10.96.10.4"), istiod: mkSvc("istiod", "10.96.0.10", "istio-system", "ClusterIP", "15010/TCP,15012/TCP,443/TCP"), "istio-ingressgateway": { name: "istio-ingressgateway", namespace: "istio-system", type: "LoadBalancer", clusterIP: "10.96.0.20", externalIP: "192.168.1.100", ports: "80:30080/TCP,443:30443/TCP", selector: { app: "istio-ingressgateway" }, ageSeconds: baseAge } }, replicaSets: { "details-v1-66b6955995": mkRs("details-v1-66b6955995", "details-v1"), "productpage-v1-564d4686f": mkRs("productpage-v1-564d4686f", "productpage-v1"), "ratings-v1-6484c4d8bb": mkRs("ratings-v1-6484c4d8bb", "ratings-v1"), "reviews-v1-55b668b89": mkRs("reviews-v1-55b668b89", "reviews-v1"), "reviews-v2-68c5b5dd7b": mkRs("reviews-v2-68c5b5dd7b", "reviews-v2"), "reviews-v3-74b888b5dc": mkRs("reviews-v3-74b888b5dc", "reviews-v3"), "istiod-75f8f5c98d": mkRs("istiod-75f8f5c98d", "istiod", "istio-system"), "istio-ingressgateway-6d8fc9f4": mkRs("istio-ingressgateway-6d8fc9f4", "istio-ingressgateway", "istio-system") }, pods: [ mkPod("details-v1-66b6955995-m8q2x", "details-v1", "10.244.1.12", "node-2", "details", "v1"), mkPod("productpage-v1-564d4686f-9zk4b", "productpage-v1", "10.244.0.18", "node-1", "productpage", "v1"), mkPod("ratings-v1-6484c4d8bb-s8w5k", "ratings-v1", "10.244.1.14", "node-2", "ratings", "v1"), mkPod("reviews-v1-55b668b89-h6dfk", "reviews-v1", "10.244.0.22", "node-1", "reviews", "v1"), mkPod("reviews-v2-68c5b5dd7b-p7m2s", "reviews-v2", "10.244.1.25", "node-2", "reviews", "v2"), mkPod("reviews-v3-74b888b5dc-d4j8w", "reviews-v3", "10.244.0.31", "node-1", "reviews", "v3"), mkPod("istiod-75f8f5c98d-j2k9l", "istiod", "10.244.0.5", "node-1", "istiod", "pilot", "istio-system", ["discovery"]), mkPod("istio-ingressgateway-6d8fc9f4-x9q7s", "istio-ingressgateway", "10.244.1.8", "node-2", "istio-ingressgateway", "ingressgateway", "istio-system", ["istio-proxy"]) ], virtualServices: {}, destinationRules: {}, peerAuthentications: {}, gateways: { "bookinfo-gateway": { name: "bookinfo-gateway", namespace: "shop", hosts: ["*"], port: 80, ageSeconds: baseAge } }, traffic: { total: 0, productpage: 0, details: 0, reviewsV1: 0, reviewsV2: 0, reviewsV3: 0, ratings: 0, lastSplitV1: 34, lastSplitV2: 33, lastSplitV3: 33, avgLatencyMs: 12 }, elapsedSeconds: 0 }; } function parseCommand(line) { var trimmed = line.trim(); var execParts = trimmed.split(/\s+--\s+/); var mainPart = execParts[0] || ''; var execExtra = execParts.length > 1 ? execParts.slice(1).join(' -- ').trim().split(/\s+/) : undefined; var rawTokens = mainPart.split(/\s+/).filter(Boolean); if (rawTokens.length === 0) { return { raw: trimmed, tool: '', subCmd: '', args: [], flags: {}, execExtra: execExtra }; } var tool = rawTokens[0]; var flags = {}; var positional = []; for (var i = 1; i < rawTokens.length; i++) { var token = rawTokens[i]; if (token.indexOf('--') === 0) { var eqIdx = token.indexOf('='); if (eqIdx !== -1) { flags[token.slice(2, eqIdx)] = token.slice(eqIdx + 1); } else { var k = token.slice(2); var next = rawTokens[i + 1]; if (next && next.indexOf('-') !== 0) { flags[k] = next; i++; } else { flags[k] = true; } } } else if (token.indexOf('-') === 0) { var eqIdx2 = token.indexOf('='); if (eqIdx2 !== -1) { flags[token.slice(1, eqIdx2)] = token.slice(eqIdx2 + 1); } else { var k2 = token.slice(1); if (k2 === 'A') { flags['A'] = true; } else { var next2 = rawTokens[i + 1]; if (next2 && next2.indexOf('-') !== 0) { flags[k2] = next2; i++; } else { flags[k2] = true; } } } } else { positional.push(token); } } return { raw: trimmed, tool: tool, subCmd: positional[0] || '', args: positional.slice(1), flags: flags, execExtra: execExtra }; } function resolveNamespace(state, flags) { if (flags['A'] || flags['all-namespaces']) return null; var ns = flags['n'] || flags['namespace']; if (ns && typeof ns === 'string') return ns; return state.currentNamespace; } function parseResourceTargets(args) { if (!args || args.length === 0) return { error: 'you must specify a resource' }; if (args[0].indexOf(',') !== -1 && args[0].indexOf('/') === -1) { var types = args[0].split(','); var multiTargets = []; for (var m = 0; m < types.length; m++) { var rawM = types[m].toLowerCase(); var kM = RESOURCE_KINDS[rawM]; if (!kM) return { errorType: types[m] }; multiTargets.push({ kind: kM, name: null, rawType: rawM }); } return { targets: multiTargets }; } if (args[0].indexOf('/') !== -1) { var targets = []; for (var i = 0; i < args.length; i++) { var slashIdx = args[i].indexOf('/'); if (slashIdx === -1) { var spelling = args[i].toLowerCase(); var k = RESOURCE_KINDS[spelling]; if (!k) return { errorType: args[i] }; targets.push({ kind: k, name: null, rawType: spelling }); } else { var rawT = args[i].slice(0, slashIdx).toLowerCase(); var name = args[i].slice(slashIdx + 1); var k2 = RESOURCE_KINDS[rawT]; if (!k2) return { errorType: rawT }; targets.push({ kind: k2, name: name, rawType: rawT }); } } return { targets: targets }; } var rawType = args[0].toLowerCase(); var kind = RESOURCE_KINDS[rawType]; if (!kind) return { errorType: args[0] }; var names = args.slice(1); if (names.length === 0) return { targets: [{ kind: kind, name: null, rawType: rawType }] }; var resTargets = []; for (var j = 0; j < names.length; j++) { var n = names[j]; if (n.indexOf('/') !== -1) n = n.slice(n.indexOf('/') + 1); resTargets.push({ kind: kind, name: n, rawType: rawType }); } return { targets: resTargets }; } function checkExercises(state) { var step1 = Boolean(state.namespaces['shop'] && state.namespaces['shop'].labels['istio-injection'] === 'enabled'); var reviewsPods = state.pods.filter(function (p) { return p.namespace === 'shop' && p.deploymentName.indexOf('reviews-') === 0; }); var step2 = Boolean(step1 && reviewsPods.length >= 3 && reviewsPods.every(function (p) { return p.sidecar && p.ready === '2/2' && p.status === 'Running'; })); var hasDr = Boolean(state.destinationRules['reviews'] && state.destinationRules['reviews'].subsets); var reviewsVs = state.virtualServices['reviews']; var hasSplit = Boolean(reviewsVs && reviewsVs.routes && reviewsVs.routes.some(function (r) { return r.subset === 'v2' && r.weight === 50; }) && reviewsVs.routes.some(function (r) { return r.subset === 'v3' && r.weight === 50; })); var step3 = Boolean(hasDr && hasSplit); var ratingsVs = state.virtualServices['ratings']; var step4 = Boolean(ratingsVs && ratingsVs.fault && ratingsVs.fault.delaySeconds === 2 && ratingsVs.fault.delayPercentage === 20); var defaultPa = state.peerAuthentications['default']; var step5 = Boolean(defaultPa && defaultPa.namespace === 'shop' && defaultPa.mode === 'STRICT'); return [step1, step2, step3, step4, step5]; } var SERVICE_GRAPH = [ { from: 'ingress', to: 'productpage' }, { from: 'productpage', to: 'details' }, { from: 'productpage', to: 'reviews-v1' }, { from: 'productpage', to: 'reviews-v2' }, { from: 'productpage', to: 'reviews-v3' }, { from: 'reviews-v2', to: 'ratings' }, { from: 'reviews-v3', to: 'ratings' } ]; function getReviewsWeights(state) { var rVs = state.virtualServices && state.virtualServices['reviews']; if (rVs && rVs.routes && rVs.routes.length > 0) { var rMap = {}; for (var i = 0; i < rVs.routes.length; i++) rMap[rVs.routes[i].subset] = rVs.routes[i].weight; return { v1: rMap['v1'] !== undefined ? rMap['v1'] : 0, v2: rMap['v2'] !== undefined ? rMap['v2'] : 0, v3: rMap['v3'] !== undefined ? rMap['v3'] : 0 }; } return { v1: 34, v2: 33, v3: 33 }; } function computeTopology(state) { var weights = getReviewsWeights(state); var isMtlsStrict = Boolean(state.peerAuthentications && state.peerAuthentications['default'] && state.peerAuthentications['default'].mode === 'STRICT'); var lockGlyph = isMtlsStrict ? ' 🔒' : ''; var faultActive = Boolean(state.virtualServices && state.virtualServices['ratings'] && state.virtualServices['ratings'].fault); var nodes = [ { id: 'ingress', title: 'Ingress', sub: ':80', x: 10, y: 75, width: 54, height: 26 }, { id: 'productpage', title: 'productpage', sub: 'v1' + lockGlyph, x: 94, y: 74, width: 80, height: 28 }, { id: 'details', title: 'details-v1', sub: 'v1' + lockGlyph, x: 226, y: 10, width: 72, height: 26 }, { id: 'reviews-v1', title: 'reviews-v1', sub: 'v1' + lockGlyph, x: 226, y: 52, width: 72, height: 26 }, { id: 'reviews-v2', title: 'reviews-v2', sub: 'v2' + lockGlyph, x: 226, y: 94, width: 72, height: 26 }, { id: 'reviews-v3', title: 'reviews-v3', sub: 'v3' + lockGlyph, x: 226, y: 136, width: 72, height: 26 }, { id: 'ratings', title: 'ratings-v1', sub: 'v1' + (faultActive ? ' (2s delay)' : '') + lockGlyph, x: 308, y: 114, width: 66, height: 28 } ]; var edges = [ { from: 'ingress', to: 'productpage', d: 'M 64 88 L 94 88' }, { from: 'productpage', to: 'details', d: 'M 174 80 L 186 23 L 226 23' }, { from: 'productpage', to: 'reviews-v1', d: 'M 174 84 L 186 65 L 226 65', weight: weights.v1, labelBox: { x: 191, y: 59, width: 24, height: 12, text: weights.v1 + '%' } }, { from: 'productpage', to: 'reviews-v2', d: 'M 174 88 L 186 107 L 226 107', weight: weights.v2, labelBox: { x: 191, y: 101, width: 24, height: 12, text: weights.v2 + '%' } }, { from: 'productpage', to: 'reviews-v3', d: 'M 174 92 L 186 149 L 226 149', weight: weights.v3, labelBox: { x: 191, y: 143, width: 24, height: 12, text: weights.v3 + '%' } }, { from: 'reviews-v2', to: 'ratings', d: 'M 298 107 L 308 122' }, { from: 'reviews-v3', to: 'ratings', d: 'M 298 149 L 308 134' } ]; return { viewBox: { width: 380, height: 180 }, nodes: nodes, edges: edges }; } function simulateTraffic(state, count) { if (typeof count !== 'number') count = 100; state.traffic.total += count; var weights = getReviewsWeights(state); var splitV1 = weights.v1, splitV2 = weights.v2, splitV3 = weights.v3; state.traffic.lastSplitV1 = splitV1; state.traffic.lastSplitV2 = splitV2; state.traffic.lastSplitV3 = splitV3; state.traffic.productpage += count; state.traffic.details += count; state.traffic.reviewsV1 += Math.round(count * (splitV1 / 100)); state.traffic.reviewsV2 += Math.round(count * (splitV2 / 100)); state.traffic.reviewsV3 += Math.round(count * (splitV3 / 100)); state.traffic.ratings += Math.round(count * ((splitV2 + splitV3) / 100)); var ratingsVs = state.virtualServices['ratings']; if (ratingsVs && ratingsVs.fault) { var faultDelay = ratingsVs.fault.delaySeconds || 2; var faultPct = ratingsVs.fault.delayPercentage || 20; state.traffic.avgLatencyMs = Math.round(12 + (faultDelay * 1000 * (faultPct / 100))); } else { state.traffic.avgLatencyMs = 12; } var lines = [ 'Simulated ' + count + ' incoming requests to productpage:9080/productpage', ' productpage: ' + state.traffic.productpage + ' total', ' details: ' + state.traffic.details + ' total', ' reviews v1: ' + splitV1 + '% (' + state.traffic.reviewsV1 + ')', ' reviews v2: ' + splitV2 + '% (' + state.traffic.reviewsV2 + ')', ' reviews v3: ' + splitV3 + '% (' + state.traffic.reviewsV3 + ')', ' ratings: ' + state.traffic.ratings + ' total (avg latency ' + state.traffic.avgLatencyMs + 'ms)' ]; return { output: lines.join('\n'), counts: state.traffic }; } function executeRolloutDep(state, dep, action) { if (action === 'status') return { line: 'deployment "' + dep.name + '" successfully rolled out' }; if (action === 'restart') { var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled'); dep.sidecarInjected = isNsInjected; state.pods = state.pods.filter(function (pod) { return pod.deploymentName !== dep.name; }); for (var i = 0; i < dep.replicas; i++) { var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: dep.name + '-rollout-' + randSuffix, namespace: dep.namespace, ready: isNsInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: '10.244.0.' + (20 + Math.floor(Math.random() * 40)), node: i % 2 === 0 ? 'node-1' : 'node-2', labels: Object.assign({}, dep.labels), sidecar: isNsInjected, deploymentName: dep.name, containerNames: isNsInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice() }); } return { line: 'deployment.apps/' + dep.name + ' restarted' }; } if (action === 'undo') { if (dep.previousImage) { dep.image = dep.previousImage; dep.previousImage = undefined; } for (var p = 0; p < state.pods.length; p++) { if (state.pods[p].deploymentName === dep.name) { state.pods[p].status = 'Running'; state.pods[p].ready = state.pods[p].sidecar ? '2/2' : '1/1'; } } return { line: 'deployment.apps/' + dep.name + ' rolled back' }; } } function handleKubectlRollout(state, p) { var action = p.args[0]; if (action !== 'status' && action !== 'restart' && action !== 'undo') { return { output: 'error: rollout action "' + action + '" not recognized. Supported: status, restart, undo', state: state, error: true }; } var targetArgs = p.args.slice(1); if (targetArgs.length === 0) return { output: 'error: you must specify the resource to ' + action, state: state, error: true }; var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (t.kind !== 'deployment') { return { output: 'error: cannot ' + action + ' ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true }; } if (t.name === null) { var nsDeps = Object.values(state.deployments).filter(function (d) { return d.namespace === ns; }); if (nsDeps.length === 0) outputs.push('No resources found in ' + ns + ' namespace.'); else { for (var d = 0; d < nsDeps.length; d++) outputs.push(executeRolloutDep(state, nsDeps[d], action).line); } } else { var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; outputs.push(executeRolloutDep(state, dep, action).line); } } return { output: outputs.join('\n'), state: state }; } function handleKubectlScale(state, p) { var rawReplicas = p.flags['replicas']; if (rawReplicas === undefined) return { output: 'error: --replicas is required', state: state, error: true }; var replicas = parseInt(String(rawReplicas), 10); if (isNaN(replicas) || replicas < 0) return { output: 'error: invalid replicas count: ' + rawReplicas, state: state, error: true }; if (p.args.length === 0) return { output: 'error: you must specify a resource to scale', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (t.kind !== 'deployment') { return { output: 'error: cannot scale ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true }; } if (!t.name) return { output: 'error: you must specify a deployment name to scale', state: state, error: true }; var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; var oldReplicas = dep.replicas; dep.replicas = replicas; dep.readyReplicas = replicas; dep.availableReplicas = replicas; dep.updatedReplicas = replicas; if (replicas > oldReplicas) { var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled'); for (var r = oldReplicas; r < replicas; r++) { var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: dep.name + '-scale-' + randSuffix, namespace: dep.namespace, ready: isNsInjected && dep.sidecarInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: '10.244.1.' + (30 + Math.floor(Math.random() * 40)), node: r % 2 === 0 ? 'node-1' : 'node-2', labels: Object.assign({}, dep.labels), sidecar: isNsInjected && dep.sidecarInjected, deploymentName: dep.name, containerNames: isNsInjected && dep.sidecarInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice() }); } } else if (replicas < oldReplicas) { var toRemove = oldReplicas - replicas; var removedCount = 0; state.pods = state.pods.filter(function (pod) { if (pod.deploymentName === dep.name && removedCount < toRemove) { removedCount++; return false; } return true; }); } outputs.push('deployment.apps/' + dep.name + ' scaled'); } return { output: outputs.join('\n'), state: state }; } function handleKubectlSet(state, p) { var sub = p.args[0]; if (sub !== 'image') return { output: 'error: set "' + sub + '" not supported. Use "kubectl set image deploy/<name> <container>=<image>"', state: state, error: true }; var rest = p.args.slice(1); if (rest.length === 0) return { output: 'error: you must specify a resource and container=image spec', state: state, error: true }; var specIdx = -1; for (var i = 0; i < rest.length; i++) { if (rest[i].indexOf('=') !== -1) { specIdx = i; break; } } if (specIdx === -1) return { output: 'error: you must specify a container image in format <container>=<image>', state: state, error: true }; var targetArgs = rest.slice(0, specIdx); var spec = rest[specIdx]; var specParts = spec.split('='); var containerName = specParts[0]; var newImage = specParts[1]; var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a deployment name', state: state, error: true }; var target = targets[0]; if (target.kind !== 'deployment') return { output: 'error: cannot set image on ' + target.rawType + ': the server doesn\'t have a resource type "' + target.rawType + '"', state: state, error: true }; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var dep = state.deployments[target.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === target.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + target.name + '" not found', state: state, error: true }; dep.previousImage = dep.image; dep.image = newImage; var isBroken = newImage.indexOf('bad') !== -1 || newImage.indexOf('invalid') !== -1 || newImage.indexOf('fail') !== -1; for (var podIdx = 0; podIdx < state.pods.length; podIdx++) { var pod = state.pods[podIdx]; if (pod.deploymentName === dep.name) { if (isBroken) { pod.status = 'ImagePullBackOff'; pod.ready = pod.sidecar ? '0/2' : '0/1'; } else { pod.status = 'Running'; pod.ready = pod.sidecar ? '2/2' : '1/1'; } } } return { output: 'deployment.apps/' + dep.name + ' image updated', state: state }; } function handleKubectlDelete(state, p) { var file = p.flags['f'] || p.flags['filename']; if (file) { var manifestName = String(file); if (!BUILTIN_MANIFESTS[manifestName]) return { output: 'error: the path "' + manifestName + '" does not exist', state: state, error: true }; if (manifestName === 'destination-rule.yaml') { delete state.destinationRules['reviews']; return { output: 'destinationrule.networking.istio.io "reviews" deleted', state: state }; } if (manifestName === 'reviews-v2-v3-split.yaml') { delete state.virtualServices['reviews']; return { output: 'virtualservice.networking.istio.io "reviews" deleted', state: state }; } if (manifestName === 'fault-delay.yaml') { if (state.virtualServices['ratings']) delete state.virtualServices['ratings'].fault; return { output: 'virtualservice.networking.istio.io "ratings" deleted', state: state }; } if (manifestName === 'mtls-strict.yaml') { delete state.peerAuthentications['default']; return { output: 'peerauthentication.security.istio.io "default" deleted', state: state }; } } if (p.args.length === 0) return { output: 'error: you must specify a resource to delete', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var outputs = []; for (var i = 0; i < targets.length; i++) { var t = targets[i]; if (!t.name) return { output: 'error: you must specify a resource name to delete', state: state, error: true }; if (t.kind === 'pod') { var podIdx = state.pods.findIndex(function (pod) { return (pod.namespace === ns || ns === null) && pod.name === t.name; }); if (podIdx === -1) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true }; var oldPod = state.pods[podIdx]; state.pods.splice(podIdx, 1); var depObj = state.deployments[oldPod.deploymentName]; if (depObj) { var isNsInjected = Boolean(state.namespaces[oldPod.namespace] && state.namespaces[oldPod.namespace].labels['istio-injection'] === 'enabled'); var sidecar = Boolean(isNsInjected && depObj.sidecarInjected); var randSuffix = Math.random().toString(36).slice(2, 7); state.pods.push({ name: oldPod.deploymentName + '-repl-' + randSuffix, namespace: oldPod.namespace, ready: sidecar ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1, ip: oldPod.ip, node: oldPod.node, labels: Object.assign({}, oldPod.labels), sidecar: sidecar, deploymentName: oldPod.deploymentName, containerNames: sidecar ? depObj.containers.concat(['istio-proxy']) : depObj.containers.slice() }); } outputs.push('pod "' + t.name + '" deleted'); } else if (t.kind === 'deployment') { var dep = state.deployments[t.name]; if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; delete state.deployments[t.name]; state.pods = state.pods.filter(function (p) { return p.deploymentName !== t.name; }); outputs.push('deployment.apps "' + t.name + '" deleted'); } else { var mapKey = { virtualservice: 'virtualServices', destinationrule: 'destinationRules', peerauthentication: 'peerAuthentications', service: 'services' }[t.kind]; if (mapKey) { if (!state[mapKey][t.name]) return { output: 'Error from server (NotFound): ' + ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" not found', state: state, error: true }; delete state[mapKey][t.name]; outputs.push(ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" deleted'); } else { return { output: 'error: cannot delete ' + t.rawType + ': unsupported resource type', state: state, error: true }; } } } return { output: outputs.join('\n'), state: state }; } function handleKubectlLabel(state, p) { var specIdx = -1; for (var i = 0; i < p.args.length; i++) { if (p.args[i].indexOf('=') !== -1) { specIdx = i; break; } } if (specIdx === -1) return { output: 'error: you must specify a label in key=value format', state: state, error: true }; var targetArgs = p.args.slice(0, specIdx); var spec = p.args[specIdx]; var eqIdx = spec.indexOf('='); var key = spec.slice(0, eqIdx); var val = spec.slice(eqIdx + 1); var parsed = parseResourceTargets(targetArgs); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a resource name to label', state: state, error: true }; var target = targets[0]; if (target.kind === 'namespace') { var nsObj = state.namespaces[target.name]; if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + target.name + '" not found', state: state, error: true }; nsObj.labels[key] = val; return { output: 'namespace/' + target.name + ' labeled', state: state }; } if (target.kind === 'pod') { var podObj = state.pods.find(function (pod) { return pod.name === target.name; }); if (!podObj) return { output: 'Error from server (NotFound): pods "' + target.name + '" not found', state: state, error: true }; podObj.labels[key] = val; return { output: 'pod/' + target.name + ' labeled', state: state }; } return { output: 'error: cannot label ' + target.rawType + ': unsupported resource type', state: state, error: true }; } function handleKubectlApply(state, p) { var file = p.flags['f'] || p.flags['filename'] || p.args[0]; if (!file) return { output: 'error: must specify one of -f and -k', state: state, error: true }; var filename = String(file); if (!BUILTIN_MANIFESTS[filename]) return { output: 'error: the path "' + filename + '" does not exist', state: state, error: true }; if (filename === 'destination-rule.yaml') { state.destinationRules['reviews'] = { name: 'reviews', namespace: 'shop', host: 'reviews', subsets: [{ name: 'v1', labels: { version: 'v1' } }, { name: 'v2', labels: { version: 'v2' } }, { name: 'v3', labels: { version: 'v3' } }], ageSeconds: 1 }; return { output: 'destinationrule.networking.istio.io/reviews created', state: state }; } if (filename === 'reviews-v2-v3-split.yaml') { state.virtualServices['reviews'] = { name: 'reviews', namespace: 'shop', hosts: ['reviews'], routes: [{ subset: 'v2', weight: 50 }, { subset: 'v3', weight: 50 }], ageSeconds: 1 }; return { output: 'virtualservice.networking.istio.io/reviews configured', state: state }; } if (filename === 'fault-delay.yaml') { if (!state.virtualServices['ratings']) { state.virtualServices['ratings'] = { name: 'ratings', namespace: 'shop', hosts: ['ratings'], routes: [{ subset: 'v1', weight: 100 }], ageSeconds: 1 }; } state.virtualServices['ratings'].fault = { delayPercentage: 20, delaySeconds: 2 }; return { output: 'virtualservice.networking.istio.io/ratings configured', state: state }; } if (filename === 'mtls-strict.yaml') { state.peerAuthentications['default'] = { name: 'default', namespace: 'shop', mode: 'STRICT', ageSeconds: 1 }; return { output: 'peerauthentication.security.istio.io/default created', state: state }; } return { output: 'applied ' + filename, state: state }; } function handleKubectlLogs(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a pod to get logs from', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var podName = targets[0] && targets[0].name ? targets[0].name : p.args[0]; if (podName.indexOf('/') !== -1) podName = podName.slice(podName.indexOf('/') + 1); var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podName; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + podName + '" not found', state: state, error: true }; var cName = p.flags['c'] || p.flags['container'] || pod.containerNames[0]; if (cName === 'istio-proxy') { return { output: [ '2026-09-26T10:00:00.123456Z info Envoy proxy initialized', '2026-09-26T10:00:00.234567Z info Pilot discovery connected: 10.96.0.10:15012', '2026-09-26T10:00:00.345678Z info CDS: route clusters synchronized', '2026-09-26T10:00:00.456789Z info mTLS: connection established using SPIFFE identity' ].join('\n'), state: state }; } return { output: [ '[Server] Listening on port 9080', '[Route] GET /reviews/0 HTTP/1.1 200 OK', '[Route] GET /ratings/0 HTTP/1.1 200 OK (latency: 14ms)' ].join('\n'), state: state }; } function handleKubectlExec(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a pod to exec into', state: state, error: true }; var podTarget = p.args[0]; if (podTarget.indexOf('/') !== -1) podTarget = podTarget.slice(podTarget.indexOf('/') + 1); var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podTarget; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + podTarget + '" not found', state: state, error: true }; var rVs = state.virtualServices['reviews']; var stars = '(no stars)'; if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v2'; })) { stars = '★★★★☆ (black stars from ratings)'; } else if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v3'; })) { stars = '★★★★★ (red stars from ratings)'; } return { output: [ 'HTTP/1.1 200 OK', 'content-type: text/html; charset=utf-8', 'content-length: 1824', '', '<!DOCTYPE html><html><title>Simple Bookstore App</title><body>', '<h1>Product: The Comedy of Errors</h1>', '<p>Reviews: ' + stars + '</p>', '</body></html>' ].join('\n'), state: state }; } function handleKubectlDescribe(state, p) { if (p.args.length === 0) return { output: 'error: you must specify a resource to describe', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; var t = targets[0]; if (!t.name) return { output: 'error: you must specify a resource name', state: state, error: true }; if (t.kind === 'pod') { var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === t.name; }); if (!pod) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true }; return { output: [ 'Name: ' + pod.name, 'Namespace: ' + pod.namespace, 'Node: ' + pod.node, 'Status: ' + pod.status, 'IP: ' + pod.ip, 'Containers: ' + pod.containerNames.join(', '), 'Sidecar: ' + (pod.sidecar ? 'injected' : 'none'), 'Ready: ' + pod.ready ].join('\n'), state: state }; } if (t.kind === 'deployment') { var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return (d.namespace === ns || ns === null) && d.name === t.name; }); if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true }; return { output: [ 'Name: ' + dep.name, 'Namespace: ' + dep.namespace, 'Replicas: ' + dep.replicas + ' desired | ' + dep.readyReplicas + ' ready', 'Image: ' + dep.image, 'Sidecar injection: ' + (dep.sidecarInjected ? 'injected' : 'none') ].join('\n'), state: state }; } if (t.kind === 'service') { var svc = state.services[t.name]; if (!svc) return { output: 'Error from server (NotFound): services "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + svc.name, 'Namespace: ' + svc.namespace, 'Type: ' + svc.type, 'IP: ' + svc.clusterIP].join('\n'), state: state }; } if (t.kind === 'namespace') { var nsObj = state.namespaces[t.name]; if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + nsObj.name, 'Status: ' + nsObj.status].join('\n'), state: state }; } if (t.kind === 'virtualservice') { var vs = state.virtualServices[t.name]; if (!vs) return { output: 'Error from server (NotFound): virtualservices.networking.istio.io "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + vs.name, 'Hosts: ' + vs.hosts.join(',')].join('\n'), state: state }; } if (t.kind === 'destinationrule') { var dr = state.destinationRules[t.name]; if (!dr) return { output: 'Error from server (NotFound): destinationrules.networking.istio.io "' + t.name + '" not found', state: state, error: true }; return { output: ['Name: ' + dr.name, 'Host: ' + dr.host].join('\n'), state: state }; } return { output: 'error: describe not supported for ' + t.rawType, state: state, error: true }; } function handleKubectlGet(state, p) { if (p.args.length === 0) return { output: 'error: You must specify the type of resource to get.', state: state, error: true }; var parsed = parseResourceTargets(p.args); if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true }; var targets = parsed.targets || []; var ns = resolveNamespace(state, p.flags); var wide = Boolean(p.flags['o'] === 'wide' || p.flags['output'] === 'wide'); var nameOnly = Boolean(p.flags['o'] === 'name' || p.flags['output'] === 'name'); var selector = p.flags['l'] || p.flags['selector']; function matchesSelector(labels) { if (!selector) return true; var parts = String(selector).split(','); for (var k = 0; k < parts.length; k++) { var pair = parts[k].split('='); var key = (pair[0] || '').trim(); var val = (pair[1] || '').trim(); if (!labels[key] || labels[key] !== val) return false; } return true; } function renderTable(items, headers, rowFn, notFoundKind, targetName) { if (targetName !== null && items.length === 0) { return { err: 'Error from server (NotFound): ' + notFoundKind + ' "' + targetName + '" not found' }; } if (items.length === 0) { return { out: ns ? 'No resources found in ' + ns + ' namespace.' : 'No resources found.' }; } if (nameOnly) { return { out: items.map(function (it) { return it.name; }).join('\n') }; } return { out: formatColumns(headers, items.map(rowFn)) }; } var outputs = []; for (var tIdx = 0; tIdx < targets.length; tIdx++) { var target = targets[tIdx]; if (target.kind === 'pod') { var podList = state.pods.filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (!matchesSelector(item.labels)) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var pHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE'] : ['NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE']; if (wide) pHeaders.push('IP', 'NODE'); var rP = renderTable(podList, pHeaders, function (pod) { var row = ns === null ? [pod.namespace, pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)] : [pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)]; if (wide) row.push(pod.ip, pod.node); return row; }, 'pods', target.name); if (rP.err) return { output: rP.err, state: state, error: true }; outputs.push(rP.out); } else if (target.kind === 'deployment') { var depList = Object.values(state.deployments).filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var dHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE'] : ['NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE']; var rD = renderTable(depList, dHeaders, function (dep) { var rStr = dep.readyReplicas + '/' + dep.replicas; return ns === null ? [dep.namespace, dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)] : [dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)]; }, 'deployments.apps', target.name); if (rD.err) return { output: rD.err, state: state, error: true }; outputs.push(rD.out); } else if (target.kind === 'service') { var svcList = Object.values(state.services).filter(function (item) { if (ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var sHeaders = ns === null ? ['NAMESPACE', 'NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE'] : ['NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE']; var rS = renderTable(svcList, sHeaders, function (svc) { return ns === null ? [svc.namespace, svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)] : [svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)]; }, 'services', target.name); if (rS.err) return { output: rS.err, state: state, error: true }; outputs.push(rS.out); } else if (target.kind === 'namespace') { var nsList = Object.values(state.namespaces).filter(function (item) { if (target.name !== null && item.name !== target.name) return false; return true; }); var rN = renderTable(nsList, ['NAME', 'STATUS', 'AGE'], function (item) { return [item.name, item.status, formatAge(item.ageSeconds)]; }, 'namespaces', target.name); if (rN.err) return { output: rN.err, state: state, error: true }; outputs.push(rN.out); } else { var genericMap = { virtualservice: { map: 'virtualServices', cols: ['NAME', 'GATEWAYS', 'HOSTS', 'AGE'], fn: function (vs) { return [vs.name, '[mesh]', vs.hosts.join(','), formatAge(vs.ageSeconds)]; } }, destinationrule: { map: 'destinationRules', cols: ['NAME', 'HOST', 'AGE'], fn: function (dr) { return [dr.name, dr.host, formatAge(dr.ageSeconds)]; } }, peerauthentication: { map: 'peerAuthentications', cols: ['NAME', 'MODE', 'AGE'], fn: function (pa) { return [pa.name, pa.mode, formatAge(pa.ageSeconds)]; } }, gateway: { map: 'gateways', cols: ['NAME', 'AGE'], fn: function (gw) { return [gw.name, formatAge(gw.ageSeconds)]; } }, node: { list: state.nodes, cols: ['NAME', 'STATUS', 'ROLES', 'AGE', 'VERSION'], fn: function (n) { return [n.name, n.status, n.roles, formatAge(n.ageSeconds), n.version]; } }, replicaset: { map: 'replicaSets', cols: ['NAME', 'DESIRED', 'CURRENT', 'READY', 'AGE'], fn: function (rs) { return [rs.name, String(rs.desired), String(rs.current), String(rs.ready), formatAge(rs.ageSeconds)]; } } }[target.kind]; if (genericMap) { var rawItems = genericMap.list || Object.values(state[genericMap.map]); var filtered = rawItems.filter(function (item) { if (genericMap.map && item.namespace && ns !== null && item.namespace !== ns) return false; if (target.name !== null && item.name !== target.name) return false; return true; }); var resGen = renderTable(filtered, genericMap.cols, genericMap.fn, ERROR_TYPE_NAMES[target.kind], target.name); if (resGen.err) return { output: resGen.err, state: state, error: true }; outputs.push(resGen.out); } } } return { output: outputs.join('\n'), state: state }; } function handleIstioctl(state, p) { var sub = p.subCmd; var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop'; if (sub === 'version') { return { output: [ 'client version: 1.22.0', 'control plane version: 1.22.0', 'data plane version: 1.22.0 (8 proxies)' ].join('\n'), state: state }; } if (sub === 'proxy-status' || sub === 'ps') { var rows = [ ['istio-ingressgateway-6d8fc9f4-x9q7s.istio-system', 'SYNCED', 'SYNCED', 'SYNCED', 'SYNCED', 'istiod-75f8f5c98d-j2k9l', '1.22.0'] ]; for (var i = 0; i < state.pods.length; i++) { var pod = state.pods[i]; if (pod.sidecar) { rows.push([ pod.name + '.' + pod.namespace, 'SYNCED', 'SYNCED', 'SYNCED', 'SYNCED', 'istiod-75f8f5c98d-j2k9l', '1.22.0' ]); } } return { output: formatColumns(['NAME', 'CDS', 'LDS', 'EDS', 'RDS', 'ISTIOD', 'VERSION'], rows), state: state }; } if (sub === 'analyze') { var targetNs = ns || 'shop'; var isNsInjected = Boolean(state.namespaces[targetNs] && state.namespaces[targetNs].labels['istio-injection'] === 'enabled'); if (!isNsInjected) { return { output: [ 'Warning [IST0102] (Namespace ' + targetNs + ') The namespace is not enabled for Istio injection.', ' Run \'kubectl label namespace ' + targetNs + ' istio-injection=enabled\' to enable automatic injection.', 'Info [IST0118] (Service details.shop) Port name details-port (service: details.shop) does not follow Istio naming convention.' ].join('\n'), state: state }; } return { output: '✔ No validation issues found when analyzing namespace: ' + targetNs + '.', state: state }; } if (sub === 'x' && p.args[0] === 'describe') { var pName = p.args[2] || p.args[1] || ''; if (pName.indexOf('/') !== -1) pName = pName.slice(pName.indexOf('/') + 1); var pPod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === pName; }); if (!pPod) return { output: 'Error: pod "' + pName + '" not found in namespace "' + ns + '"', state: state, error: true }; if (!pPod.sidecar) return { output: 'Pod ' + pPod.name + ' has no Istio sidecar injected.', state: state }; return { output: [ 'Pod: ' + pPod.name, 'Namespace: ' + pPod.namespace, 'Sidecar: istio-proxy (docker.io/istio/proxyv2:1.22.0)', 'Inbound Port: 9080/HTTP -> 127.0.0.1:9080', 'mTLS: PERMISSIVE (PeerAuthentication default.shop)', 'VirtualService: ' + (state.virtualServices['reviews'] ? 'reviews.shop (HTTP routes: v2=50%, v3=50%)' : 'none') ].join('\n'), state: state }; } return { output: 'istioctl: unknown subcommand "' + sub + '". Try "istioctl proxy-status" or "istioctl analyze".', state: state, error: true }; } function executeCommand(state, line) { var p = parseCommand(line); if (!p.tool) return { output: '', state: state }; if (p.tool === 'clear') return { output: '__CLEAR__', state: state }; if (p.tool === 'reset') { var fresh = createInitialCluster(); Object.keys(state).forEach(function (k) { delete state[k]; }); Object.assign(state, fresh); return { output: 'Cluster reset to initial state.', state: state }; } if (p.tool === 'help') { return { output: [ 'Supported commands:', ' kubectl get pods|deploy|svc|ns|vs|dr|pa|nodes [-n ns|-A] [-o wide|name] [-l key=val]', ' kubectl describe pod|deploy|svc|ns|vs|dr <name> [-n ns]', ' kubectl logs <pod> [-c container] [-n ns]', ' kubectl scale deploy <name> --replicas=N', ' kubectl delete pod|deploy|svc <name> [-n ns]', ' kubectl rollout status|restart|undo deploy/<name> [-n ns]', ' kubectl set image deploy/<name> <container>=<image>', ' kubectl label namespace <name> <key>=<val>', ' kubectl apply -f <manifest.yaml>', ' kubectl exec <pod> -- curl -s productpage:9080/productpage', ' istioctl version | proxy-status | analyze [-n ns] | x describe pod <p>', ' cat <manifest> | ls | hint | reset | clear' ].join('\n'), state: state }; } if (p.tool === 'ls') { return { output: 'destination-rule.yaml reviews-v2-v3-split.yaml fault-delay.yaml mtls-strict.yaml', state: state }; } if (p.tool === 'cat') { var fileName = p.subCmd || p.args[0]; if (fileName && BUILTIN_MANIFESTS[fileName]) return { output: BUILTIN_MANIFESTS[fileName], state: state }; return { output: 'cat: ' + (fileName || 'missing') + ': No such file or directory', state: state, error: true }; } if (p.tool === 'hint') { var chks = checkExercises(state); if (!chks[0]) return { output: 'Hint (Step 1): Reviews pods show 1/1 READY (no sidecar) because namespace "shop" is not labeled for automatic injection. Run:\n kubectl label namespace shop istio-injection=enabled', state: state }; if (!chks[1]) return { output: 'Hint (Step 2): Namespace injection is enabled, but running pods must be restarted to inject Envoy sidecars (2/2). Restart the deployments:\n kubectl rollout restart deployment reviews-v1 reviews-v2 reviews-v3', state: state }; if (!chks[2]) return { output: 'Hint (Step 3): Apply destination-rule.yaml and reviews-v2-v3-split.yaml to define subsets and split traffic 50/50 between v2 and v3:\n kubectl apply -f destination-rule.yaml\n kubectl apply -f reviews-v2-v3-split.yaml', state: state }; if (!chks[3]) return { output: 'Hint (Step 4): Apply fault-delay.yaml to introduce a 2s delay on 20% of ratings calls:\n kubectl apply -f fault-delay.yaml', state: state }; if (!chks[4]) return { output: 'Hint (Step 5): Apply mtls-strict.yaml to enforce mutual TLS across the shop namespace:\n kubectl apply -f mtls-strict.yaml', state: state }; return { output: 'All 5 guided exercises completed! Try traffic burst or kubectl exec curl.', state: state }; } if (p.tool === 'kubectl') { var sub = p.subCmd; if (sub === 'get') return handleKubectlGet(state, p); if (sub === 'describe') return handleKubectlDescribe(state, p); if (sub === 'delete') return handleKubectlDelete(state, p); if (sub === 'scale') return handleKubectlScale(state, p); if (sub === 'rollout') return handleKubectlRollout(state, p); if (sub === 'set') return handleKubectlSet(state, p); if (sub === 'label') return handleKubectlLabel(state, p); if (sub === 'apply') return handleKubectlApply(state, p); if (sub === 'logs') return handleKubectlLogs(state, p); if (sub === 'exec') return handleKubectlExec(state, p); return { output: 'kubectl: unknown subcommand "' + sub + '". Type "help" for usage.', state: state, error: true }; } if (p.tool === 'istioctl') return handleIstioctl(state, p); return { output: p.tool + ': command not found. Type "help" for usage.', state: state, error: true }; } var root = document.querySelector('.kubectl-sim') || document.body; var termLog = root.querySelector('#termLog'); var termInput = root.querySelector('#termInput'); var mtlsStatus = root.querySelector('#mtlsStatus'); var activeNs = root.querySelector('#activeNs'); var statTotal = root.querySelector('#statTotal'); var statV1 = root.querySelector('#statV1'); var statV2 = root.querySelector('#statV2'); var statV3 = root.querySelector('#statV3'); var statRatings = root.querySelector('#statRatings'); var statLatency = root.querySelector('#statLatency'); var meshSvg = root.querySelector('#meshSvg'); var hintOutput = root.querySelector('#hintOutput'); var btnLoad = root.querySelector('#btnLoad'); var btnReset = root.querySelector('#btnReset'); var btnHelp = root.querySelector('#btnHelp'); var btnHint = root.querySelector('#btnHint'); var cluster = createInitialCluster(); var history = []; var historyIndex = 0; function appendLog(line, type) { if (!termLog) return; var p = document.createElement('div'); p.className = type === 'cmd' ? 'cmd-line' : type === 'err' ? 'cmd-err' : 'cmd-out'; p.textContent = line; termLog.appendChild(p); termLog.scrollTop = termLog.scrollHeight; } function renderExercises() { var checks = checkExercises(cluster); for (var i = 1; i <= 5; i++) { var chk = root.querySelector('#chk' + i); var item = chk ? chk.parentElement : null; if (chk) { if (checks[i - 1]) { chk.innerHTML = '✓'; if (item) item.classList.add('done'); } else { chk.innerHTML = '○'; if (item) item.classList.remove('done'); } } } } function renderTrafficMesh() { var topo = computeTopology(cluster); var isMtlsStrict = Boolean(cluster.peerAuthentications['default'] && cluster.peerAuthentications['default'].mode === 'STRICT'); if (mtlsStatus) { mtlsStatus.textContent = isMtlsStrict ? 'mTLS strict' : 'mTLS permissive'; if (isMtlsStrict) mtlsStatus.classList.add('strict'); else mtlsStatus.classList.remove('strict'); } if (activeNs) activeNs.textContent = cluster.currentNamespace; if (statTotal) statTotal.textContent = String(cluster.traffic.total); if (statV1) statV1.textContent = cluster.traffic.lastSplitV1 + '%'; if (statV2) statV2.textContent = cluster.traffic.lastSplitV2 + '%'; if (statV3) statV3.textContent = cluster.traffic.lastSplitV3 + '%'; if (statRatings) statRatings.textContent = String(cluster.traffic.ratings); if (statLatency) statLatency.textContent = cluster.traffic.avgLatencyMs + ' ms'; if (!meshSvg) return; var parts = [ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 1 L 10 5 L 0 9 z" fill="var(--ink-2)"/></marker></defs>' ]; for (var i = 0; i < topo.edges.length; i++) { var e = topo.edges[i]; parts.push('<path d="' + e.d + '" fill="none" stroke="var(--ink-2)" stroke-width="1.2" marker-end="url(#arrow)"/>'); if (e.labelBox) { var lb = e.labelBox; parts.push('<rect x="' + lb.x + '" y="' + lb.y + '" width="' + lb.width + '" height="' + lb.height + '" fill="var(--paper)"/>'); parts.push('<text x="' + (lb.x + lb.width / 2) + '" y="' + (lb.y + lb.height / 2 + 3) + '" font-family="var(--sans)" font-size="9" text-anchor="middle" fill="var(--ink-2)" stroke="var(--paper)" stroke-width="2.5" paint-order="stroke fill">' + lb.text + '</text>'); } } for (var j = 0; j < topo.nodes.length; j++) { var n = topo.nodes[j]; parts.push('<g class="mesh-node" transform="translate(' + n.x + ', ' + n.y + ')">'); parts.push('<rect width="' + n.width + '" height="' + n.height + '" rx="3" fill="var(--paper)" stroke="var(--ink)" stroke-width="1"/>'); parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 - 1) + '" font-family="var(--sans)" font-size="10" font-weight="600" text-anchor="middle" fill="var(--ink)">' + n.title + '</text>'); parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 + 10) + '" font-family="var(--mono)" font-size="8" text-anchor="middle" fill="var(--ink-2)">' + n.sub + '</text>'); parts.push('</g>'); } meshSvg.innerHTML = parts.join(''); } function renderUi() { renderExercises(); renderTrafficMesh(); } function handleLine(cmd) { if (!cmd.trim()) return; history.push(cmd); historyIndex = history.length; appendLog('$ ' + cmd, 'cmd'); var res = executeCommand(cluster, cmd); if (res.output === '__CLEAR__') { if (termLog) termLog.innerHTML = ''; } else if (res.output) { appendLog(res.output, res.error ? 'err' : 'out'); } renderUi(); } if (termInput) { termInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); var val = termInput.value; termInput.value = ''; handleLine(val); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (history.length > 0) { if (historyIndex > 0) historyIndex--; else historyIndex = 0; termInput.value = history[historyIndex] || ''; } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (historyIndex < history.length - 1) { historyIndex++; termInput.value = history[historyIndex] || ''; } else { historyIndex = history.length; termInput.value = ''; } } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); if (termLog) termLog.innerHTML = ''; } else if (e.key === 'Tab') { e.preventDefault(); var current = termInput.value; var tokens = current.split(' '); var lastTok = tokens[tokens.length - 1] || ''; var candidates = [ 'kubectl', 'istioctl', 'help', 'hint', 'clear', 'reset', 'cat', 'ls', 'get', 'describe', 'logs', 'scale', 'delete', 'rollout', 'set', 'label', 'apply', 'exec', 'pods', 'deployments', 'services', 'virtualservices', 'destinationrules', 'peerauthentications', 'destination-rule.yaml', 'reviews-v2-v3-split.yaml', 'fault-delay.yaml', 'mtls-strict.yaml' ]; Object.keys(cluster.deployments).forEach(function (d) { candidates.push(d); candidates.push('deploy/' + d); candidates.push('deployment/' + d); }); cluster.pods.forEach(function (pod) { candidates.push(pod.name); candidates.push('pod/' + pod.name); }); var matches = candidates.filter(function (c) { return c.indexOf(lastTok) === 0; }); if (matches.length === 1) { tokens[tokens.length - 1] = matches[0]; termInput.value = tokens.join(' ') + ' '; } else if (matches.length > 1) { appendLog(matches.join(' '), 'out'); } } }); } if (btnLoad) { btnLoad.addEventListener('click', function () { var res = simulateTraffic(cluster, 100); appendLog('$ simulate-traffic 100', 'cmd'); appendLog(res.output, 'out'); renderUi(); }); } if (btnReset) { btnReset.addEventListener('click', function () { var res = executeCommand(cluster, 'reset'); if (termLog) termLog.innerHTML = ''; appendLog(res.output, 'out'); renderUi(); }); } if (btnHelp) { btnHelp.addEventListener('click', function () { var res = executeCommand(cluster, 'help'); appendLog('$ help', 'cmd'); appendLog(res.output, 'out'); }); } if (btnHint) { btnHint.addEventListener('click', function () { var res = executeCommand(cluster, 'hint'); if (hintOutput) hintOutput.textContent = res.output; appendLog('$ hint', 'cmd'); appendLog(res.output, 'out'); }); } appendLog('Kubernetes v1.28.2 · Istio 1.22.0 · Bookinfo demo in namespace "shop"', 'out'); appendLog('Type "help" for commands, "hint" for exercises, or click buttons above.', 'out'); renderUi(); })();A half-day Istio workshop as a handout: the afternoon as a table, every command as a step, and a cluster in the page for the laptop that will not start one. See the recipe
-
33A standard operating procedureWork and teams
Document controlControlled copy
- SOP number
- SOP-PA-014
- Revision
- 6, superseding revision 5 of 3 March 2025
- Owner
- Pre-analytical supervisor, R. Adeyemi
- Approved by
- Laboratory manager
- Effective
- 1 October 2026
- Next review
- 1 October 2028
Printed copies are uncontrolled. Check a printed revision number against this page before you rely on it.
Document control · Controlled copy
SOP number: SOP-PA-014
Revision: 6, superseding revision 5 of 3 March 2025
Owner: Pre-analytical supervisor, R. Adeyemi
Approved by: Laboratory manager
Effective: 1 October 2026
Next review: 1 October 2028
Printed copies are uncontrolled. Check a printed revision number against this page before you rely on it.
Document control on a card, the procedure as numbered steps, the accept-or-reject call as a flowchart, and every revision kept with its reason. See the recipe
-
16A Regents study shelfStudy and exams
Civil rights, from Brown to the Voting Rights Act
Brown v. Board of Education
A unanimous Supreme Court rules segregated public schools unconstitutional, overturning Plessy’s “separate but equal” in education.
- 5 December 1955 – 20 December 1956
Montgomery bus boycott
Begun after Rosa Parks’s arrest; it ends once the Supreme Court upholds a ruling against segregated buses.
The Little Rock Nine
Nine Black students enter Central High School under the protection of federal troops sent by Eisenhower.
Greensboro sit-in
Four students sit at a whites-only lunch counter; sit-ins spread across the South.
March on Washington
About 250,000 people; Martin Luther King Jr. gives the “I Have a Dream” speech.
Twenty-fourth Amendment ratified
Poll taxes are banned in federal elections.
Civil Rights Act
Signed by Lyndon Johnson: bans discrimination in public accommodations and employment.
“Bloody Sunday” at Selma
State troopers attack marchers on the Edmund Pettus Bridge.
Voting Rights Act
Suspends literacy tests and brings federal oversight of registration where discrimination had kept Black voters off the rolls.
Civil rights, from Brown to the Voting Rights Act
- 17 May 1954 — Brown v. Board of Education
A unanimous Supreme Court rules segregated public schools unconstitutional, overturning Plessy’s “separate but equal” in education.
- 5 December 1955 – 20 December 1956 — Montgomery bus boycott
Begun after Rosa Parks’s arrest; it ends once the Supreme Court upholds a ruling against segregated buses.
- 25 September 1957 — The Little Rock Nine
Nine Black students enter Central High School under the protection of federal troops sent by Eisenhower.
- 1 February 1960 — Greensboro sit-in
Four students sit at a whites-only lunch counter; sit-ins spread across the South.
- 28 August 1963 — March on Washington
About 250,000 people; Martin Luther King Jr. gives the “I Have a Dream” speech.
- 23 January 1964 — Twenty-fourth Amendment ratified
Poll taxes are banned in federal elections.
- 2 July 1964 — Civil Rights Act
Signed by Lyndon Johnson: bans discrimination in public accommodations and employment.
- 7 March 1965 — “Bloody Sunday” at Selma
State troopers attack marchers on the Edmund Pettus Bridge.
- 6 August 1965 — Voting Rights Act
Suspends literacy tests and brings federal oversight of registration where discrimination had kept Black voters off the rolls.
Every topic a document with its timeline, the Supreme Court cases as flashcards, quizzes that come back when due, and a plan that runs to June. See the recipe
-
53A fantasy league almanacHobbies and play
Points by week, the four top-scoring teams, weeks 1 to 5
Points by week, the four top-scoring teams, weeks 1 to 5 Week Fourth and Long Hail Mary Club Two-Minute Drill The Taxi Squad Week 1 118.4 points 102.6 points 125.9 points 91.3 points Week 2 96.2 points 124.8 points 83.7 points 108.5 points Week 3 131.7 points 88.3 points 99.2 points 112 points Week 4 104.9 points 115.1 points 121.6 points 79.4 points Week 5 122.3 points 109.6 points 94.5 points 127.8 points Weekly scores from the league’s platform, half-point per reception.
Points by week, the four top-scoring teams, weeks 1 to 5
Week Fourth and Long Hail Mary Club Two-Minute Drill The Taxi Squad Week 1 118.4 points 102.6 points 125.9 points 91.3 points Week 2 96.2 points 124.8 points 83.7 points 108.5 points Week 3 131.7 points 88.3 points 99.2 points 112 points Week 4 104.9 points 115.1 points 121.6 points 79.4 points Week 5 122.3 points 109.6 points 94.5 points 127.8 points Values: Points scored
Marked at Week 5: The Monday comeback.
Source: Weekly scores from the league’s platform, half-point per reception.
The rules email everyone lost, a Tuesday report with the weekend’s injuries and waiver targets, and a scoring calculator that ends the argument. See the recipe
-
51A forecasting studyResearch and civic life
Chance Measure R passes: the market against my forecast, each Sunday
Chance Measure R passes: the market against my forecast, each Sunday Week ending Market My forecast 2 Aug 52% 45% 9 Aug 55% 45% 16 Aug 54% 45% 23 Aug 61% 52% 30 Aug 58% 52% 6 Sep 60% 52% 13 Sep 66% 57% 20 Sep 60% 55% 27 Sep 62% 55% The market’s implied probability, noted each Sunday evening. Port Calder, Measure R and every figure here are invented.
Chance Measure R passes: the market against my forecast, each Sunday
Week ending Market My forecast 2 Aug 52% 45% 9 Aug 55% 45% 16 Aug 54% 45% 23 Aug 61% 52% 30 Aug 58% 52% 6 Sep 60% 52% 13 Sep 66% 57% 20 Sep 60% 55% 27 Sep 62% 55% Values: Chance of Yes
The value axis runs from 30% to 80%.
Reference line at 50%: Even.
Marked at 23 Aug: Chamber endorses.
Marked at 13 Sep: Poll: 54% Yes.
Marked at 20 Sep: No campaign starts.
Source: The market’s implied probability, noted each Sunday evening. Port Calder, Measure R and every figure here are invented.
Pick one question a prediction market is forecasting, pin its rules, find the base rate, weigh every piece of evidence, and log each change to your forecast. See the recipe
-
37A small-group Bible studyFaith and study
The Tuesday group’s six weeks in Philippians
Week and passage Theme, and a question to open with 1. Chapter 1 Paul in prison, the gospel spreading. What good does Paul see coming from his chains (1:12–14)? 2. 2:1–4 Of one mind. What does Paul ask of the church, and on what grounds (2:1–2)? 3. 2:5–11 The mind of Christ. Where does the passage turn, and what word marks the turn (2:9)? 4. 2:12–18 Working out what God works in. How do verses 12 and 13 belong together? 5. 2:19–30 Timothy and Epaphroditus. What does Paul value in each man, and why tell the church (2:20–22, 2:25–30)? 6. Chapters 3 and 4 Pressing on, and content. What does Paul count as loss, and for what (3:7–8)? Weeks 2 to 5 are the four evenings in this study. Read the whole letter at home in week 1, and again before week 6.
The Tuesday group’s six weeks in Philippians
Week and passage Theme, and a question to open with 1. Chapter 1 Paul in prison, the gospel spreading. What good does Paul see coming from his chains (1:12–14)? 2. 2:1–4 Of one mind. What does Paul ask of the church, and on what grounds (2:1–2)? 3. 2:5–11 The mind of Christ. Where does the passage turn, and what word marks the turn (2:9)? 4. 2:12–18 Working out what God works in. How do verses 12 and 13 belong together? 5. 2:19–30 Timothy and Epaphroditus. What does Paul value in each man, and why tell the church (2:20–22, 2:25–30)? 6. Chapters 3 and 4 Pressing on, and content. What does Paul count as loss, and for what (3:7–8)? Weeks 2 to 5 are the four evenings in this study. Read the whole letter at home in week 1, and again before week 6.
Four evenings on Philippians 2 in one document: the passage, its context and good questions for the group, with your leader notes kept off their link. See the recipe
-
34A sprint bookWork and teams
The board, Thursday morning The board, Thursday morning
To do: the swap audit log, 3 points. In progress: blocking swaps that break the 11-hour rest rule, 8 points, and the swap screens’ copy, 2 points. Done: swap request, one-tap approval, the approval alert, swap limits, the accessibility pass and the rota-week bug.
- To do
- Swap audit log · 3, assigned to Wen, ticket SHF-216, low priority
- In progress
- Block swaps that break the 11-hour rest rule · 8, assigned to Priya, ticket SHF-215, very high priority
- Swap screens copy · 2, assigned to Marta, ticket SHF-219
- Done
- Swap request · 5, assigned to Tomás, ticket SHF-212
- One-tap approval · 5, assigned to Ade, ticket SHF-213
- Approval alert · 3, assigned to Tomás, ticket SHF-214
- Swap limits per month · 5, assigned to Ade, ticket SHF-217
- Accessibility pass · 3, assigned to Marta, ticket SHF-218
- Rota shows the wrong week · 3, assigned to Wen, ticket SHF-221, high priority
One document per sprint with the goal on top, the board and the burndown under it, the two weeks as a Gantt, and every retro decision kept. See the recipe
- To do
-
35An ad campaign fileWork and teams
Cost per trial by week, paid social and search
Cost per trial by week, paid social and search Week Paid social Search Wk 1 £24.6 £19.8 Wk 2 £21.3 £19.1 Wk 3 £18.9 £18.4 Wk 4 £16.1 £18.7 Wk 5 £15.2 £17.9 Wk 6 £14.7 £18.2 Platform exports, Monday to Sunday; a trial is a first bag ordered with a card on file.
Cost per trial by week, paid social and search
Week Paid social Search Wk 1 £24.6 £19.8 Wk 2 £21.3 £19.1 Wk 3 £18.9 £18.4 Wk 4 £16.1 £18.7 Wk 5 £15.2 £17.9 Wk 6 £14.7 £18.2 Values: Cost per trial
Reference line at £18: Target, £18.
Marked at Wk 3: Founder video cut.
Source: Platform exports, Monday to Sunday; a trial is a first bag ordered with a card on file.
Cost per trial falling week by week, the winning ad marked in the variants table, and every cut creative filed with the numbers that killed it. See the recipe
-
36PCI DSS readinessWork and teams
Controls in place by requirement, per cent, May to September
0–20%20–40%40–60%60–80%80%+no data
Controls in place by requirement, per cent, May to September Requirement May Jun Jul Aug Sep 1 Network security controls 40 55 70 85 90 2 Secure configurations 30 45 60 70 80 3 Protect stored account data 60 80 95 100 100 4 Cryptography in transit 70 80 90 100 100 5 Malicious software 50 60 75 80 85 6 Secure systems and software 20 30 45 55 65 7 Access by need to know 45 55 65 75 85 8 Identify and authenticate 25 40 50 60 70 9 Physical access 80 85 90 95 100 10 Log and monitor 20 30 40 55 60 11 Test security regularly 15 25 35 45 55 12 Policies and programmes 35 45 60 70 80 Share of each requirement’s applicable controls marked In place in its document, at month end.
Controls in place by requirement, per cent, May to September
Requirement May Jun Jul Aug Sep 1 Network security controls 40 55 70 85 90 2 Secure configurations 30 45 60 70 80 3 Protect stored account data 60 80 95 100 100 4 Cryptography in transit 70 80 90 100 100 5 Malicious software 50 60 75 80 85 6 Secure systems and software 20 30 45 55 65 7 Access by need to know 45 55 65 75 85 8 Identify and authenticate 25 40 50 60 70 9 Physical access 80 85 90 95 100 10 Log and monitor 20 30 40 55 60 11 Test security regularly 15 25 35 45 55 12 Policies and programmes 35 45 60 70 80 Columns: Month
Units: %
Shaded in five steps: 0–20%, 20–40%, 40–60%, 60–80% and 80%+.
Highest: 3 Protect stored account data, Aug, 100%. Lowest: 11 Test security regularly, May, 15%.
Source: Share of each requirement’s applicable controls marked In place in its document, at month end.
All twelve PCI DSS v4.0.1 requirements shaded month by month, evidence moving across a board, and every scope decision on the record before the assessor asks. See the recipe
-
60A Hobonichi planning libraryHobbies and play
Habits kept each day, of five, June to September
habits0–11–22–33–44+no data
- 22 JuneThe walk moves to before work
- 10 AugustFlu, three days
- 24 AugustThe phone charges outside the bedroom
Habits kept each day, of five, June to September Week of Mo Tu We Th Fr Sa Su 1 Jun 2 3 2 2 2 2 3 8 Jun 3 4 3 3 3 0 3 15 Jun 3 3 1 1 2 1 2 22 Jun 4 4 3 4 4 2 4 29 Jun 4 5 3 3 3 3 3 6 Jul 4 3 3 3 5 2 3 13 Jul 4 2 4 5 2 3 3 20 Jul 3 4 4 2 4 3 4 27 Jul 5 4 4 2 4 2 2 3 Aug 2 3 3 5 2 1 3 10 Aug 0 0 0 5 4 1 1 17 Aug 4 3 3 4 5 3 3 24 Aug 5 5 5 5 5 2 5 31 Aug 5 5 2 4 5 2 3 7 Sep 5 3 5 5 4 4 4 14 Sep 4 5 4 4 5 3 3 21 Sep 5 5 4 3 4 3 Counted from the habit strip on each daily page, 1 June to 26 September.
Habits kept each day, of five, June to September
Week of Mo Tu We Th Fr Sa Su 1 Jun 2 3 2 2 2 2 3 8 Jun 3 4 3 3 3 0 3 15 Jun 3 3 1 1 2 1 2 22 Jun 4 4 3 4 4 2 4 29 Jun 4 5 3 3 3 3 3 6 Jul 4 3 3 3 5 2 3 13 Jul 4 2 4 5 2 3 3 20 Jul 3 4 4 2 4 3 4 27 Jul 5 4 4 2 4 2 2 3 Aug 2 3 3 5 2 1 3 10 Aug 0 0 0 5 4 1 1 17 Aug 4 3 3 4 5 3 3 24 Aug 5 5 5 5 5 2 5 31 Aug 5 5 2 4 5 2 3 7 Sep 5 3 5 5 4 4 4 14 Sep 4 5 4 4 5 3 3 21 Sep 5 5 4 3 4 3 385 habits over 118 days; the most on Tuesday 30 June, 5 habits.
Shades: 0–1, 1–2, 2–3, 3–4 and 4+ habits
Marked on Monday 22 June: The walk moves to before work.
Marked on Monday 10 August: Flu, three days.
Marked on Monday 24 August: The phone charges outside the bedroom.
Source: Counted from the habit strip on each daily page, 1 June to 26 September.
A summer of habits shaded day by day, the year’s goals on one chart, every pen and ink you own with how it behaves on thin paper, and a daily page to plan. See the recipe
-
50A book club guideResearch and civic life
Our two Wharton novels: what each has alone, and what they share Our two Wharton novels: what each has alone, and what they share
Two overlapping circles. Ethan Frome, 1911, alone: Starkfield, Massachusetts, in winter; a poor hill farm; told by an outsider, an engineer working nearby; the sledding crash. The Age of Innocence, 1920, alone: New York society in the 1870s; the opera and the ballrooms; seen through Newland Archer; the 1921 Pulitzer Prize. Both: a man who loves his wife’s cousin, a town that watches, he never leaves, and an ending set years later.
- Ethan Frome (1911)
- Starkfield, in winter
- A poor hill farm
- Told by an outsider
- The sledding crash
- The Age of Innocence (1920)
- New York, the 1870s
- Opera and ballrooms
- Seen through Archer
- Pulitzer Prize, 1921
- Both, where Ethan Frome (1911) and The Age of Innocence (1920) overlap
- Loves his wife’s cousin
- A town that watches
- He never leaves
- Ends years later
Your assistant researches the author, the era and the reviews and drafts the questions; you pick the best, write the welcome and send one link per meeting. See the recipe
- Ethan Frome (1911)
Every recipe
Books and writingnine recipes
Screenplays, comic books, novels, worlds and memoirs, with the bibles that keep them straight.
- 1 A technical bookFor a developer writing a book about a web frameworkYour coding assistant files every concept in your programming book with its sources, then checks each chapter against the framework version you have installed.
- 2 A novel series bibleFor a novelist on book four of a mystery seriesYour assistant reads your published books, files every fact with the chapter that set it, and catches the line in book four that contradicts book two.
- 3 A fantasy world encyclopediaFor a fantasy writer building one world across several booksHand your lore notebooks to your assistant and get an encyclopedia of your invented world: an entry per people, place, war and word, linked and indexed A to Z.
- 4 A memoir from vignettesFor a retired teacher writing a memoir for the grandchildrenWrite a memoir the way memory works, a vignette at a time and in any order. Your assistant files each one word for word; you drag them into place.
- 5 A television series bibleFor a screenwriter developing a limited series to pitchYour assistant lays out a limited-series bible from your pilot and outline; you write every section, then send producers one link with a password.
- 6 A commonplace bookFor a reader who wants to remember what they readThe old commonplace-book habit with an index: your assistant files the passages you marked, finds echoes across books and quizzes you on your own notes.
- 7 A podcast seasonFor a podcaster planning a ten-episode narrative seasonYour assistant researches the season and every guest from public sources and files a brief per episode; you check each fact and write the questions yourself.
- 8 A feature screenplay’s bibleFor a screenwriter between the outline and the third draftKeep the script in your screenwriting app and the thinking in know.sh: a beat sheet charted against the page, scene cards per sequence and every round of notes.
- 9 A graphic novel’s bibleFor a writer-artist making a graphic novel aloneYour assistant turns your script and notes into model-sheet cards, a page-by-page breakdown and a production board, and flags the key ring on the wrong hip.
Study and examsseven recipes
Study guides, outlines and oral-exam prep you quiz yourself on, term after term.
- 10 A semester from the syllabusFor a first-year student taking Introduction to PsychologyGive your assistant the PSY 101 syllabus on day one and it lays out the semester: every date, key terms from the open textbook, and a quiz before each exam.
- 11 A law school outlineFor a first-year law student in ContractsBrief each case as a section, grow each topic’s overview into your outline, and draw the order of attack on a formation question as a flowchart.
- 12 Board exam notes for medical schoolFor a second-year medical student preparing for USMLE Step 1Your lecture notes become a document per organ system, each topic in your own words, the look-alike murmurs side by side, and quizzes built from them.
- 13 Kubernetes certification notesFor a platform engineer preparing for the CKA examYour lab notes become a section per task, the node upgrade set out step by step, and typed-answer drills where the answer is the kubectl command itself.
- 14 Checkride oral exam prepFor a student pilot preparing for the private pilot oralPrepare for the private pilot oral element by element: your answer in your words, the regulation behind it, and flashcards at the foot of every page.
- 15 A language learner’s notebookFor an adult learning European Portuguese for a move to LisbonGrammar in your own words, phrases for the café and the Loja do Cidadão, and a log of the mistakes you repeat, drilled until every tilde lands.
- 16 A Regents study shelfFor a high-school junior sitting the US History RegentsEvery topic a document with its timeline, the Supreme Court cases as flashcards, quizzes that come back when due, and a plan that runs to June.
Teaching and coursesseven recipes
Syllabi, novel units, study guides and open textbooks a class reads from one link.
- 17 A course syllabus and course bookFor a sociology professor teaching Introduction to SociologyThe syllabus and the weekly course book in one document: the term on a Gantt chart, every objective checked against an assessment, one link for students.
- 18 A unit study guide for a classFor a US history teacher preparing a class for a unit testYour unit plan becomes one study guide link for the class, with Reconstruction on a timeline and a quiz that tests the guide before your students do.
- 19 An open textbookFor an instructor writing an open statistics textbookDraft an open statistics textbook a chapter at a time: worked examples with their R code and histograms, sources on every section, each chapter on a link.
- 20 A homeschool yearFor a parent homeschooling two children, aged 8 and 11Plan each unit study for two ages at once, record what you taught the same day, and see the whole year of school days on one calendar.
- 21 A music theory workbookFor a piano teacher with twenty students across five levelsYour teaching notes become theory pages for your piano students: thirds counted in half steps, a glossary of markings, and a metronome that clicks on the page.
- 22 A docent tour bookFor a coordinator training docents for a new exhibitionA document per gallery and a page per object, checked by the curator and read by docents on their phones between tours, with a training deck to match.
- 23 A novel unitFor an English teacher planning four weeks on a novelA four-week Frankenstein unit in one document, from the 1818 text: a reading schedule, weekly guides, a character map and flashcards on the lines that matter.
Labs and workshopsseven recipes
Chemistry, physics, electronics, code and a cluster: lab manuals with working models to press, drag and fly.
- 24 A chemistry lab manualFor a chemistry teacher writing a year of lab practicalsEvery practical in the same six parts, every hazard checked against your risk assessment, and a titration model the class watches jump at equivalence.
- 25 A physics lab manualFor a physics teacher who wants students to fly the Moon landingStudents predict the fall, fly the lander, record height, speed and fuel, then explain why the crash came later than the arithmetic said.
- 26 An electronics lab manualFor a teacher running an intro electronics classFive labs, a document each: parts, the circuit pin by pin, a measurement table. Beside the LED lab, a widget that reads resistor bands and sizes the resistor.
- 27 A mechanical engineering design notebookFor a mechanical engineering student on a capstone designPedal to pad in six worked steps, a brake caliper you can squeeze, and every design choice kept with the figures that made it.
- 28 An intro programming courseFor a teacher running a first programming course in PythonA first Python course where every worked example has been run, every classic mistake is a Bug section, and sixteen bars sort on the classroom screen.
- 29 A lifting technique manualFor a school coach teaching the big lifts in the weight roomThe big lifts in your own cues: a faults table per lift, spotting taught before anyone spots, and a bench press model students study frame by frame.
- 30 A service-mesh workshopFor a platform engineer running a half-day Istio workshopA half-day Istio workshop as a handout: the afternoon as a table, every command as a step, and a cluster in the page for the laptop that will not start one.
Work and teamssix recipes
Runbooks, SOPs, sprints, ad campaigns, compliance and the week’s AI benchmarks: the documents a team leans on.
- 31 An AI model trackerFor a team lead keeping up with new AI models for the teamEvery model that shipped this month in one table, its score plotted against its price, and the team’s call on which one to try next.
- 32 Incident runbooks and a postmortem libraryFor the SRE lead who keeps the on-call runbooksRunbooks named exactly as your alerts page, first checks drawn as a flowchart, and every postmortem linked to the runbook that should have caught it.
- 33 A standard operating procedureFor a laboratory quality lead who owns the bench SOPsDocument control on a card, the procedure as numbered steps, the accept-or-reject call as a flowchart, and every revision kept with its reason.
- 34 A sprint bookFor a team lead running two-week sprintsOne document per sprint with the goal on top, the board and the burndown under it, the two weeks as a Gantt, and every retro decision kept.
- 35 An ad campaign fileFor a marketer running a paid campaign for a launchCost per trial falling week by week, the winning ad marked in the variants table, and every cut creative filed with the numbers that killed it.
- 36 PCI DSS readinessFor a compliance lead getting a small company ready for PCI DSSAll twelve PCI DSS v4.0.1 requirements shaded month by month, evidence moving across a board, and every scope decision on the record before the assessor asks.
Faith and studytwo recipes
A chapter studied with a group, and a classic read slowly, with notes that last.
- 37 A small-group Bible studyFor a small-group leader taking a group through Philippians 2Four evenings on Philippians 2 in one document: the passage, its context and good questions for the group, with your leader notes kept off their link.
- 38 A reading notebook on Augustine’s ConfessionsFor a first-time reader of Augustine’s ConfessionsRead the Confessions with a notebook beside you: your assistant maps the people, places and dates, and you keep the passages that stop you.
Kitchen and tablefour recipes
Family recipes, a cookbook in progress, a year of bakes and a kitchen’s own manual.
- 39 A family cookbookFor a family saving three generations of recipe cardsPhotograph the recipe box, let your assistant file every card as a recipe, add the stories only the family knows, and send everyone a link.
- 40 A cookbook manuscriptFor a food writer testing sixty recipes for a first cookbookDevelop a cookbook the way test kitchens do: a document per recipe, a section per testing round, and your assistant comparing rounds.
- 41 A sourdough bake logFor a home baker who wants to know why Saturday’s loaf workedLog every loaf with the same table of numbers, then let your assistant compare the good bakes with the dense ones and find the variable that moved.
- 42 A restaurant kitchen manualFor a chef-owner training new cooks without a sous chefReplace the station binder with a manual cooks read on their phones: every recipe by weight, with its plating and allergens, changed once for everyone.
Home and familythree recipes
A family history written from the records, the guide to a trip you take together, and a story to hear on the way.
- 43 A family history bookFor a family historian writing up four generationsWrite up four generations with the reasoning left in: every record an Evidence section, every brick wall a Question, and a link that leaves the living out.
- 44 A group trip guideFor someone organising a ten-person family trip to PortugalTen people, eleven days, one link: the itinerary on a timeline, every day and booking on its own page, and the family reading it on their phones.
- 45 A story for a road tripFor a parent driving two children from Denver to MoabA story written for your route, a chapter a leg, with the pass, the canyon and the arches in it, read aloud in the car by a voice made on your phone.
Research and civic lifesix recipes
Literature reviews, a thesis, a story file, oral histories and a book club’s season.
- 46 A literature reviewFor a PhD student with a literature review chapter dueTurn ninety half-read papers into a review: a section per paper checked against the source, evidence tables across each theme, and a synthesis in your words.
- 47 A thesis research baseFor a history PhD candidate starting to write upKeep a history thesis as a shelf: each chapter’s argument at the top of its page, and the evidence, objections and supervisor’s decisions one click below.
- 48 An investigative story fileFor a reporter looking into how a city contract was awardedKeep an investigation in one private file, where every fact carries its record and every claim stays open until a primary source confirms it.
- 49 An oral history collectionFor a volunteer cataloguing a town’s oral history interviewsThirty-one interviews nobody could search, catalogued: time-coded summaries, the narrators’ own words, and an index that finds every flood and foreman.
- 50 A book club guideFor the organiser of a library book club, ten books a yearYour assistant researches the author, the era and the reviews and drafts the questions; you pick the best, write the welcome and send one link per meeting.
- 51 A forecasting studyFor someone practising forecasting on one hard questionPick one question a prediction market is forecasting, pin its rules, find the base rate, weigh every piece of evidence, and log each change to your forecast.
Hobbies and playnine recipes
Campaign settings, fantasy leagues, chess, puzzle hunts, gardens, brews and builds.
- 52 A tabletop campaign settingFor a game master running a homebrew fantasy campaignThe harbour town your players know on one shelf, who really runs it on another. One search at the table finds the magistrate’s public face and her real motive.
- 53 A fantasy league almanacFor the commissioner of a twelve-team fantasy football leagueThe rules email everyone lost, a Tuesday report with the weekend’s injuries and waiver targets, and a scoring calculator that ends the argument.
- 54 A garden journalFor a vegetable gardener with six raised beds in USDA zone 7bWhich tomato split in the August rain, when the beans went in, and the whole harvest shaded day by day, from the first peas to the last Cherokee Purple.
- 55 A homebrew recipe book and batch logFor a homebrewer forty batches into all-grain brewingWhy batch 44 beat batch 42, in numbers: every brew day in a table, the gravity charted each morning, and the house recipes on one link for the club.
- 56 A car restoration build bookFor a hobbyist restoring a 1972 half-ton pickup truckThree winters of receipts, sorted into a build book: every part number, every specification from the manual, and a graph showing the brakes block the road test.
- 57 A youth coach’s playbookFor a volunteer coach of an under-10 recreational soccer teamSixteen practices planned in an evening, a heatmap that shows who keeps getting the short end, and one page parents can read on their phones.
- 58 A chess opening repertoireFor a club player building an opening repertoireWas it 8.Nbxd2 or 8.Qxd2? A repertoire with the plans, the traps and your own losses, flashcards for the move orders, and a board that plays each main line.
- 59 A puzzle hunt or escape-room nightFor someone writing an escape-room night for friendsTwo books that must never meet: the players’ pack on one link, and your answers, staged hints and playtest notes in documents that have none.
- 60 A Hobonichi planning libraryFor a Hobonichi Techo user who plans the year on paperA summer of habits shaded day by day, the year’s goals on one chart, every pen and ink you own with how it behaves on thin paper, and a daily page to plan.