Skip to content

Commit f548de1

Browse files
electrolobzikclaude
andcommitted
fix(ux): reload tools/approvals/logs on serverName navigation (ServerDetail.vue)
Vue Router 4 reuses the ServerDetail.vue component instance across /servers/foo → /servers/bar — same route, different param. The `server` computed correctly retargets via the Pinia store, but the local data refs (serverTools, toolApprovals, serverLogs, scan*) stayed populated with the previous server's data until something refetched them. Navigating between server detail pages briefly showed server B's name + stats combined with server A's tool list, which looked like a data-corruption bug. Adds a `watch(() => props.serverName)` that resets every per-server local ref and re-runs loadServerDetails. The reset enumerates exactly the refs that carry per-server data and leaves UI-state refs (activeTab, logTail, toolSearch) alone so the user's tab/scope choice persists across navigation. Race protection: introduces a `loadGeneration` counter bumped on every loadServerDetails entry. The three parallel fetches now run via internal `_loadToolsWithGen / _loadToolApprovalsWithGen / _loadLogsWithGen` helpers that capture the generation at entry and only commit results if it hasn't advanced — protects the foo→bar→foo case where foo's response arrives AFTER bar's load already started. The public no-arg `loadTools / loadToolApprovals / loadLogs` wrappers preserve existing call sites (template @click handlers, the connected/enabled watch, post-action refreshes) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f7694c8 commit f548de1

1 file changed

Lines changed: 80 additions & 10 deletions

File tree

