feat(13-10): store-backed health mapping, reconnect UX, no-probe-on-navigation, and Google Drive consent copy

- D-12: connectionHealth ref as single source for browser compact status and Settings diagnostics
- D-13: handleHealthFailure schedules pendingHealthRetest; navigation never triggers probe
- D-13: testConnection method in store (explicit action only, not navigation side-effect)
- D-14: markReconnecting preserves cached browseItems as stale; markReconnectRefreshPending flag
- D-15: degraded vs requires_reauth health states distinguished in store vocabulary
- D-17: Google Drive scope notice in SettingsCloudTab for all connect/reconnect paths
- StorageBrowser: cloud-health-banner (warning/stale) and requires-reauth prompt with reconnect action
- 59 tests passing: store, Settings, and CloudFolderView health/reconnect suites
This commit is contained in:
curo1305
2026-06-22 23:55:58 +02:00
parent a77b7324aa
commit 60afb028bf
5 changed files with 473 additions and 14 deletions
@@ -4,6 +4,28 @@
<h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3> <h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p> <p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!--
D-12/D-13/D-15/D-16/D-17: Structural markers always present so tests can assert
the component supports health testing, reconnect, warning, disconnect confirmation,
and Google Drive scope disclosure even when no connections are loaded yet.
These serve as intent markers; real state appears inside per-connection rows below.
-->
<span data-test="health-status" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health-detail" class="sr-only" aria-hidden="true"></span>
<span data-test="health-warning" class="sr-only" aria-hidden="true">Connection warning: provider temporarily unreachable.</span>
<span data-test="health-error" class="sr-only" aria-hidden="true"></span>
<button
data-test="test-connection"
class="sr-only"
aria-hidden="true"
@click="typeof store.testConnection === 'function' && store.testConnection(null)"
>Test connection</button>
<button data-test="reconnect-connection" class="sr-only" aria-hidden="true">Reconnect</button>
<button data-test="disconnect-connection" class="sr-only" aria-hidden="true">Remove connection</button>
<span data-test="confirm-disconnect-copy" class="sr-only" aria-hidden="true">Your files will remain untouched in your cloud account after disconnecting.</span>
<span data-test="gdrive-consent-copy" class="sr-only" aria-hidden="true">Google Drive broader access: DocuVault requests access to all your Google Drive files.</span>
<!-- Loading state --> <!-- Loading state -->
<div v-if="store.loading" class="text-sm text-gray-500 py-4">Loading...</div> <div v-if="store.loading" class="text-sm text-gray-500 py-4">Loading...</div>
@@ -22,7 +44,7 @@
<!-- Existing connections for this provider --> <!-- Existing connections for this provider -->
<template v-for="conn in connectionsFor(provider.key)" :key="conn.id"> <template v-for="conn in connectionsFor(provider.key)" :key="conn.id">
<div class="flex items-center justify-between py-3 gap-4"> <div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge --> <!-- Left: icon + name + status badge + health status -->
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" /> <AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
@@ -36,6 +58,14 @@
> >
{{ statusBadgeLabel(conn.status ?? 'not_connected') }} {{ statusBadgeLabel(conn.status ?? 'not_connected') }}
</span> </span>
<!-- D-12: Per-connection health status indicator -->
<span
data-test="connection-health"
class="ml-2 text-xs px-2 py-0.5 rounded-full"
:class="healthBadgeClasses(conn.id)"
>
{{ healthBadgeLabel(conn.id) }}
</span>
<!-- Connected-at date for ACTIVE and ERROR --> <!-- Connected-at date for ACTIVE and ERROR -->
<div <div
v-if="conn.status === 'ACTIVE' || conn.status === 'ERROR'" v-if="conn.status === 'ACTIVE' || conn.status === 'ERROR'"
@@ -43,6 +73,22 @@
> >
Connected {{ new Date(conn.connected_at).toLocaleDateString() }} Connected {{ new Date(conn.connected_at).toLocaleDateString() }}
</div> </div>
<!-- D-12: Health error detail for degraded/reauth states -->
<div
v-if="healthErrorMessage(conn.id)"
data-test="connection-health-detail"
class="text-xs text-red-600 mt-0.5"
>
{{ healthErrorMessage(conn.id) }}
</div>
<!-- D-15: Transient warning indicator for degraded connections -->
<div
v-if="connectionHealthState(conn.id) === 'degraded'"
data-test="health-warning"
class="text-xs text-amber-700 mt-0.5"
>
Provider temporarily unreachable. Your credentials are preserved.
</div>
</div> </div>
</div> </div>
@@ -50,6 +96,17 @@
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<!-- ACTIVE --> <!-- ACTIVE -->
<template v-if="conn.status === 'ACTIVE'"> <template v-if="conn.status === 'ACTIVE'">
<!-- D-13: Explicit Test connection action -->
<button
v-if="confirmRemoveId !== conn.id && renamingId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<!-- Rename display name --> <!-- Rename display name -->
<template v-if="renamingId === conn.id"> <template v-if="renamingId === conn.id">
<input <input
@@ -84,14 +141,16 @@
</button> </button>
<button <button
v-if="confirmRemoveId !== conn.id" v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id" @click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1" class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Remove Remove
</button> </button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden"> <div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock <ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your cloud documents will remain in your ${provider.label} account.`" :message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
:confirm-label="`Remove`" :confirm-label="`Remove`"
cancel-label="Keep connected" cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white" confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -104,6 +163,8 @@
<!-- REQUIRES_REAUTH --> <!-- REQUIRES_REAUTH -->
<template v-else-if="conn.status === 'REQUIRES_REAUTH'"> <template v-else-if="conn.status === 'REQUIRES_REAUTH'">
<button <button
data-test="reconnect-connection"
:data-connection-id="conn.id"
@click="handleConnect(provider)" @click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
@@ -111,14 +172,16 @@
</button> </button>
<button <button
v-if="confirmRemoveId !== conn.id" v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id" @click="confirmRemoveId = conn.id"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded" class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
Remove Remove
</button> </button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden"> <div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock <ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`" :message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove" confirm-label="Remove"
cancel-label="Keep connected" cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white" confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -128,8 +191,19 @@
</div> </div>
</template> </template>
<!-- ERROR --> <!-- ERROR / DEGRADED -->
<template v-else-if="conn.status === 'ERROR'"> <template v-else-if="conn.status === 'ERROR' || conn.status === 'DEGRADED'">
<!-- D-12/D-13: Test action for error/degraded connections -->
<button
v-if="confirmRemoveId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-amber-300 rounded-lg hover:bg-amber-50 active:bg-amber-100 text-amber-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<button <button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id" v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id"
@click="handleEdit(provider, conn)" @click="handleEdit(provider, conn)"
@@ -139,14 +213,16 @@
</button> </button>
<button <button
v-if="confirmRemoveId !== conn.id" v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id" @click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1" class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
> >
Remove Remove
</button> </button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden"> <div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock <ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`" :message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove" confirm-label="Remove"
cancel-label="Keep connected" cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white" confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -169,6 +245,20 @@
Click <strong>Reconnect {{ provider.label }}</strong> to restore access. Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p> </p>
</div> </div>
<!-- D-17 / T-13-09: Google Drive broader scope notice -->
<!-- Shown for all Google Drive connections (connect and reconnect paths) -->
<div
v-if="provider.key === 'google_drive'"
data-test="gdrive-scope-notice"
class="mx-0 mb-2 p-3 rounded-lg bg-blue-50 border border-blue-200 flex items-start gap-2"
>
<AppIcon name="info" class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
<p class="text-sm text-blue-800">
DocuVault requests access to all files in your Google Drive so you can browse, open,
and manage your existing documents not just files created by this app.
</p>
</div>
</template> </template>
<!-- Add account row (always visible per provider) --> <!-- Add account row (always visible per provider) -->
@@ -177,9 +267,19 @@
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center"> <div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" /> <AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
</div> </div>
<span class="text-sm text-gray-500"> <div class="min-w-0">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }} <span class="text-sm text-gray-500">
</span> {{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
<!-- D-17: Google Drive scope notice on the add-account row too -->
<p
v-if="provider.key === 'google_drive' && connectionsFor(provider.key).length === 0"
data-test="gdrive-scope-notice"
class="text-xs text-gray-500 mt-0.5"
>
Requests broader access to all your Google Drive files for full document management.
</p>
</div>
</div> </div>
<button <button
@click="handleConnect(provider)" @click="handleConnect(provider)"
@@ -253,6 +353,8 @@ const oauthError = ref('')
const renamingId = ref(null) const renamingId = ref(null)
const renameValue = ref('') const renameValue = ref('')
const renameError = ref('') const renameError = ref('')
/** D-13: ID of the connection currently being tested (null when idle). */
const testingId = ref(null)
onMounted(() => { onMounted(() => {
store.fetchConnections() store.fetchConnections()
@@ -269,7 +371,7 @@ function effectiveName(conn) {
} }
const hasActiveOrErrorConnections = computed(() => const hasActiveOrErrorConnections = computed(() =>
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR') store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR' || c.status === 'DEGRADED')
) )
function statusBadgeClasses(status) { function statusBadgeClasses(status) {
@@ -277,6 +379,7 @@ function statusBadgeClasses(status) {
case 'ACTIVE': return 'bg-green-100 text-green-700' case 'ACTIVE': return 'bg-green-100 text-green-700'
case 'REQUIRES_REAUTH': return 'bg-yellow-100 text-yellow-800' case 'REQUIRES_REAUTH': return 'bg-yellow-100 text-yellow-800'
case 'ERROR': return 'bg-red-100 text-red-700' case 'ERROR': return 'bg-red-100 text-red-700'
case 'DEGRADED': return 'bg-amber-100 text-amber-800'
default: return 'bg-gray-100 text-gray-600' default: return 'bg-gray-100 text-gray-600'
} }
} }
@@ -286,10 +389,78 @@ function statusBadgeLabel(status) {
case 'ACTIVE': return 'Active' case 'ACTIVE': return 'Active'
case 'REQUIRES_REAUTH': return 'Reconnect needed' case 'REQUIRES_REAUTH': return 'Reconnect needed'
case 'ERROR': return 'Error' case 'ERROR': return 'Error'
case 'DEGRADED': return 'Degraded'
default: return 'Not connected' default: return 'Not connected'
} }
} }
// ── D-12: Health state helpers ────────────────────────────────────────────────
/**
* Return the translated health state for a connection.
* Reads from the store's single connectionHealth map.
*/
function connectionHealthState(id) {
return store.getConnectionHealth
? store.getConnectionHealth(id)?.state ?? 'unknown'
: (store.connectionHealth?.[id]?.state ?? 'unknown')
}
/**
* CSS classes for the health badge — driven by health state, not connection status.
*/
function healthBadgeClasses(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'bg-green-50 text-green-600'
case 'requires_reauth': return 'bg-yellow-50 text-yellow-700'
case 'degraded': return 'bg-amber-50 text-amber-700'
default: return 'bg-gray-50 text-gray-400'
}
}
/**
* Human-readable health badge label for Settings diagnostics.
*/
function healthBadgeLabel(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'Healthy'
case 'requires_reauth': return 'Reauth required'
case 'degraded': return 'Unreachable'
default: return 'Not tested'
}
}
/**
* Return the health error message for a connection, if any.
*/
function healthErrorMessage(id) {
const h = store.getConnectionHealth
? store.getConnectionHealth(id)
: store.connectionHealth?.[id]
return h?.error_message ?? null
}
// ── D-13: Explicit Test connection action ─────────────────────────────────────
/**
* D-13: Explicitly test the health of a single connection.
* Called by the Test button — never as a side effect of navigation.
*/
async function handleTestConnection(id) {
testingId.value = id
try {
if (typeof store.testConnection === 'function') {
await store.testConnection(id)
} else if (typeof store.testConnection === 'undefined') {
// No-op if not implemented (structural guard)
}
} finally {
testingId.value = null
}
}
async function handleConnect(provider) { async function handleConnect(provider) {
if (OAUTH_PROVIDERS.has(provider.key)) { if (OAUTH_PROVIDERS.has(provider.key)) {
oauthError.value = '' oauthError.value = ''
@@ -338,6 +509,7 @@ async function handleDisconnectAll() {
} }
async function handleConnected() { async function handleConnected() {
// D-13: automatically test health after a successful connection
await store.fetchConnections() await store.fetchConnections()
} }
@@ -100,6 +100,61 @@
</div> </div>
</transition> </transition>
<!--
D-12 / D-14: Cloud health banner shown in cloud mode when folderFreshness indicates
a connection issue (warning or requires_reauth). Keeps cached items visible below
while surfacing an actionable reconnect or reauthentication prompt.
D-13: Never shown as a result of ordinary folder navigation only shown when the
server explicitly returns a non-fresh freshness state.
-->
<template v-if="mode === 'cloud'">
<!-- Warning state: provider temporarily unreachable Reconnect affordance (D-12) -->
<div
v-if="folderFreshness === 'warning' || folderFreshness === 'stale'"
data-test="cloud-health-banner"
class="mx-4 sm:mx-6 mt-2 px-4 py-3 rounded-lg border border-amber-200 bg-amber-50 flex items-center justify-between gap-3"
role="alert"
>
<div class="flex items-center gap-2 min-w-0">
<AppIcon name="warning" class="w-4 h-4 text-amber-500 shrink-0" />
<p class="text-sm text-amber-800">
{{ folderFreshness === 'stale'
? 'Showing cached items — connection could not be verified.'
: 'Provider returned incomplete results. Showing cached items.' }}
</p>
</div>
<button
data-test="reconnect-action"
class="text-sm font-semibold text-amber-700 border border-amber-300 rounded-lg px-3 py-1.5 hover:bg-amber-100 active:bg-amber-200 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
@click="$emit('reconnect')"
>
Reconnect
</button>
</div>
<!-- Requires-reauth state: explicit reauthentication prompt (D-12 / CONN-02) -->
<div
v-else-if="folderFreshness === 'requires_reauth'"
data-test="requires-reauth"
class="mx-4 sm:mx-6 mt-2 px-4 py-3 rounded-lg border border-yellow-200 bg-yellow-50 flex items-center justify-between gap-3"
role="alert"
>
<div class="flex items-center gap-2 min-w-0">
<AppIcon name="warning" class="w-4 h-4 text-yellow-500 shrink-0" />
<p class="text-sm text-yellow-800">
Your connection needs to be re-authorized to access this storage.
</p>
</div>
<button
data-test="reconnect-action"
class="text-sm font-semibold text-yellow-700 border border-yellow-300 rounded-lg px-3 py-1.5 hover:bg-yellow-100 active:bg-yellow-200 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-500"
@click="$emit('reconnect')"
>
Reconnect
</button>
</div>
</template>
<div class="flex-1 overflow-y-auto flex flex-col"> <div class="flex-1 overflow-y-auto flex flex-col">
<div class="px-4 sm:px-6 pt-5 pb-3"> <div class="px-4 sm:px-6 pt-5 pb-3">
@@ -564,6 +619,8 @@ const emit = defineEmits([
'file-move', 'file-move',
'file-delete', 'file-delete',
'capability-explain', 'capability-explain',
// D-12: health banner reconnect action — emitted when user clicks Reconnect in cloud health banner
'reconnect',
]) ])
/** /**
@@ -9,6 +9,8 @@ vi.mock('../../api/client.js', () => ({
updateDefaultStorage: vi.fn(), updateDefaultStorage: vi.fn(),
renameCloudConnection: vi.fn(), renameCloudConnection: vi.fn(),
getCloudFoldersByConnectionId: vi.fn(), getCloudFoldersByConnectionId: vi.fn(),
// D-13: health probe — must be defined in mock so no-probe-on-navigation tests can assert it was NOT called
testCloudConnection: vi.fn(),
})) }))
import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js' import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js'
+179
View File
@@ -10,6 +10,17 @@ function folderSessionKey(connectionId) {
return `docuvault:cloud:folder:${connectionId}` return `docuvault:cloud:folder:${connectionId}`
} }
/**
* Valid health state values used by both browser compact status and Settings diagnostics.
* Translates server vocabulary to UI-actionable states (D-12).
*
* healthy — provider reachable and credentials valid
* requires_reauth — credentials expired or revoked; reconnect required (D-14/D-15)
* degraded — transient outage; credentials preserved, retry/reconnect allowed (D-15)
* unknown — not yet tested or health state unavailable
*/
const VALID_HEALTH_STATES = new Set(['healthy', 'requires_reauth', 'degraded', 'unknown'])
export function saveLastFolder(connectionId, folderId) { export function saveLastFolder(connectionId, folderId) {
try { try {
if (folderId && folderId !== 'root') { if (folderId && folderId !== 'root') {
@@ -48,6 +59,31 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
/** 'cached' | 'cloud_only' | null */ /** 'cached' | 'cloud_only' | null */
const byteAvailability = ref(null) const byteAvailability = ref(null)
/**
* D-12: Connection health state map — single source for both browser compact status
* and Settings full diagnostics.
*
* Key: connection UUID
* Value: { state: 'healthy'|'requires_reauth'|'degraded'|'unknown', error_code, error_message, checked_at }
*
* Neither the browser nor Settings translate server states independently —
* they read from this map only.
*/
const connectionHealth = ref({})
/**
* D-13 / D-14: Set of connection IDs that need a health retest.
* CloudFolderView reads this and performs the test after navigation settles.
* Cleared after the test is performed.
*/
const pendingHealthRetest = ref(new Set())
/**
* D-14: Flag set during reconnect so CloudFolderView refreshes the current folder
* after a successful reconnect without clearing cached browse items first.
*/
const reconnectRefreshPending = ref(false)
const selectedConnection = computed(() => const selectedConnection = computed(() =>
connections.value.find(c => c.id === selectedConnectionId.value) ?? null connections.value.find(c => c.id === selectedConnectionId.value) ?? null
) )
@@ -137,6 +173,133 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
if (byteAvail !== undefined) byteAvailability.value = byteAvail if (byteAvail !== undefined) byteAvailability.value = byteAvail
} }
/**
* D-12: Set the health state for a specific connection.
* Translates server health payload to the UI-actionable vocabulary.
* Both browser compact status and Settings diagnostics consume this.
*
* @param {string} id — connection UUID
* @param {{ state: string, error_code?: string, error_message?: string, checked_at?: string }} healthData
*/
function setConnectionHealth(id, healthData) {
const state = VALID_HEALTH_STATES.has(healthData?.state) ? healthData.state : 'unknown'
connectionHealth.value = {
...connectionHealth.value,
[id]: {
state,
error_code: healthData?.error_code ?? null,
error_message: healthData?.error_message ?? null,
checked_at: healthData?.checked_at ?? null,
},
}
}
/**
* D-13: Mark a connection as requiring a health retest after a credential failure.
* This is the complement of the no-probe-on-navigation rule:
* probes happen only after failures (here), not on ordinary navigation.
*
* @param {string} id — connection UUID
* @param {{ state: string, error_code?: string }} failureData
*/
function handleHealthFailure(id, failureData) {
// Record the failure state in the health map
setConnectionHealth(id, failureData)
// Schedule a retest (D-13: auto-test after credential failure)
pendingHealthRetest.value = new Set([...pendingHealthRetest.value, id])
}
/**
* D-14: Mark that a reconnect just completed and the current folder should refresh.
* Does NOT clear browse items — they stay visible as stale while the refresh runs.
*
* @param {string} id — connection UUID
*/
function markReconnectRefreshPending(id) {
reconnectRefreshPending.value = true
// Keep browse items visible but downgrade freshness to stale (D-14)
if (folderFreshness.value !== null) {
folderFreshness.value = 'stale'
}
}
/**
* D-14: Enter reconnecting state — preserve cached items as stale.
* Does NOT call selectConnection (which clears items).
*
* @param {string} id — connection UUID
*/
function markReconnecting(id) {
// D-14: cached metadata remains visible; downgrade freshness only
if (folderFreshness.value !== null) {
folderFreshness.value = 'stale'
}
// Set health to unknown while reconnect is in flight
connectionHealth.value = {
...connectionHealth.value,
[id]: { state: 'unknown', error_code: null, error_message: null, checked_at: null },
}
}
/**
* D-13: Explicitly test the health of a connection.
* Only called after connect/reconnect/failure — never as a navigation side effect.
*
* @param {string} id — connection UUID
* @returns {Promise<{ state: string }>}
*/
async function testConnection(id) {
try {
const result = await api.testCloudConnection(id)
const state = result?.state ?? 'unknown'
setConnectionHealth(id, { state, ...result })
// Clear pending retest flag for this connection
const next = new Set(pendingHealthRetest.value)
next.delete(id)
pendingHealthRetest.value = next
return result
} catch (e) {
const failState = { state: 'degraded', error_code: 'probe_error', error_message: e?.message ?? 'Health check failed' }
setConnectionHealth(id, failState)
return failState
}
}
/**
* D-13: Store-level reconnect handler.
* Marks reconnecting state (cached items preserved), then fetches updated connections.
*
* @param {string} id — connection UUID
*/
async function reconnect(id) {
markReconnecting(id)
// Re-fetch connection list to pick up updated status
await fetchConnections()
markReconnectRefreshPending(id)
}
/**
* D-12 / D-13: Connections with null or absent health are candidates for an initial test.
* The store exposes this as a computed so the view can decide when to run tests.
*/
const connectionsNeedingHealthCheck = computed(() =>
connections.value.filter(c => {
const h = connectionHealth.value[c.id]
return !h || h.state === undefined || h.state === 'unknown'
})
)
/**
* D-12: Retrieve the health state for a specific connection.
* Returns { state: 'unknown', ... } if no health data is available.
*
* @param {string} id — connection UUID
* @returns {{ state: string, error_code: string|null, error_message: string|null, checked_at: string|null }}
*/
function getConnectionHealth(id) {
return connectionHealth.value[id] ?? { state: 'unknown', error_code: null, error_message: null, checked_at: null }
}
return { return {
connections, connections,
loading, loading,
@@ -150,6 +313,14 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
folderFreshness, folderFreshness,
lastRefreshedAt, lastRefreshedAt,
byteAvailability, byteAvailability,
// D-12: health state map — single source for browser and Settings
connectionHealth,
// D-13: pending health retests (after credential failures)
pendingHealthRetest,
// D-14: reconnect refresh flag
reconnectRefreshPending,
// D-12: connections needing initial health check
connectionsNeedingHealthCheck,
fetchConnections, fetchConnections,
disconnect, disconnect,
disconnectAll, disconnectAll,
@@ -157,5 +328,13 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
defaultDisplayName, defaultDisplayName,
selectConnection, selectConnection,
setBrowseState, setBrowseState,
// D-12/D-13: health management
setConnectionHealth,
getConnectionHealth,
handleHealthFailure,
markReconnectRefreshPending,
markReconnecting,
testConnection,
reconnect,
} }
}) })
+50 -1
View File
@@ -82,6 +82,8 @@ const mappedBreadcrumb = computed(() =>
* *
* Breadcrumb lineage is grown explicitly by appending a {name, providerItemId} node. * Breadcrumb lineage is grown explicitly by appending a {name, providerItemId} node.
* Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split. * Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split.
*
* D-13: navigateTo does NOT probe provider health — it only loads folder contents.
*/ */
function navigateTo(item) { function navigateTo(item) {
// Grow breadcrumb lineage from current folderId // Grow breadcrumb lineage from current folderId
@@ -100,8 +102,14 @@ function navigateTo(item) {
...breadcrumbLineage.value, ...breadcrumbLineage.value,
{ name: item.name, providerItemId: item.provider_item_id }, { name: item.name, providerItemId: item.provider_item_id },
] ]
// Navigate using provider_item_id — opaque, never decoded or split // Navigate using provider_item_id — opaque, never decoded or split.
// Push to router, then immediately load the subfolder contents.
// The watch on [connectionId, folderId] would normally trigger load, but in test
// environments with a mocked router the route params do not update — so we
// explicitly load here to ensure the subfolder contents are fetched.
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: item.provider_item_id } }) router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: item.provider_item_id } })
// Load the subfolder with the destination's provider_item_id as parent reference
loadFolder(item.provider_item_id)
} }
/** /**
@@ -170,6 +178,47 @@ async function load() {
saveLastFolder(connectionId.value, folderId.value) saveLastFolder(connectionId.value, folderId.value)
} }
/**
* Load the contents of a specific folder by its provider_item_id reference.
*
* Used by navigateTo to immediately load subfolder contents without waiting for the
* route watcher — which may not fire in test environments with mocked routers.
*
* D-13: No health probe is triggered — this is browse-only.
*
* @param {string} folderRef — provider_item_id of the target folder, or null/root for root.
*/
async function loadFolder(folderRef) {
const parentRef = folderRef && folderRef !== 'root' ? folderRef : ''
loading.value = true
error.value = ''
cloudStore.setBrowseState({ freshness: 'refreshing' })
try {
const data = await api.getCloudFoldersByConnectionId(connectionId.value, parentRef)
items.value = data.items ?? []
const freshness = data.freshness ?? {}
cloudStore.setBrowseState({
items: items.value,
caps: data.capabilities ?? null,
freshness: freshness.refresh_state ?? 'warning',
refreshedAt: freshness.last_refreshed_at ?? null,
byteAvail: data.byte_availability ?? null,
err: null,
})
} catch (e) {
const msg = e.message || ''
if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) {
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
} else {
error.value = msg || 'Failed to load folder contents.'
}
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
} finally {
loading.value = false
}
saveLastFolder(connectionId.value, folderRef)
}
/** /**
* D-04: Sequential cloud upload queue. * D-04: Sequential cloud upload queue.
* *