Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions frontend/src/components/ClientConnectList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,19 @@
<span>{{ conflictOf(client.id)!.message }}</span>
<span>Revoke or delete token <code>{{ conflictOf(client.id)!.conflicting_token }}</code>, then connect again.</span>
</div>
<!-- A failed re-fetch leaves the old preview up with Connect disabled;
say why. (Mutually exclusive with the no-preview error below.) -->
<p
v-if="previewError[client.id]"
:data-test="`connect-preview-error-${client.id}`"
class="text-xs text-error"
>{{ previewError[client.id] }}</p>
<div class="flex items-center gap-2 pt-1">
<button
:data-test="`client-preview-confirm-${client.id}`"
@click="confirmConnect(client.id)"
class="btn btn-primary btn-xs"
:disabled="loading.clients[client.id] || previews[client.id]!.access_state === 'malformed'"
:disabled="loading.clients[client.id] || previewRefreshing[client.id] || previewStale[client.id] || previews[client.id]!.access_state === 'malformed'"
>
<span v-if="loading.clients[client.id]" class="loading loading-spinner loading-xs"></span>
<span v-else>Connect</span>
Expand Down Expand Up @@ -846,13 +853,43 @@ async function onBindingToggle(clientId: string) {
await refreshPreview(clientId)
}

// One ticket per client: a slow preview for an older intent must not land over
// the newer one. `previewRefreshing` keeps Connect disabled while the intent on
// screen and the precondition token in hand disagree.
const previewTickets: Record<string, number> = {}
const previewRefreshing = ref<Record<string, boolean>>({})
// A re-fetch after a binding change failed: the token in hand belongs to an
// older intent, so Connect stays disabled until a fetch succeeds or Cancel.
const previewStale = ref<Record<string, boolean>>({})
function setStale(clientId: string, on: boolean) {
previewStale.value = { ...previewStale.value, [clientId]: on }
}
function setRefreshing(clientId: string, on: boolean) {
previewRefreshing.value = { ...previewRefreshing.value, [clientId]: on }
}

async function refreshPreview(clientId: string) {
const ticket = (previewTickets[clientId] = (previewTickets[clientId] ?? 0) + 1)
setRefusal(clientId, null)
setRefreshing(clientId, true)
try {
const response = await fetchPreview(clientId, intentFor(clientId))
if (response.success && response.data) previews.value = { ...previews.value, [clientId]: response.data }
if (ticket !== previewTickets[clientId]) return
if (response.success && response.data) {
previews.value = { ...previews.value, [clientId]: response.data }
setStale(clientId, false)
previewError.value = { ...previewError.value, [clientId]: '' }
} else {
setStale(clientId, true)
previewError.value = { ...previewError.value, [clientId]: response.error || 'Failed to refresh preview' }
}
} catch (err) {
if (ticket !== previewTickets[clientId]) return
setStale(clientId, true)
setRefusal(clientId, err as ApiError)
previewError.value = { ...previewError.value, [clientId]: describeError(err, 'Failed to refresh preview') }
} finally {
if (ticket === previewTickets[clientId]) setRefreshing(clientId, false)
}
}

