feat: implement scenario caching and hot-reloading
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
@@ -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
|
- `correct_option` must match one of the `options[].id` values
|
||||||
- Always include an `explanation`
|
- 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
|
### Workflow
|
||||||
|
|
||||||
```bash
|
```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
|
vim scenarios/bundles/k8s-basics.json # edit the bundle to include the new scenario ID
|
||||||
|
|
||||||
# 5. Build and test locally
|
# 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)
|
# 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
|
git add scenarios/data/my-new-scenario.json scenarios/bundles/k8s-basics.json
|
||||||
|
|||||||
+29
-2
@@ -90,12 +90,28 @@ function loadJsonDir(dir) {
|
|||||||
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
.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() {
|
function loadScenarios() {
|
||||||
return loadJsonDir(SCENARIOS_DIR);
|
return scenariosCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadBundles() {
|
function loadBundles() {
|
||||||
return loadJsonDir(BUNDLES_DIR);
|
return bundlesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCommand(cmd, timeoutMs = 15000) {
|
async function runCommand(cmd, timeoutMs = 15000) {
|
||||||
@@ -466,6 +482,17 @@ app.post('/api/progress/reset/:id', (req, res) => {
|
|||||||
res.json({ ok: true });
|
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
|
// GET /api/health
|
||||||
app.get('/api/health', async (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
const kube = await runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1');
|
const kube = await runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1');
|
||||||
|
|||||||
@@ -13,6 +13,31 @@ export default function Header({ clusterReady }) {
|
|||||||
|
|
||||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
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 (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
@@ -54,6 +79,20 @@ export default function Header({ clusterReady }) {
|
|||||||
<span className={styles.dot} />
|
<span className={styles.dot} />
|
||||||
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
|
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,6 +107,58 @@
|
|||||||
transform: rotate(12deg);
|
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 {
|
.clusterBadge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user