frontend/src/views/ServerDetail.vue

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,6 +1279,41 @@ const hasDisabledTool = computed(() =>
12791279
serverTools.value.some(t => isToolToggleAvailable(t.name) && !isToolEnabled(t.name))
12801280
)
12811281
1282+
// Vue Router 4 reuses the ServerDetail.vue component instance across
1283+
// /servers/foo → /servers/bar (same route, just a different param). The
1284+
// `server` computed correctly retargets via the store, but the local
1285+
// data refs (serverTools, toolApprovals, serverLogs, scan*) stay populated
1286+
// with the previous server's data until something refetches them. Without
1287+
// this watch, navigating between server detail pages briefly shows server
1288+
// B's name + stats with server A's tool list — looks like a data-corruption
1289+
// bug. Reset eagerly, then kick off a fresh load.
1290+
//
1291+
// Race protection: loadGeneration is bumped by loadServerDetails so an
1292+
// in-flight load for the previous server can't overwrite the new server's
1293+
// refs when its fetch finally resolves. See loadTools / loadToolApprovals
1294+
// / loadLogs for the gen-check pattern.
1295+
watch(
1296+
() => props.serverName,
1297+
(next, prev) => {
1298+
if (next === prev) return
1299+
serverTools.value = []
1300+
toolsError.value = null
1301+
selectedToolSchema.value = null
1302+
toolApprovals.value = []
1303+
toolToggleLoading.value = {}
1304+
serverLogs.value = []
1305+
logsError.value = null
1306+
scanReport.value = null
1307+
scanStatus.value = null
1308+
scanError.value = null
1309+
scanReportLoading.value = false
1310+
activeScanJobId.value = null
1311+
scanFiles.value = []
1312+
scanFilesLoaded.value = false
1313+
void loadServerDetails()
1314+
}
1315+
)
1316+
12821317
// Reload tools (and approvals) whenever the server's runtime state
12831318
// changes between enabled/disconnected/connected. Without this, toggling
12841319
// a server enabled/disabled would leave the local serverTools list
@@ -1406,12 +1441,23 @@ function computeWordDiff(oldText: string, newText: string): DiffPart[] {
14061441
}
14071442
14081443
// Methods
1444+
// loadGeneration is bumped by every loadServerDetails entry. The three
1445+
// per-server fetches (loadTools / loadToolApprovals / loadLogs) capture
1446+
// the generation at the start of their call and only commit their result
1447+
// if the generation hasn't advanced — which protects against the foo→bar
1448+
// navigation race where foo's response arrives AFTER bar's load already
1449+
// started. The counter is intentionally not a Vue ref: it's purely
1450+
// internal flow control, no UI reactivity needed.
1451+
let loadGeneration = 0
1452+
14091453
async function loadServerDetails() {
1454+
const myGen = ++loadGeneration
14101455
loading.value = true
14111456
error.value = null
14121457
14131458
try {
14141459
await serversStore.fetchServers()
1460+
if (myGen !== loadGeneration) return
14151461
// server is a computed from the store — no manual reassignment needed.
14161462
14171463
if (!server.value) {
@@ -1421,42 +1467,59 @@ async function loadServerDetails() {
14211467
14221468
// Load tools, approvals, and logs in parallel
14231469
await Promise.all([
1424-
loadTools(),
1425-
loadToolApprovals(),
1426-
loadLogs()
1470+
_loadToolsWithGen(myGen),
1471+
_loadToolApprovalsWithGen(myGen),
1472+
_loadLogsWithGen(myGen)
14271473
])
14281474
} catch (err) {
1429-
error.value = err instanceof Error ? err.message : 'Failed to load server details'
1475+
if (myGen === loadGeneration) {
1476+
error.value = err instanceof Error ? err.message : 'Failed to load server details'
1477+
}
14301478
} finally {
1431-
loading.value = false
1479+
if (myGen === loadGeneration) loading.value = false
14321480
}
14331481
}
14341482
1435-
async function loadTools() {
1483+
// loadTools is the public no-arg wrapper used by template @click handlers
1484+
// and ad-hoc reloads (e.g. the connected/enabled watch). Internal gen-gated
1485+
// impl lives in _loadToolsWithGen so loadServerDetails can pass an explicit
1486+
// generation token for navigation-race protection.
1487+
function loadTools() {
1488+
return _loadToolsWithGen(loadGeneration)
1489+
}
1490+
1491+
async function _loadToolsWithGen(gen: number) {
14361492
if (!server.value) return
14371493
14381494
toolsLoading.value = true
14391495
toolsError.value = null
14401496
14411497
try {
14421498
const response = await api.getServerTools(server.value.name)
1499+
if (gen !== loadGeneration) return
14431500
if (response.success && response.data) {
14441501
serverTools.value = response.data.tools || []
14451502
} else {
14461503
toolsError.value = response.error || 'Failed to load tools'
14471504
}
14481505
} catch (err) {
1506+
if (gen !== loadGeneration) return
14491507
toolsError.value = err instanceof Error ? err.message : 'Failed to load tools'
14501508
} finally {
1451-
toolsLoading.value = false
1509+
if (gen === loadGeneration) toolsLoading.value = false
14521510
}
14531511
}
14541512
14551513
// Tool quarantine functions (Spec 032)
1456-
async function loadToolApprovals() {
1514+
function loadToolApprovals() {
1515+
return _loadToolApprovalsWithGen(loadGeneration)
1516+
}
1517+
1518+
async function _loadToolApprovalsWithGen(gen: number) {
14571519
if (!server.value) return
14581520
try {
14591521
const response = await api.getToolApprovals(server.value.name)
1522+
if (gen !== loadGeneration) return
14601523
if (response.success && response.data) {
14611524
const approvals = (response.data.tools || []).map((tool) => {
14621525
const disabled = typeof tool.disabled === 'boolean'
@@ -1484,6 +1547,7 @@ async function loadToolApprovals() {
14841547
}
14851548
})
14861549
await Promise.all(diffPromises)
1550+
if (gen !== loadGeneration) return
14871551
}
14881552
14891553
toolApprovals.value = approvals
@@ -1736,23 +1800,29 @@ async function bulkToggleAllTools(enabled: boolean) {
17361800
}
17371801
}
17381802
1739-
async function loadLogs() {
1803+
function loadLogs() {
1804+
return _loadLogsWithGen(loadGeneration)
1805+
}
1806+
1807+
async function _loadLogsWithGen(gen: number) {
17401808
if (!server.value) return
17411809
17421810
logsLoading.value = true
17431811
logsError.value = null
17441812
17451813
try {
17461814
const response = await api.getServerLogs(server.value.name, logTail.value)
1815+
if (gen !== loadGeneration) return
17471816
if (response.success && response.data) {
17481817
serverLogs.value = response.data.logs || []
17491818
} else {
17501819
logsError.value = response.error || 'Failed to load logs'
17511820
}
17521821
} catch (err) {
1822+
if (gen !== loadGeneration) return
17531823
logsError.value = err instanceof Error ? err.message : 'Failed to load logs'
17541824
} finally {
1755-
logsLoading.value = false
1825+
if (gen === loadGeneration) logsLoading.value = false
17561826
}
17571827
}
17581828

0 commit comments

Comments
 (0)