Expand All @@ -863,20 +900,27 @@ async function startConnect(clientId: string) {
previewLoading[clientId] = true
previewError.value = { ...previewError.value, [clientId]: '' }
setRefusal(clientId, null)
const ticket = (previewTickets[clientId] = (previewTickets[clientId] ?? 0) + 1)
setRefreshing(clientId, false)
setStale(clientId, false)
// The row's current binding lives in the clients store; make sure it is there.
if (!clientsStore.clients.some(c => c.id === clientId)) await clientsStore.refreshPresence()
delete forms[clientId]
try {
const response = await fetchPreview(clientId, intentFor(clientId))
if (ticket !== previewTickets[clientId]) return
if (response.success && response.data) {
previews.value = { ...previews.value, [clientId]: response.data }
} else {
// The preview read may have been blocked by macOS App-Data — resolve the
// access state so a denial renders the existing remediation banner.
if (previews.value[clientId]) setStale(clientId, true)
previewError.value = { ...previewError.value, [clientId]: response.error || 'Failed to load preview' }
void checkAccess(clientId)
}
} catch (err) {
if (ticket !== previewTickets[clientId]) return
if (previews.value[clientId]) setStale(clientId, true)
previewError.value = { ...previewError.value, [clientId]: describeError(err, 'Failed to load preview') }
void checkAccess(clientId)
} finally {
Expand All @@ -886,16 +930,23 @@ async function startConnect(clientId: string) {

// Cancel dismisses the preview WITHOUT writing anything (Spec 078 US1).
function cancelPreview(clientId: string) {
// Invalidate any preview still in flight and drop its refusal (the bulk path
// never goes through here).
previewTickets[clientId] = (previewTickets[clientId] ?? 0) + 1
setRefreshing(clientId, false)
setStale(clientId, false)
setRefusal(clientId, null)
const next = { ...previews.value }
delete next[clientId]
previews.value = next
// Dismissing also drops a failed re-fetch's message.
const nextErr = { ...previewError.value }
delete nextErr[clientId]
previewError.value = nextErr
}

function clearPreview(clientId: string) {
cancelPreview(clientId)
const nextErr = { ...previewError.value }
delete nextErr[clientId]
previewError.value = nextErr
}

// Confirm proceeds with the connect. If an entry already exists, confirming
Expand Down Expand Up @@ -970,6 +1021,8 @@ async function connect(
resultMessage.value = response.data.message || `Connected to ${clientId}`
resultSuccess.value = true
resultReloadHint.value = response.data.reload_hint || ''
// A stale refusal from an earlier attempt no longer applies.
setRefusal(clientId, null)
// Empty/absent backup_path on success means no prior file existed.
const backupPath = response.data.backup_path || null
resultBackupPath.value = backupPath
Expand Down Expand Up @@ -1307,6 +1360,10 @@ watch(() => props.show, (newVal) => {
previews.value = {}
previewError.value = {}
connectRefusal.value = {}
// Invalidate any preview fetch still in flight from the previous open.
for (const id of Object.keys(previewTickets)) previewTickets[id] += 1
previewRefreshing.value = {}
previewStale.value = {}
for (const id of Object.keys(forms)) delete forms[id]
Object.assign(bulkForm, { profile: '', locked: false, touched: false })
lastConnect.value = null
Expand Down
28 changes: 22 additions & 6 deletions frontend/src/components/clients/BulkMoveDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@
<label class="label cursor-pointer justify-start gap-2"><input v-model="mode" type="radio" class="radio radio-sm" value="switchable" data-test="bulk-mode-switchable" /><span class="label-text">Switchable</span></label>
</fieldset>
<p class="text-sm" data-test="bulk-preview-line" aria-live="polite">{{ previewLine }}</p>
<div v-if="loadFailed" class="flex items-center gap-2 text-sm text-error" data-test="bulk-count-failed">
<span>Could not count every client.</span>
<button type="button" class="btn btn-ghost btn-xs" data-test="bulk-count-retry" @click="loadAllClients">Retry</button>
</div>
<div v-if="error" role="alert" class="alert alert-error text-sm" data-test="bulk-error">{{ error }}</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('close')">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm" :disabled="busy || from === to || affected.length === 0" data-test="bulk-submit">
<button type="submit" class="btn btn-primary btn-sm" :disabled="busy || countPending || from === to || affected.length === 0" data-test="bulk-submit">
<span v-if="busy" class="loading loading-spinner loading-xs" />
Move {{ affected.length }} client{{ affected.length === 1 ? '' : 's' }}
<template v-if="countPending">Counting...</template>
<template v-else>Move {{ affected.length }} client{{ affected.length === 1 ? '' : 's' }}</template>
</button>
</div>
</form>
Expand Down Expand Up @@ -75,8 +80,11 @@ const error = ref('')
const result = ref<BulkAssignResponse | null>(null)
// POST /clients/bulk-assign moves every client on the from-profile instance-wide,
// while props.clients is the page's ?profile=/?client= filtered list. The preview
// counts the unscoped list (props.clients until it arrives or if it fails).
// counts the unscoped list only: while it is pending or failed there is no
// count, and Move stays disabled (the filtered rows would undercount).
const allClients = ref<ClientPresence[] | null>(null)
const loadFailed = ref(false)
let openTicket = 0

watch(() => props.open, open => {
if (!open) return
Expand All @@ -87,6 +95,7 @@ watch(() => props.open, open => {
error.value = ''
result.value = null
allClients.value = null
loadFailed.value = false
void loadAllClients()
if (!profiles.loaded) void profiles.fetchProfiles()
})
Expand All @@ -95,17 +104,24 @@ watch(() => props.open, open => {
watch(to, value => { if (!value && mode.value === 'locked') mode.value = '' })

async function loadAllClients() {
// A per-load ticket: a slow response of an earlier open (or retry) is ignored.
const ticket = ++openTicket
loadFailed.value = false
try {
const response = await api.getClients()
if (response.success && Array.isArray(response.data?.clients) && props.open) allClients.value = response.data.clients
if (ticket !== openTicket || !props.open) return
if (response.success && Array.isArray(response.data?.clients)) allClients.value = response.data.clients
else loadFailed.value = true
} catch {
// The page's rows stay the fallback.
if (ticket === openTicket) loadFailed.value = true
}
}

const affected = computed(() => (allClients.value ?? props.clients).filter(client => client.credential_state === 'client' && (client.profile ?? '') === from.value))
const countPending = computed(() => allClients.value === null)
const affected = computed(() => (allClients.value ?? []).filter(client => client.credential_state === 'client' && (client.profile ?? '') === from.value))
const previewLine = computed(() => {
const name = from.value ? profiles.titleFor(from.value) : 'All servers'
if (countPending.value) return loadFailed.value ? `Count unavailable for ${name}` : `Counting clients that use ${name}...`
const n = affected.value.length
return `${n} client${n === 1 ? ' uses' : 's use'} ${name}`
})
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/profiles/AssignClientDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ const bindings = useClientBindingsStore()
const clientId = ref('')
const done = ref('')
const refusal = computed(() => (clientId.value ? bindings.rowErrors[clientId.value] : undefined))
const eligible = computed(() => clients.clients.filter(client => client.credential_state === 'client'))
const eligible = computed(() => clients.allClients.filter(client => client.credential_state === 'client'))

watch(() => props.open, open => {
if (!open) return
clientId.value = ''
done.value = ''
if (!clients.clients.length) void clients.refreshPresence()
// The roster may be a scoped page's rows: always refetch the unscoped one.
void clients.refreshPresence()
})

async function submit() {
Expand Down
62 changes: 50 additions & 12 deletions frontend/src/stores/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,73 @@ export const useClientsStore = defineStore('clients', () => {
// A response is applied only while it is still the latest of its kind and the
// scope it was asked for is still the active one: changing ?profile= / ?client=
// must not let the previous scope's rows land afterwards.
// One ticket covers every GET /clients (load and refreshPresence), so an older
// response can never overwrite a newer one. loadTicket only owns the loading
// flag, so a load superseded by a presence poll still clears it.
let fetchTicket = 0
let loadTicket = 0
let presenceTicket = 0
const scopeKey = () => JSON.stringify(scope)
const isUnscoped = () => !scope.profile && !scope.client

// The unscoped roster (updated only by unscoped fetches). The sidebar badge,
// the assign dialog and the profile usage list read it, so a ?profile= /
// ?client= filter on the Clients page never shrinks them.
const allClients = ref<ClientPresence[]>([])
// Set by clearScope(): the roster must be refetched before it is trusted.
const stale = ref(false)
// Both GET /clients reads of one refresh; the unscoped one is shared when the
// active scope is already unscoped.
async function fetchRosters() {
const scoped = api.getClients(scope)
const all = isUnscoped() ? scoped : api.getClients({})
return Promise.all([scoped, all])
}
function applyAll(response: { success: boolean; data?: { clients?: ClientPresence[] } }) {
if (response.success && Array.isArray(response.data?.clients)) {
allClients.value = response.data.clients
stale.value = false
}
}

async function load(nextScope?: { profile?: string; client?: string }) {
if (nextScope) scope = nextScope
const ticket = ++loadTicket
const ticket = ++fetchTicket
const mine = ++loadTicket
const asked = scopeKey()
loading.value = true
error.value = null
const [clientResponse, routingResponse] = await Promise.all([api.getClients(scope), api.getRouting()])
// A newer load owns the loading flag and the rows.
if (ticket !== loadTicket || asked !== scopeKey()) return
const [[clientResponse, allResponse], routingResponse] = await Promise.all([fetchRosters(), api.getRouting()])
// A newer load owns the loading flag.
if (mine !== loadTicket || asked !== scopeKey()) return
// Routing is only fetched here, so a superseded load still applies it.
if (routingResponse.success && routingResponse.data) routing.value = routingResponse.data
// A newer fetch (another load or a presence poll) owns the rows.
if (ticket !== fetchTicket) {
loading.value = false
return
}
applyAll(allResponse)
if (clientResponse.success && clientResponse.data) {
clients.value = clientResponse.data.clients
warnings.value = clientResponse.data.warnings ?? []
detailLoaded.clear()
} else error.value = clientResponse.error || 'Unable to load clients'
if (routingResponse.success && routingResponse.data) routing.value = routingResponse.data
loading.value = false
}

// Rows with at least one live session: the sidebar Clients badge (Spec 109-i).
const liveCount = computed(() => clients.value.filter(client => (client.active_sessions ?? 0) > 0).length)
const liveCount = computed(() => allClients.value.filter(client => (client.active_sessions ?? 0) > 0).length)

// Silent presence refresh for the sidebar badge. It fetches GET /clients only
// and never touches loading/error/routing, so the Clients page does not
// flash a spinner when a badge poll lands underneath it.
async function refreshPresence() {
const ticket = ++presenceTicket
const ticket = ++fetchTicket
const asked = scopeKey()
try {
const response = await api.getClients(scope)
if (ticket !== presenceTicket || asked !== scopeKey()) return
const [response, allResponse] = await fetchRosters()
if (ticket !== fetchTicket || asked !== scopeKey()) return
applyAll(allResponse)
if (response.success && Array.isArray(response.data?.clients)) {
warnings.value = response.data.warnings ?? []
// GET /clients is metadata-only. For rows whose detail was loaded via
Expand Down Expand Up @@ -97,7 +130,12 @@ export const useClientsStore = defineStore('clients', () => {
else clients.value.push(row)
}

function clearScope() { scope = {} }
// Dropping the scope also invalidates the rows: they were fetched under the
// old filter, so the next consumer must refetch rather than trust them.
function clearScope() {
scope = {}
stale.value = true
}

async function loadDetail(id: string) {
const response = await api.getClient(id)
Expand All @@ -119,5 +157,5 @@ export const useClientsStore = defineStore('clients', () => {
})
}

return { clients, warnings, routing, loading, error, liveCount, load, refreshPresence, loadDetail, replaceRow, clearScope }
return { clients, allClients, stale, warnings, routing, loading, error, liveCount, load, refreshPresence, loadDetail, replaceRow, clearScope }
})
11 changes: 8 additions & 3 deletions frontend/src/views/AgentTokens.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
<tr>
<th>Name</th>
<th>Kind</th>
<th>Profile</th>
<th class="min-w-[10rem]">Profile</th>
<th>Mode</th>
<th>Prefix</th>
<th>Expires</th>
Expand Down Expand Up @@ -151,7 +151,7 @@
</td>
<td :data-test="`token-kind-${token.name}`">{{ isClientCredential(token) ? 'Client' : 'Agent' }}</td>
<td>
<span v-if="token.profile_pin" class="badge badge-outline badge-sm max-w-[12rem] min-w-0 justify-start overflow-hidden" :title="profilesStore.titleFor(token.profile_pin)" :data-test="`token-profile-${token.name}`"><span class="truncate">{{ profilesStore.titleFor(token.profile_pin) }}</span></span>
<span v-if="token.profile_pin" class="badge badge-outline badge-sm max-w-[12rem] min-w-0 justify-start overflow-hidden whitespace-nowrap" :title="profilesStore.titleFor(token.profile_pin)" :data-test="`token-profile-${token.name}`"><span class="truncate">{{ profilesStore.titleFor(token.profile_pin) }}</span></span>
<span v-else class="text-base-content/40 text-sm">&mdash;</span>
</td>
<td>
Expand Down Expand Up @@ -558,23 +558,28 @@ function permissionBadgeClass(perm: string): string {
}

// Data loading
let tokensTicket = 0
async function loadTokens() {
const ticket = ++tokensTicket
loading.value = true
error.value = null

try {
const rest = scopeQuery?.toRest()
const response = await apiClient.listAgentTokens({ profile: rest?.profile, token: rest?.token })
// A newer load owns the rows and the loading flag.
if (ticket !== tokensTicket) return
if (response.success && response.data) {
tokens.value = response.data.tokens || []
} else {
error.value = response.error || 'Failed to load tokens'
}
} catch (err: any) {
if (ticket !== tokensTicket) return
error.value = err.message || 'Failed to load tokens'
console.error('Failed to load tokens:', err)
} finally {
loading.value = false
if (ticket === tokensTicket) loading.value = false
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/Profiles.vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,6 @@ onMounted(() => {
consumeCreateParam()
void store.fetchProfiles()
// Who uses a profile (client names) comes from the clients store.
if (!tenant.value && !clients.clients.length) void clients.refreshPresence()
if (!tenant.value && (!clients.allClients.length || clients.stale)) void clients.refreshPresence()
})
</script>
Loading
Loading