Skip to content

Commit 9ee566f

Browse files
committed
feat(ui): add config toggles and session timeout support
1 parent 0c05a72 commit 9ee566f

6 files changed

Lines changed: 159 additions & 15 deletions

File tree

host-agent/api/handlers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,32 @@
2929

3030
class APIHandlers:
3131

32-
def __init__(self, agent_defaults: Dict[str, Any]):
32+
def __init__(self, agent_defaults: Dict[str, Any], ui_config: Optional[Dict[str, Any]] = None):
3333
self.agent_defaults = agent_defaults
34+
self.ui_config = ui_config or {"enabled": True, "session_timeout_seconds": 1800}
3435
self.vm_manager = VMManager()
3536
self.vm_lifecycle = VMLifecycle(agent_defaults)
3637
self.config_manager = ConfigManager(agent_defaults)
3738
self.state_manager = StateManager(agent_defaults)
3839

40+
def v1_ui_config(self) -> Dict[str, Any]:
41+
cfg = self.ui_config or {}
42+
enabled = bool(cfg.get("enabled", True))
43+
timeout = cfg.get("session_timeout_seconds", 1800)
44+
try:
45+
timeout = int(timeout)
46+
except (TypeError, ValueError):
47+
timeout = 1800
48+
if timeout < 0:
49+
timeout = 0
50+
return {
51+
"status": "success",
52+
"config": {
53+
"enabled": enabled,
54+
"session_timeout_seconds": timeout,
55+
},
56+
}
57+
3958
def api_create(self, req: SpecRequest) -> Dict[str, Any]:
4059
"""Prepare storage+network, write config and start the VM."""
4160
# Dump the raw incoming spec as early as possible (before any parsing/try),

host-agent/api/routes.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ def register_routes(
1313
app: FastAPI,
1414
agent_defaults: Dict[str, Any],
1515
auth_dependency: Optional[Any] = None,
16+
ui_config: Optional[Dict[str, Any]] = None,
1617
) -> None:
1718
"""Register all API routes with the FastAPI application."""
18-
handlers = APIHandlers(agent_defaults)
19+
handlers = APIHandlers(agent_defaults, ui_config=ui_config)
1920
deps = [Depends(auth_dependency)] if auth_dependency else []
2021
protected = {"dependencies": deps} if deps else {}
2122

@@ -40,6 +41,10 @@ def v1_health_alias():
4041
def v1_config_effective():
4142
return handlers.v1_config_effective()
4243

44+
@app.get("/v1/ui/config")
45+
def v1_ui_config():
46+
return handlers.v1_ui_config()
47+
4348
# VM management endpoints
4449
@app.post("/v1/vms", status_code=201, **protected)
4550
def create_vm(req: SpecRequest):

host-agent/config/manager.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ def load_agent_config(self) -> Dict[str, Any]:
8080
for key in ["host", "storage", "net"]:
8181
if key not in cfg["defaults"] or not isinstance(cfg["defaults"][key], dict):
8282
cfg["defaults"][key] = {}
83+
# Normalize UI configuration
84+
ui_cfg = cfg.get("ui") if isinstance(cfg.get("ui"), dict) else {}
85+
enabled = ui_cfg.get("enabled")
86+
if enabled is None:
87+
enabled = True
88+
else:
89+
enabled = bool(enabled)
90+
timeout_seconds = 1800
91+
if "session_timeout_seconds" in ui_cfg:
92+
try:
93+
timeout_seconds = int(ui_cfg.get("session_timeout_seconds", 1800))
94+
except (TypeError, ValueError):
95+
timeout_seconds = 1800
96+
elif "session_timeout_minutes" in ui_cfg:
97+
try:
98+
timeout_seconds = int(ui_cfg.get("session_timeout_minutes", 30)) * 60
99+
except (TypeError, ValueError):
100+
timeout_seconds = 1800
101+
if timeout_seconds < 0:
102+
timeout_seconds = 0
103+
cfg["ui"] = {
104+
"enabled": enabled,
105+
"session_timeout_seconds": timeout_seconds,
106+
}
83107
return cfg
84108

85109
def write_config(self, spec: Spec, paths: Paths) -> None:

host-agent/debian/firecracker-agent.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,9 @@
3232
"auth": {
3333
"enabled": true,
3434
"service": "firecracker-agent"
35+
},
36+
"ui": {
37+
"enabled": true,
38+
"session_timeout_seconds": 1800
3539
}
3640
}

