feat(frontend): add workspace-aware server state

This commit is contained in:
Juan Denis committed 2026-08-18 03:09:53 -04:00
1 parent 899dac99b5
commit b5c6efb3c3
5 files changed
+366 -20

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { queryClient, serializeQueryKey } from '../services/queryClient';
const workspaceKey = (workspaceId, queryKey) => ['workspace', workspaceId, ...queryKey];
export function useServerQuery(queryKey, queryFn, options = {}) {
const {
enabled = true,
staleTime = 0,
onError,
} = options;
const { activeWorkspaceId } = useWorkspace();
const localKeyHash = serializeQueryKey(queryKey);
const scopedKey = useMemo(
() => workspaceKey(activeWorkspaceId, queryKey),
// queryKey is commonly declared inline; its serialized value is the
// semantic dependency and avoids refetching on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeWorkspaceId, localKeyHash],
);
const scopedKeyHash = serializeQueryKey(scopedKey);
const queryFnRef = useRef(queryFn);
const onErrorRef = useRef(onError);
queryFnRef.current = queryFn;
onErrorRef.current = onError;
const subscribe = useCallback(
(listener) => queryClient.subscribe(scopedKey, listener),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scopedKeyHash],
);
const getSnapshot = useCallback(
() => queryClient.getSnapshot(scopedKey),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scopedKeyHash],
);
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
useEffect(() => {
if (!enabled) return undefined;
let active = true;
queryClient.fetchQuery(
scopedKey,
(context) => queryFnRef.current(context),
{ staleTime },
).catch((error) => {
if (active) onErrorRef.current?.(error);
});
return () => { active = false; };
}, [enabled, scopedKeyHash, staleTime, state.revision, scopedKey]);
const refetch = useCallback(
() => queryClient.fetchQuery(
scopedKey,
(context) => queryFnRef.current(context),
{ force: true, staleTime },
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scopedKeyHash, staleTime],
);
return {
...state,
isLoading: state.status === 'idle' || state.status === 'loading',
isError: state.status === 'error',
refetch,
};
}
export function useServerMutation(mutationFn, options = {}) {
const { invalidate = [], onSuccess, onError } = options;
const { activeWorkspaceId } = useWorkspace();
const mutationFnRef = useRef(mutationFn);
const optionsRef = useRef({ invalidate, onSuccess, onError });
mutationFnRef.current = mutationFn;
optionsRef.current = { invalidate, onSuccess, onError };
const [state, setState] = useState({ status: 'idle', data: undefined, error: null });
const mutate = useCallback(async (variables) => {
setState((previous) => ({ ...previous, status: 'loading', error: null }));
try {
const data = await mutationFnRef.current(variables);
setState({ status: 'success', data, error: null });
optionsRef.current.invalidate.forEach((prefix) => {
queryClient.invalidateQueries(workspaceKey(activeWorkspaceId, prefix));
});
await optionsRef.current.onSuccess?.(data, variables);
return data;
} catch (error) {
setState({ status: 'error', data: undefined, error });
optionsRef.current.onError?.(error, variables);
throw error;
}
}, [activeWorkspaceId]);
const reset = useCallback(() => {
setState({ status: 'idle', data: undefined, error: null });
}, []);
return {
...state,
isPending: state.status === 'loading',
mutate,
reset,
};
}
+24 -18
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { useToast } from '../contexts/ToastContext';
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useServerMutation, useServerQuery } from '../hooks/useServerQuery';
// Preset views. `servers` and `users` are quota CEILINGS, not usage, so there
// is no column to express "near capacity" against.
@@ -83,8 +84,6 @@ const Workspaces = () => {
const toast = useToast();
const navigate = useNavigate();
const { activeWorkspaceId } = useWorkspace();
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState(new Set());
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -92,18 +91,21 @@ const Workspaces = () => {
useFocusParam('create', () => setShowCreateModal(true));
const [form, setForm] = useState({ name: '', description: '', max_servers: 0, max_users: 0, primary_color: '#6d7cff' });
const loadWorkspaces = useCallback(async () => {
try {
const data = await api.getWorkspaces();
setWorkspaces(data.workspaces || []);
} catch (err) {
toast.error('Failed to load workspaces');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => { loadWorkspaces(); }, [loadWorkspaces]);
const loadWorkspaces = useCallback(
({ signal }) => api.getWorkspaces({}, { signal }).then((data) => data.workspaces || []),
[],
);
const {
data: workspaces = [],
isLoading: loading,
} = useServerQuery(['workspaces'], loadWorkspaces, {
staleTime: 30_000,
onError: () => toast.error('Failed to load workspaces'),
});
const createWorkspace = useServerMutation(
(values) => api.createWorkspace(values),
{ invalidate: [['workspaces']] },
);
useTopbarActions(() => (
<>
@@ -121,11 +123,10 @@ const Workspaces = () => {
const handleCreate = async () => {
try {
await api.createWorkspace(form);
await createWorkspace.mutate(form);
toast.success('Workspace created');
setShowCreateModal(false);
setForm({ name: '', description: '', max_servers: 0, max_users: 0, primary_color: '#6d7cff' });
loadWorkspaces();
} catch (err) {
toast.error(err.message);
}
@@ -256,7 +257,12 @@ const Workspaces = () => {
footer={(
<>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!form.name}>Create</Button>
<Button
onClick={handleCreate}
disabled={!form.name || createWorkspace.isPending}
>
{createWorkspace.isPending ? 'Creating…' : 'Create'}
</Button>
</>
)}
>
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createQueryClient } from '../queryClient.js';
test('deduplicates concurrent requests for one scoped key', async () => {
const client = createQueryClient();
let calls = 0;
let resolveRequest;
const request = () => {
calls += 1;
return new Promise((resolve) => { resolveRequest = resolve; });
};
const first = client.fetchQuery(['workspace', 'alpha', 'apps'], request);
const second = client.fetchQuery(['workspace', 'alpha', 'apps'], request);
await Promise.resolve();
resolveRequest(['one']);
assert.deepEqual(await first, ['one']);
assert.deepEqual(await second, ['one']);
assert.equal(calls, 1);
assert.equal(client.getSnapshot(['workspace', 'alpha', 'apps']).status, 'success');
});
test('isolates identical resources between workspaces', async () => {
const client = createQueryClient();
await Promise.all([
client.fetchQuery(['workspace', 'alpha', 'servers'], async () => ['alpha-server']),
client.fetchQuery(['workspace', 'beta', 'servers'], async () => ['beta-server']),
]);
assert.deepEqual(
client.getSnapshot(['workspace', 'alpha', 'servers']).data,
['alpha-server'],
);
assert.deepEqual(
client.getSnapshot(['workspace', 'beta', 'servers']).data,
['beta-server'],
);
});
test('cancels an in-flight request after its last observer leaves', async () => {
const client = createQueryClient();
const key = ['workspace', 'alpha', 'jobs'];
let signal;
const unsubscribe = client.subscribe(key, () => {});
const pending = client.fetchQuery(key, ({ signal: requestSignal }) => {
signal = requestSignal;
return new Promise(() => {});
});
await Promise.resolve();
unsubscribe();
assert.equal(signal.aborted, true);
assert.equal(client.getSnapshot(key).isFetching, false);
void pending;
});
test('prefix invalidation targets one workspace and advances revision', async () => {
const client = createQueryClient();
const alpha = ['workspace', 'alpha', 'apps'];
const beta = ['workspace', 'beta', 'apps'];
await client.fetchQuery(alpha, async () => ['a']);
await client.fetchQuery(beta, async () => ['b']);
const alphaRevision = client.getSnapshot(alpha).revision;
const betaRevision = client.getSnapshot(beta).revision;
client.invalidateQueries(['workspace', 'alpha']);
assert.equal(client.getSnapshot(alpha).revision, alphaRevision + 1);
assert.equal(client.getSnapshot(alpha).updatedAt, 0);
assert.equal(client.getSnapshot(beta).revision, betaRevision);
});
+2 -2
View File
@@ -916,12 +916,12 @@ export async function getServerTemplateAssignments(serverId) {
}
// Workspaces endpoints
export async function getWorkspaces(params = {}) {
export async function getWorkspaces(params = {}, options = {}) {
const query = new URLSearchParams();
if (params.all) query.append('all', 'true');
if (params.include_archived) query.append('include_archived', 'true');
const qs = query.toString();
return this.request(`/workspaces/${qs ? '?' + qs : ''}`);
return this.request(`/workspaces/${qs ? '?' + qs : ''}`, options);
}
export async function getWorkspace(id) {
+153
View File
@@ -0,0 +1,153 @@
const IDLE_STATE = Object.freeze({
status: 'idle',
data: undefined,
error: null,
updatedAt: 0,
revision: 0,
isFetching: false,
});
export const serializeQueryKey = (queryKey) => JSON.stringify(queryKey);
const keyStartsWith = (candidate, prefix) => (
prefix.length <= candidate.length
&& prefix.every((part, index) => Object.is(part, candidate[index]))
);
const isAbortError = (error) => error?.name === 'AbortError';
/**
* Small framework-neutral server-state cache.
*
* It deliberately owns only lifecycle concerns shared by every resource:
* key isolation, in-flight deduplication, cancellation, freshness and
* invalidation. Domain hooks still own response interpretation and live-event
* semantics.
*/
export function createQueryClient() {
const entries = new Map();
const ensureEntry = (queryKey) => {
const hash = serializeQueryKey(queryKey);
let entry = entries.get(hash);
if (!entry) {
entry = {
hash,
queryKey: [...queryKey],
state: IDLE_STATE,
listeners: new Set(),
controller: null,
promise: null,
};
entries.set(hash, entry);
}
return entry;
};
const notify = (entry) => entry.listeners.forEach((listener) => listener());
const updateState = (entry, patch) => {
entry.state = { ...entry.state, ...patch };
notify(entry);
};
const cancelEntry = (entry) => {
entry.controller?.abort();
entry.controller = null;
entry.promise = null;
if (entry.state.isFetching) updateState(entry, { isFetching: false });
};
const subscribe = (queryKey, listener) => {
const entry = ensureEntry(queryKey);
entry.listeners.add(listener);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size === 0) cancelEntry(entry);
};
};
const getSnapshot = (queryKey) => ensureEntry(queryKey).state;
const fetchQuery = (queryKey, queryFn, { force = false, staleTime = 0 } = {}) => {
const entry = ensureEntry(queryKey);
if (entry.promise && !force) return entry.promise;
const isFresh = entry.state.status === 'success'
&& Date.now() - entry.state.updatedAt < staleTime;
if (isFresh && !force) return Promise.resolve(entry.state.data);
if (force) cancelEntry(entry);
const controller = new AbortController();
entry.controller = controller;
updateState(entry, {
status: entry.state.data === undefined ? 'loading' : entry.state.status,
error: null,
isFetching: true,
});
const promise = Promise.resolve().then(() => queryFn({
signal: controller.signal,
queryKey: entry.queryKey,
}));
entry.promise = promise;
return promise.then((data) => {
if (entry.controller !== controller || controller.signal.aborted) return data;
entry.controller = null;
entry.promise = null;
updateState(entry, {
status: 'success',
data,
error: null,
updatedAt: Date.now(),
isFetching: false,
});
return data;
}).catch((error) => {
if (entry.controller !== controller || controller.signal.aborted || isAbortError(error)) {
return undefined;
}
entry.controller = null;
entry.promise = null;
updateState(entry, {
status: 'error',
error,
isFetching: false,
});
throw error;
});
};
const invalidateQueries = (queryKeyPrefix = []) => {
entries.forEach((entry) => {
if (!keyStartsWith(entry.queryKey, queryKeyPrefix)) return;
updateState(entry, {
updatedAt: 0,
revision: entry.state.revision + 1,
});
});
};
const cancelQueries = (queryKeyPrefix = []) => {
entries.forEach((entry) => {
if (keyStartsWith(entry.queryKey, queryKeyPrefix)) cancelEntry(entry);
});
};
const clear = () => {
entries.forEach(cancelEntry);
entries.clear();
};
return {
subscribe,
getSnapshot,
fetchQuery,
invalidateQueries,
cancelQueries,
clear,
};
}
export const queryClient = createQueryClient();