mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
feat(frontend): add workspace-aware server state
This commit is contained in:
1 parent
899dac99b5
commit
b5c6efb3c3
5 files changed
+366
-20
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user