host-agent/firecracker-agent.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from fastapi import FastAPI, HTTPException, Request
3030
from fastapi.staticfiles import StaticFiles
3131
from fastapi.exceptions import RequestValidationError
32-
from starlette.responses import FileResponse, JSONResponse
32+
from starlette.responses import FileResponse, JSONResponse, RedirectResponse
3333

3434
from api import register_routes
3535
from cli import CLICommands
@@ -57,6 +57,7 @@
5757
TLS_OPTIONS: Dict[str, Any] = {}
5858
IS_API_MODE = True
5959
UI_STATIC_MOUNTED = False
60+
UI_CONFIG: Dict[str, Any] = {"enabled": True, "session_timeout_seconds": 1800}
6061
# Initialize FastAPI app
6162
app = FastAPI(title="Firecracker Agent", version="1.0.0")
6263

@@ -128,6 +129,25 @@ def _build_tls_options(security_cfg: Any) -> Dict[str, Any]:
128129
return options
129130

130131

132+
def _configure_ui_settings(ui_cfg: Any) -> None:
133+
"""Normalize UI configuration defaults."""
134+
global UI_CONFIG
135+
enabled = True
136+
timeout_seconds = 1800
137+
if isinstance(ui_cfg, dict):
138+
enabled = bool(ui_cfg.get("enabled", True))
139+
try:
140+
timeout_seconds = int(ui_cfg.get("session_timeout_seconds", timeout_seconds))
141+
except (TypeError, ValueError):
142+
timeout_seconds = 1800
143+
if timeout_seconds < 0:
144+
timeout_seconds = 0
145+
UI_CONFIG = {
146+
"enabled": enabled,
147+
"session_timeout_seconds": timeout_seconds,
148+
}
149+
150+
131151
def _mount_ui_static_if_available() -> None:
132152
"""Mount the compiled web UI under /ui when assets are present."""
133153
global UI_STATIC_MOUNTED
@@ -198,11 +218,17 @@ async def startup_event():
198218

199219
# Apply logging configuration
200220
_apply_logging_from_cfg(AGENT_CFG)
221+
# Configure optional features
222+
_configure_ui_settings(AGENT_CFG.get("ui"))
223+
201224
# Register API routes with loaded configuration
202225
if AUTH_DEPENDENCY is None:
203226
AUTH_DEPENDENCY = _configure_auth_dependency(AGENT_CFG.get("auth", {}))
204-
register_routes(app, AGENT_DEFAULTS, AUTH_DEPENDENCY)
205-
_mount_ui_static_if_available()
227+
register_routes(app, AGENT_DEFAULTS, AUTH_DEPENDENCY, UI_CONFIG)
228+
if UI_CONFIG.get("enabled"):
229+
_mount_ui_static_if_available()
230+
else:
231+
logger.info("UI disabled via configuration; static assets will not be served.")
206232
# Initialize VM lifecycle for recovery
207233
vm_lifecycle = VMLifecycle(AGENT_DEFAULTS)
208234
vm_lifecycle.startup_vm_recovery()
@@ -230,6 +256,8 @@ async def shutdown_event():
230256
# Root endpoint
231257
@app.get("/", include_in_schema=False)
232258
def root():
259+
if UI_CONFIG.get("enabled"):
260+
return RedirectResponse(url="/ui", status_code=302)
233261
return root_ok()
234262

235263

host-agent/ui/src/App.vue

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
</template>
6767

