API and Authentication#

This page explains how the app communicates with the ArgoCD API — authentication, data fetching, caching, and log streaming.

Token lifecycle#

Authentication starts when a user runs argocd login --sso on their workstation to authenticate via Keycloak, then copies the resulting auth-token and (optionally) refresh-token from ~/.config/argocd/config into the app’s login dialog.

When the user submits tokens in the TokenDialog (src/components/shared/token-dialog.tsx:23), saveTokens() writes both values to localStorage and sets them as browser cookies (src/lib/auth-token.ts:62). This dual storage is intentional – localStorage is the durable source of truth, while cookies are needed because fetch() calls use credentials: "include" so the browser automatically attaches them to every API request (src/api/argocd-client.ts:67).

On page load, applyStoredTokens() runs at module-import time (src/api/argocd-client.ts:9) to re-sync localStorage values back into cookies, since cookies may have expired or been cleared by the browser between sessions.

Handling 401s: the singleton refresh promise#

When any API call returns a 401, the client attempts to refresh the auth token before giving up. The key design challenge here is concurrency: if multiple API requests are in flight and they all receive 401s simultaneously, the app must not fire multiple refresh requests in parallel.

The solution is a module-level singleton promise (src/api/argocd-client.ts:28). The first 401 that calls tryRefreshToken() creates the promise and stores it in refreshPromise. Any subsequent 401 that arrives while that refresh is still in progress receives the same promise, so all callers wait on a single network request. The finally block clears refreshPromise back to null once the attempt settles (src/api/argocd-client.ts:52), allowing future 401s to try again.

If the refresh succeeds, saveTokens() writes the new auth token to both localStorage and cookies, and the original request is retried. If it fails, the flow falls through to onAuthFailure().

Asymmetric token clearing on failure#

When authentication fails irrecoverably, onAuthFailure() (src/lib/auth-token.ts:47) clears only the auth token and its cookie. The refresh token is deliberately preserved. This means the TokenDialog will re-appear (because the auth token snapshot becomes falsy), but the refresh token field will still be pre-populated with the previously stored value. The user only needs to paste a new auth token rather than re-entering both.

By contrast, clearStoredToken() (src/lib/auth-token.ts:35) removes both tokens and is used for explicit logout.

The AuthGate pattern#

AuthGate (src/App.tsx:22) is a thin wrapper around all routes that uses React’s useSyncExternalStore to subscribe to the token store. It reads getTokenSnapshot(), which returns a boolean indicating whether an auth token exists in localStorage.

When the snapshot is false, the TokenDialog opens as a modal overlay. Crucially, the child routes are still rendered behind the dialog – AuthGate does not conditionally unmount them. This avoids losing React state if a token expires mid-session: the dialog appears, the user pastes a new token, and the app continues from where it left off.

After a successful token submission, the onTokenSubmit callback invalidates all React Query caches (src/App.tsx:29), causing every active query to refetch with the new credentials.

Why useAuth disables refetchOnWindowFocus#

The useAuth hook (src/hooks/use-auth.ts:4) queries the /api/v1/session/userinfo endpoint to check whether the current session is valid. It explicitly sets refetchOnWindowFocus: false, overriding the app-wide default of true (src/App.tsx:17).

Without this override, every tab switch or window focus would fire a userinfo request. If the token has expired, that request returns a 401, which triggers onAuthFailure(), which clears the auth token and pops the login dialog – even though the user may simply be switching back to the app. Disabling focus-based refetch for this specific query prevents the dialog from appearing unexpectedly on every window focus event when the token is stale.

Data fetching with React Query#

All HTTP calls flow through argocdFetch<T> (JSON) and argocdFetchStream (streaming) in src/api/argocd-client.ts. Both share the same 401-handling logic described above.

Query key hierarchy#

React Query uses query keys to identify cached data. This app organises keys into a hierarchy that mirrors the API resource model:

Hook

Query key

Scope

useApplications

["applications", project]

All apps, optionally filtered by project

useApplication

["application", name, appNamespace]

Single app metadata

useResourceTree

["resourceTree", appName, appNamespace]

Kubernetes resources owned by one app

The first element of each key acts as a category tag. Because React Query matches keys by prefix, invalidating ["applications"] (without a project) clears every project-scoped variant too.

Key definitions live alongside the hooks that use them (src/hooks/use-applications.ts:10, src/hooks/use-application.ts:7, src/hooks/use-application.ts:15).

Polling with refetch intervals#

Two hooks set up automatic background polling so the UI stays current without manual refreshes:

  • useApplications refetches every VITE_REFRESH_INTERVAL milliseconds (default 30 000) and marks data stale after 10 seconds.

  • useResourceTree refetches every 15 seconds to keep the pod table up to date.

useApplication does not poll – the detail page relies on useResourceTree for live resource state while the app metadata changes infrequently.

Mutation invalidation#

When a user restarts a pod, the useRestartPod mutation needs to update three levels of the cache so every view reflects the change. Its onSuccess handler (src/hooks/use-restart-pod.ts:17-26) invalidates queries in a cascade:

  1. ["resourceTree", appName] – the pod table for the affected application.

  2. ["application", appName] – the application detail (health status may change).

  3. ["applications"] – the top-level application list (sync/health summary columns).

Because keys are matched by prefix, the ["applications"] invalidation covers all project-filtered variants in a single call.

Streaming logs#

Log streaming is the most complex data-fetching pattern in the app. It bypasses React Query entirely because log data is an append-only stream, not a cacheable query result.

The transport layer#

argocdFetchStream (src/api/argocd-client.ts:97-127) returns a raw ReadableStream<Uint8Array> from the Fetch API. It handles 401 responses the same way argocdFetch does – attempting a token refresh before giving up.

The async generator#

streamLogs (src/api/logs.ts:14-85) wraps the readable stream in an async generator. It reads chunks from the stream, accumulates them in a text buffer, splits on newline boundaries, and yields each parsed LogEntry. This approach:

  • Lets the consumer use a simple for await...of loop.

  • Handles partial JSON lines that span chunk boundaries by keeping a buffer (src/api/logs.ts:61).

  • Releases the stream reader lock in a finally block (src/api/logs.ts:83) so the connection is cleaned up regardless of how the loop exits.

The React hook#

useLogs (src/hooks/use-logs.ts:15-90) manages the stream lifecycle inside a React component:

  • Start/stop – the start callback creates a new AbortController, clears accumulated lines, and kicks off the stream. The stop callback aborts the controller, resetting state.

  • Appending lines – each yielded entry is appended to state via setLines(prev => [...prev, entry.content]).

  • Auto-reconnect – when follow mode is enabled and the stream errors, the hook retries up to MAX_RETRIES (3) times with a 2-second delay between attempts. A successful read resets the retry counter, so only consecutive failures count towards the limit.

Cancellation with AbortController#

The app uses AbortController in two places to cancel in-flight HTTP requests:

  1. Log streaminguseLogs stores a controller in a ref. Calling stop() or unmounting the component aborts the signal, which propagates through argocdFetchStream into the underlying fetch call. The async generator’s reader.read() rejects with an AbortError, which the hook catches and silently ignores.

  2. React Query queries – TanStack React Query passes its own AbortSignal to query functions automatically. When a component unmounts or a query is cancelled, React Query aborts the signal. The app’s argocdFetch receives this through the init parameter’s signal property.

This ensures that navigating away from a page does not leave orphaned connections consuming server resources.