feat: implement scenario caching and hot-reloading

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-15 15:28:53 +05:30
parent 8489544981
commit ab2403e914
4 changed files with 140 additions and 3 deletions
+20 -1
View File
@@ -150,6 +150,22 @@ Each scenario is a single JSON file in `scenarios/data` directory; each bundle i
- `correct_option` must match one of the `options[].id` values
- Always include an `explanation`
### In-Memory Cache & Hot Reloading
To ensure high performance and zero disk-I/O bottlenecking, scenarios and bundles are cached in memory on backend startup. When developing or updating scenarios, you can hot-reload the definitions without rebuilding the image or restarting the container:
1. **Mount Scenarios Directory:** Run the container with the local `scenarios/` directory mounted to `/app/scenarios`:
```bash
docker run --rm -itd --privileged -p 7554:80 --name kubekosh -v <path_to_scenarios_directory>:/app/scenarios zeborg/kubekosh:latest
```
2. **Reload Cache:** Click the **Reload Scenario Cache** (↻) button in the top right corner of the header in the web user interface, or send an API request:
```bash
curl -X POST http://localhost:7554/api/cache/reload
```
> **NOTE:**
> The content in `<path_to_scenarios_directory>` should be the path to the local `scenarios/` directory of the cloned repository with your updates, i.e., it should contain the updated `scenarios/data` and `scenarios/bundles` directories.
### Workflow
```bash
@@ -168,7 +184,10 @@ vim scenarios/data/my-new-scenario.json # edit the new scenario as per [SCHEMA.m
vim scenarios/bundles/k8s-basics.json # edit the bundle to include the new scenario ID
# 5. Build and test locally
docker build -t kubekosh . && docker run --rm -itd --privileged -p 7554:80 kubekosh
# Run the built container directly:
docker build -t kubekosh . && docker run --rm -itd --privileged -p 7554:80 --name kubekosh kubekosh
# Or mount the scenarios folder for hot-reloading:
docker run --rm -itd --privileged -p 7554:80 -v $PWD/scenarios:/app/scenarios --name kubekosh zeborg/kubekosh:dev
# 6. Commit and push to your fork (example for adding `my-new-scenario` to `k8s-basics` bundle)
git add scenarios/data/my-new-scenario.json scenarios/bundles/k8s-basics.json
+29 -2
View File
@@ -90,12 +90,28 @@ function loadJsonDir(dir) {
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
}
let scenariosCache = [];
let bundlesCache = [];
function reloadCache() {
try {
scenariosCache = loadJsonDir(SCENARIOS_DIR);
bundlesCache = loadJsonDir(BUNDLES_DIR);
console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`);
} catch (e) {
console.error('Failed to reload cache:', e.message);
}
}
// Initial cache populate
reloadCache();
function loadScenarios() {
return loadJsonDir(SCENARIOS_DIR);
return scenariosCache;
}
function loadBundles() {
return loadJsonDir(BUNDLES_DIR);
return bundlesCache;
}
async function runCommand(cmd, timeoutMs = 15000) {
@@ -466,6 +482,17 @@ app.post('/api/progress/reset/:id', (req, res) => {
res.json({ ok: true });
});
// POST /api/cache/reload — reload scenarios and bundles cache
app.post('/api/cache/reload', (req, res) => {
reloadCache();
res.json({
ok: true,
message: 'Cache reloaded successfully',
scenarios_count: loadScenarios().length,
bundles_count: loadBundles().length
});
});
// GET /api/health
app.get('/api/health', async (req, res) => {
const kube = await runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1');
+39
View File
@@ -13,6 +13,31 @@ export default function Header({ clusterReady }) {
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
const handleReloadCache = async () => {
const sendReloadRequest = async () => {
return fetch('/api/cache/reload', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
};
try {
let response = await sendReloadRequest();
if (response.ok) {
const data = await response.json();
alert(`Success: ${data.message}\nScenarios: ${data.scenarios_count}\nBundles: ${data.bundles_count}`);
window.location.reload();
} else {
const data = await response.json().catch(() => ({}));
alert(`Error reloading cache: ${data.error || 'Unknown error'}`);
}
} catch (err) {
alert(`Network error reloading cache: ${err.message}`);
}
};
return (
<header className={styles.header}>
<div className={styles.brand}>
@@ -54,6 +79,20 @@ export default function Header({ clusterReady }) {
<span className={styles.dot} />
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
</div>
{/* Reload cache */}
<div className={styles.reloadBtnContainer}>
<button
className={styles.reloadBtn}
onClick={handleReloadCache}
aria-label="Reload scenario cache"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</div>
</header>
)
+52
View File
@@ -107,6 +107,58 @@
transform: rotate(12deg);
}
.reloadBtnContainer {
position: relative;
display: flex;
align-items: center;
}
.reloadBtnContainer::after {
content: "Reload Scenario Cache";
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--surface3);
color: var(--text);
font-family: var(--mono);
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border2);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.reloadBtnContainer:hover::after {
opacity: 1;
}
.reloadBtn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.2s, color 0.15s;
color: var(--text-2);
line-height: 1;
}
.reloadBtn:hover {
background: var(--surface2);
border-color: var(--border2);
color: var(--text);
transform: rotate(12deg);
}
.clusterBadge {
display: flex;
align-items: center;