6868
<script setup>
69-
import { computed, onMounted, ref, watch } from "vue";
69+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
7070
import LoginPage from "./components/LoginPage.vue";
7171
import VmList from "./components/VmList.vue";
7272
import { api, clearAuth, loadAuthFromStorage, setBasicAuth } from "./services/apiClient";
@@ -77,6 +77,11 @@ const loginLoading = ref(false);
7777
const isAuthenticated = ref(false);
7878
const vmListRef = ref(null);
7979
const hostSummary = ref(null);
80+
const uiConfig = ref({
81+
enabled: true,
82+
session_timeout_seconds: 0,
83+
});
84+
let sessionExpiryHandle = null;
8085
8186
const updateTimestamp = (value) => {
8287
lastUpdated.value = value;
@@ -94,6 +99,7 @@ const handleAuthRequired = (message) => {
9499
clearAuth();
95100
setAuthState(false);
96101
loginLoading.value = false;
102+
clearSessionExpiry();
97103
hostSummary.value = null;
98104
if (typeof message === "string" && message.trim()) {
99105
loginError.value = message;
@@ -119,6 +125,7 @@ const handleLoginSubmit = async ({ username, password }) => {
119125
setBasicAuth(username, password);
120126
await api.get("/v1/vms");
121127
setAuthState(true);
128+
scheduleSessionExpiry();
122129
await fetchHostSummary();
123130
} catch (error) {
124131
clearAuth();
@@ -134,6 +141,7 @@ const handleLoginSubmit = async ({ username, password }) => {
134141
};
135142
136143
const handleLogout = () => {
144+
clearSessionExpiry();
137145
clearAuth();
138146
setAuthState(false);
139147
loginError.value = "";
@@ -146,15 +154,6 @@ const lastUpdatedLabel = computed(() => {
146154
return new Date(lastUpdated.value).toLocaleString();
147155
});
148156
149-
const primaryAddress = computed(() => {
150-
const addresses = hostSummary.value?.ip_addresses;
151-
if (!Array.isArray(addresses) || addresses.length === 0) {
152-
return null;
153-
}
154-
const primary = addresses.find((entry) => entry.family === "IPv4" && !entry.is_loopback);
155-
return primary || addresses[0];
156-
});
157-
158157
const cpuLabel = computed(() => {
159158
const cpu = hostSummary.value?.cpu;
160159
if (!cpu) return "-";
@@ -193,6 +192,11 @@ const diskLabel = computed(() => {
193192
return `${gib.toFixed(1)} GiB`;
194193
});
195194
195+
const sessionTimeoutSeconds = computed(() => {
196+
const raw = Number(uiConfig.value?.session_timeout_seconds ?? 0);
197+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
198+
});
199+
196200
const isBridgeInterface = (name) => {
197201
if (!name) {
198202
return false;
@@ -255,7 +259,48 @@ const hostInterfaces = computed(() => {
255259
.sort((a, b) => a.name.localeCompare(b.name));
256260
});
257261
262+
const loadUiConfig = async () => {
263+
try {
264+
const { data } = await api.get("/v1/ui/config");
265+
const payload = (data && data.config) || data || {};
266+
const timeoutSeconds = Number(payload.session_timeout_seconds ?? 0);
267+
uiConfig.value = {
268+
enabled: payload.enabled !== false,
269+
session_timeout_seconds: Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 0,
270+
};
271+
} catch (error) {
272+
console.warn("Failed to load UI configuration", error);
273+
}
274+
};
275+
276+
const clearSessionExpiry = () => {
277+
if (sessionExpiryHandle !== null) {
278+
window.clearTimeout(sessionExpiryHandle);
279+
sessionExpiryHandle = null;
280+
}
281+
};
282+
283+
const handleSessionExpired = () => {
284+
clearSessionExpiry();
285+
loginError.value = "Session expired. Please sign in again.";
286+
clearAuth();
287+
setAuthState(false);
288+
};
289+
290+
const scheduleSessionExpiry = () => {
291+
clearSessionExpiry();
292+
const seconds = sessionTimeoutSeconds.value;
293+
if (!seconds) {
294+
return;
295+
}
296+
sessionExpiryHandle = window.setTimeout(() => {
297+
handleSessionExpired();
298+
}, seconds * 1000);
299+
};
258300
onMounted(() => {
301+
loadUiConfig().catch(() => {
302+
/* optional */
303+
});
259304
const restored = loadAuthFromStorage();
260305
setAuthState(restored);
261306
if (!restored) {
@@ -271,16 +316,35 @@ watch(
271316
() => isAuthenticated.value,
272317
(value) => {
273318
if (value) {
319+
scheduleSessionExpiry();
274320
if (!hostSummary.value) {
275321
fetchHostSummary().catch(() => {
276322
/* non-fatal */
277323
});
278324
}
279325
} else {
326+
clearSessionExpiry();
280327
hostSummary.value = null;
281328
}
282329
}
283330
);
331+
332+
watch(
333+
() => sessionTimeoutSeconds.value,
334+
(seconds) => {
335+
if (!seconds) {
336+
clearSessionExpiry();
337+
return;
338+
}
339+
if (isAuthenticated.value) {
340+
scheduleSessionExpiry();
341+
}
342+
}
343+
);
344+
345+
onBeforeUnmount(() => {
346+
clearSessionExpiry();
347+
});
284348
</script>
285349
286350
<style scoped>

0 commit comments

Comments
 (0)