Unified · Split
| @@ -0,0 +1,238 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | ||
| 4 | import type { UpsertSourcePayload } from "@executor-v2/control-plane"; | ||
| 5 | import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | ||
| 6 | import { useState, type FormEvent } from "react"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | optimisticRemoveSources, | ||
| 10 | optimisticSourcesByWorkspace, | ||
| 11 | optimisticUpsertSources, | ||
| 12 | removeSource, | ||
| 13 | sourcesByWorkspace, | ||
| 14 | sourcesPendingByWorkspace, | ||
| 15 | sourcesResultByWorkspace, | ||
| 16 | upsertSource, | ||
| 17 | } from "../lib/control-plane/atoms"; | ||
| 18 | |||
| 19 | const kindOptions: ReadonlyArray<SourceKind> = [ | ||
| 20 | "openapi", | ||
| 21 | "mcp", | ||
| 22 | "graphql", | ||
| 23 | "internal", | ||
| 24 | ]; | ||
| 25 | |||
| 26 | const Page = () => { | ||
| 27 | const [workspaceIdInput, setWorkspaceIdInput] = useState("ws_demo"); | ||
| 28 | const [name, setName] = useState("Weather API"); | ||
| 29 | const [kind, setKind] = useState<SourceKind>("openapi"); | ||
| 30 | const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | ||
| 31 | const [statusText, setStatusText] = useState<string | null>(null); | ||
| 32 | |||
| 33 | const workspaceId = workspaceIdInput as WorkspaceId; | ||
| 34 | |||
| 35 | const sources = useAtomValue(sourcesByWorkspace(workspaceId)); | ||
| 36 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); | ||
| 37 | const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId)); | ||
| 38 | |||
| 39 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | ||
| 40 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | ||
| 41 | const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); | ||
| 42 | |||
| 43 | const handleWorkspaceChange = (value: string) => { | ||
| 44 | setWorkspaceIdInput(value); | ||
| 45 | setStatusText(null); | ||
| 46 | }; | ||
| 47 | |||
| 48 | const handleAddSource = (event: FormEvent<HTMLFormElement>) => { | ||
| 49 | event.preventDefault(); | ||
| 50 | |||
| 51 | if (sourcesPending) { | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | |||
| 55 | const sourceId = `src_${crypto.randomUUID()}` as SourceId; | ||
| 56 | |||
| 57 | const payload: UpsertSourcePayload = { | ||
| 58 | id: sourceId, | ||
| 59 | name, | ||
| 60 | kind, | ||
| 61 | endpoint, | ||
| 62 | enabled: true, | ||
| 63 | configJson: "{}", | ||
| 64 | status: "draft", | ||
| 65 | sourceHash: null, | ||
| 66 | lastError: null, | ||
| 67 | }; | ||
| 68 | |||
| 69 | const previousSources = sources.items; | ||
| 70 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); | ||
| 71 | |||
| 72 | setOptimisticSources({ | ||
| 73 | items: optimistic.items, | ||
| 74 | pendingAck: { | ||
| 75 | kind: "upsert", | ||
| 76 | sourceId: optimistic.sourceId, | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | |||
| 80 | void runUpsertSource({ | ||
| 81 | path: { workspaceId }, | ||
| 82 | payload, | ||
| 83 | }) | ||
| 84 | .then(() => { | ||
| 85 | setStatusText("Source saved."); | ||
| 86 | refreshSources(); | ||
| 87 | }) | ||
| 88 | .catch(() => { | ||
| 89 | setStatusText("Source save failed."); | ||
| 90 | setOptimisticSources(null); | ||
| 91 | refreshSources(); | ||
| 92 | }); | ||
| 93 | }; | ||
| 94 | |||
| 95 | const handleRemoveSource = (sourceId: SourceId) => { | ||
| 96 | if (sourcesPending) { | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | |||
| 100 | const previousSources = sources.items; | ||
| 101 | const optimistic = optimisticRemoveSources(previousSources, sourceId); | ||
| 102 | |||
| 103 | setOptimisticSources({ | ||
| 104 | items: optimistic.items, | ||
| 105 | pendingAck: { | ||
| 106 | kind: "remove", | ||
| 107 | sourceId: optimistic.sourceId, | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | void runRemoveSource({ | ||
| 112 | path: { workspaceId, sourceId }, | ||
| 113 | }) | ||
| 114 | .then(() => { | ||
| 115 | setStatusText("Source removed."); | ||
| 116 | refreshSources(); | ||
| 117 | }) | ||
| 118 | .catch(() => { | ||
| 119 | setStatusText("Source removal failed."); | ||
| 120 | setOptimisticSources(null); | ||
| 121 | refreshSources(); | ||
| 122 | }); | ||
| 123 | }; | ||
| 124 | |||
| 125 | return ( | ||
| 126 | <main> | ||
| 127 | <section className="shell"> | ||
| 128 | <header className="hero"> | ||
| 129 | <h1>Executor v2 Control Plane</h1> | ||
| 130 | <p> | ||
| 131 | Basic Next.js frontend wired to the shared Effect HttpApi client via | ||
| 132 | Effect Atom. | ||
| 133 | </p> | ||
| 134 | </header> | ||
| 135 | |||
| 136 | <div className="grid"> | ||
| 137 | <section className="card"> | ||
| 138 | <h2>Workspace + Source</h2> | ||
| 139 | <form onSubmit={handleAddSource}> | ||
| 140 | <div className="field"> | ||
| 141 | <label htmlFor="workspace-id">Workspace ID</label> | ||
| 142 | <input | ||
| 143 | id="workspace-id" | ||
| 144 | value={workspaceIdInput} | ||
| 145 | onChange={(event) => handleWorkspaceChange(event.target.value)} | ||
| 146 | required | ||
| 147 | /> | ||
| 148 | </div> | ||
| 149 | |||
| 150 | <div className="field"> | ||
| 151 | <label htmlFor="source-name">Name</label> | ||
| 152 | <input | ||
| 153 | id="source-name" | ||
| 154 | value={name} | ||
| 155 | onChange={(event) => setName(event.target.value)} | ||
| 156 | required | ||
| 157 | /> | ||
| 158 | </div> | ||
| 159 | |||
| 160 | <div className="field"> | ||
| 161 | <label htmlFor="source-kind">Kind</label> | ||
| 162 | <select | ||
| 163 | id="source-kind" | ||
| 164 | value={kind} | ||
| 165 | onChange={(event) => setKind(event.target.value as SourceKind)} | ||
| 166 | > | ||
| 167 | {kindOptions.map((option) => ( | ||
| 168 | <option key={option} value={option}> | ||
| 169 | {option} | ||
| 170 | </option> | ||
| 171 | ))} | ||
| 172 | </select> | ||
| 173 | </div> | ||
| 174 | |||
| 175 | <div className="field"> | ||
| 176 | <label htmlFor="source-endpoint">Endpoint</label> | ||
| 177 | <input | ||
| 178 | id="source-endpoint" | ||
| 179 | value={endpoint} | ||
| 180 | onChange={(event) => setEndpoint(event.target.value)} | ||
| 181 | required | ||
| 182 | /> | ||
| 183 | </div> | ||
| 184 | |||
| 185 | <button className="primary" type="submit" disabled={sourcesPending}> | ||
| 186 | {sourcesPending ? "Saving..." : "Save Source"} | ||
| 187 | </button> | ||
| 188 | </form> | ||
| 189 | |||
| 190 | {statusText ? <p className="status">{statusText}</p> : null} | ||
| 191 | </section> | ||
| 192 | |||
| 193 | <section className="card"> | ||
| 194 | <h2>Sources</h2> | ||
| 195 | |||
| 196 | {sources.state === "loading" ? ( | ||
| 197 | <p className="status">Loading sources...</p> | ||
| 198 | ) : null} | ||
| 199 | |||
| 200 | {sources.state === "error" ? ( | ||
| 201 | <p className="status error">{sources.message}</p> | ||
| 202 | ) : null} | ||
| 203 | |||
| 204 | {sources.state !== "loading" && sources.items.length === 0 ? ( | ||
| 205 | <p className="status">No sources yet in this workspace.</p> | ||
| 206 | ) : null} | ||
| 207 | |||
| 208 | <div className="list"> | ||
| 209 | {sources.items.map((source) => ( | ||
| 210 | <article className="list-item" key={source.id}> | ||
| 211 | <header> | ||
| 212 | <div> | ||
| 213 | <strong>{source.name}</strong> | ||
| 214 | <div className="meta"> | ||
| 215 | {source.kind} · {source.status} | ||
| 216 | </div> | ||
| 217 | <div className="meta">{source.endpoint}</div> | ||
| 218 | </div> | ||
| 219 | <button | ||
| 220 | className="danger" | ||
| 221 | type="button" | ||
| 222 | onClick={() => handleRemoveSource(source.id)} | ||
| 223 | disabled={sourcesPending} | ||
| 224 | > | ||
| 225 | Remove | ||
| 226 | </button> | ||
| 227 | </header> | ||
| 228 | </article> | ||
| 229 | ))} | ||
| 230 | </div> | ||
| 231 | </section> | ||
| 232 | </div> | ||
| 233 | </section> | ||
| 234 | </main> | ||
| 235 | ); | ||
| 236 | }; | ||
| 237 | |||
| 238 | export default Page; | ||
| @@ -1,210 +1,12 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | 3 | import dynamic from "next/dynamic"; |
| 4 | import type { UpsertSourcePayload } from "@executor-v2/control-plane"; | ||
| 5 | import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | ||
| 6 | import { useEffect, useState, useTransition, type FormEvent } from "react"; | ||
| 7 | 4 | ||
| 8 | import { | 5 | const ControlPlanePageContent = dynamic(() => import("./page-content"), { |
| 9 | removeSource, | 6 | ssr: false, |
| 10 | sourcesByWorkspace, | 7 | loading: () => null, |
| 11 | sourcesLoading, | 8 | }); |
| 12 | sourcesResultByWorkspace, | ||
| 13 | sourcesResultDisabled, | ||
| 14 | upsertSource, | ||
| 15 | } from "../lib/control-plane/atoms"; | ||
| 16 | 9 | ||
| 17 | const kindOptions: ReadonlyArray<SourceKind> = [ | 10 | const Page = () => <ControlPlanePageContent />; |
| 18 | "openapi", | ||
| 19 | "mcp", | ||
| 20 | "graphql", | ||
| 21 | "internal", | ||
| 22 | ]; | ||
| 23 | |||
| 24 | const Page = () => { | ||
| 25 | const [workspaceIdInput, setWorkspaceIdInput] = useState("ws_demo"); | ||
| 26 | const [name, setName] = useState("Weather API"); | ||
| 27 | const [kind, setKind] = useState<SourceKind>("openapi"); | ||
| 28 | const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | ||
| 29 | const [statusText, setStatusText] = useState<string | null>(null); | ||
| 30 | const [isPending, startTransition] = useTransition(); | ||
| 31 | const [isClient, setIsClient] = useState(false); | ||
| 32 | |||
| 33 | useEffect(() => { | ||
| 34 | setIsClient(true); | ||
| 35 | }, []); | ||
| 36 | |||
| 37 | const workspaceId = workspaceIdInput as WorkspaceId; | ||
| 38 | |||
| 39 | const sources = useAtomValue( | ||
| 40 | isClient ? sourcesByWorkspace(workspaceId) : sourcesLoading, | ||
| 41 | ); | ||
| 42 | const refreshSources = useAtomRefresh( | ||
| 43 | isClient ? sourcesResultByWorkspace(workspaceId) : sourcesResultDisabled, | ||
| 44 | ); | ||
| 45 | |||
| 46 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | ||
| 47 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | ||
| 48 | |||
| 49 | const handleAddSource = (event: FormEvent<HTMLFormElement>) => { | ||
| 50 | event.preventDefault(); | ||
| 51 | |||
| 52 | const payload: UpsertSourcePayload = { | ||
| 53 | name, | ||
| 54 | kind, | ||
| 55 | endpoint, | ||
| 56 | enabled: true, | ||
| 57 | configJson: "{}", | ||
| 58 | status: "draft", | ||
| 59 | sourceHash: null, | ||
| 60 | lastError: null, | ||
| 61 | }; | ||
| 62 | |||
| 63 | startTransition(() => { | ||
| 64 | void runUpsertSource({ | ||
| 65 | path: { workspaceId }, | ||
| 66 | payload, | ||
| 67 | }) | ||
| 68 | .then(() => { | ||
| 69 | setStatusText("Source saved."); | ||
| 70 | refreshSources(); | ||
| 71 | }) | ||
| 72 | .catch(() => { | ||
| 73 | setStatusText("Source save failed."); | ||
| 74 | }); | ||
| 75 | }); | ||
| 76 | }; | ||
| 77 | |||
| 78 | const handleRemoveSource = (sourceId: SourceId) => { | ||
| 79 | startTransition(() => { | ||
| 80 | void runRemoveSource({ | ||
| 81 | path: { workspaceId, sourceId }, | ||
| 82 | }) | ||
| 83 | .then(() => { | ||
| 84 | setStatusText("Source removed."); | ||
| 85 | refreshSources(); | ||
| 86 | }) | ||
| 87 | .catch(() => { | ||
| 88 | setStatusText("Source removal failed."); | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | }; | ||
| 92 | |||
| 93 | return ( | ||
| 94 | <main> | ||
| 95 | <section className="shell"> | ||
| 96 | <header className="hero"> | ||
| 97 | <h1>Executor v2 Control Plane</h1> | ||
| 98 | <p> | ||
| 99 | Basic Next.js frontend wired to the shared Effect HttpApi client via | ||
| 100 | Effect Atom. | ||
| 101 | </p> | ||
| 102 | </header> | ||
| 103 | |||
| 104 | <div className="grid"> | ||
| 105 | <section className="card"> | ||
| 106 | <h2>Workspace + Source</h2> | ||
| 107 | <form onSubmit={handleAddSource}> | ||
| 108 | <div className="field"> | ||
| 109 | <label htmlFor="workspace-id">Workspace ID</label> | ||
| 110 | <input | ||
| 111 | id="workspace-id" | ||
| 112 | value={workspaceIdInput} | ||
| 113 | onChange={(event) => setWorkspaceIdInput(event.target.value)} | ||
| 114 | required | ||
| 115 | /> | ||
| 116 | </div> | ||
| 117 | |||
| 118 | <div className="field"> | ||
| 119 | <label htmlFor="source-name">Name</label> | ||
| 120 | <input | ||
| 121 | id="source-name" | ||
| 122 | value={name} | ||
| 123 | onChange={(event) => setName(event.target.value)} | ||
| 124 | required | ||
| 125 | /> | ||
| 126 | </div> | ||
| 127 | |||
| 128 | <div className="field"> | ||
| 129 | <label htmlFor="source-kind">Kind</label> | ||
| 130 | <select | ||
| 131 | id="source-kind" | ||
| 132 | value={kind} | ||
| 133 | onChange={(event) => setKind(event.target.value as SourceKind)} | ||
| 134 | > | ||
| 135 | {kindOptions.map((option) => ( | ||
| 136 | <option key={option} value={option}> | ||
| 137 | {option} | ||
| 138 | </option> | ||
| 139 | ))} | ||
| 140 | </select> | ||
| 141 | </div> | ||
| 142 | |||
| 143 | <div className="field"> | ||
| 144 | <label htmlFor="source-endpoint">Endpoint</label> | ||
| 145 | <input | ||
| 146 | id="source-endpoint" | ||
| 147 | value={endpoint} | ||
| 148 | onChange={(event) => setEndpoint(event.target.value)} | ||
| 149 | required | ||
| 150 | /> | ||
| 151 | </div> | ||
| 152 | |||
| 153 | <button className="primary" type="submit" disabled={isPending}> | ||
| 154 | {isPending ? "Saving..." : "Save Source"} | ||
| 155 | </button> | ||
| 156 | </form> | ||
| 157 | |||
| 158 | {statusText ? <p className="status">{statusText}</p> : null} | ||
| 159 | </section> | ||
| 160 | |||
| 161 | <section className="card"> | ||
| 162 | <h2>Sources</h2> | ||
| 163 | |||
| 164 | {sources.state === "loading" ? ( | ||
| 165 | <p className="status">Loading sources...</p> | ||
| 166 | ) : null} | ||
| 167 | |||
| 168 | {sources.state === "refreshing" ? ( | ||
| 169 | <p className="status">Refreshing sources...</p> | ||
| 170 | ) : null} | ||
| 171 | |||
| 172 | {sources.state === "error" ? ( | ||
| 173 | <p className="status error">{sources.message}</p> | ||
| 174 | ) : null} | ||
| 175 | |||
| 176 | {sources.state !== "loading" && sources.items.length === 0 ? ( | ||
| 177 | <p className="status">No sources yet in this workspace.</p> | ||
| 178 | ) : null} | ||
| 179 | |||
| 180 | <div className="list"> | ||
| 181 | {sources.items.map((source) => ( | ||
| 182 | <article className="list-item" key={source.id}> | ||
| 183 | <header> | ||
| 184 | <div> | ||
| 185 | <strong>{source.name}</strong> | ||
| 186 | <div className="meta"> | ||
| 187 | {source.kind} · {source.status} | ||
| 188 | </div> | ||
| 189 | <div className="meta">{source.endpoint}</div> | ||
| 190 | </div> | ||
| 191 | <button | ||
| 192 | className="danger" | ||
| 193 | type="button" | ||
| 194 | onClick={() => handleRemoveSource(source.id)} | ||
| 195 | disabled={isPending} | ||
| 196 | > | ||
| 197 | Remove | ||
| 198 | </button> | ||
| 199 | </header> | ||
| 200 | </article> | ||
| 201 | ))} | ||
| 202 | </div> | ||
| 203 | </section> | ||
| 204 | </div> | ||
| 205 | </section> | ||
| 206 | </main> | ||
| 207 | ); | ||
| 208 | }; | ||
| 209 | 11 | ||
| 210 | export default Page; | 12 | export default Page; |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { Atom, Result } from "@effect-atom/atom"; | 1 | import { Atom, Result } from "@effect-atom/atom"; |
| 2 | import type { Source, WorkspaceId } from "@executor-v2/schema"; | 2 | import type { UpsertSourcePayload } from "@executor-v2/control-plane"; |
| 3 | import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema"; | ||
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 4 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 5 | 6 | ||
| @@ -9,9 +10,19 @@ type SourcesResult = Result.Result<ReadonlyArray<Source>, unknown>; · 3 unmodified lines | |||
| 9 | 10 | ||
| 10 | const emptySources: ReadonlyArray<Source> = []; | 11 | const emptySources: ReadonlyArray<Source> = []; |
| 11 | 12 | ||
| 12 | export const sourcesResultDisabled = Atom.make( | 13 | const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`; |
| 13 | Result.initial<ReadonlyArray<Source>, unknown>(), | 14 | |
| 14 | ); | 15 | const sortSources = (sources: ReadonlyArray<Source>): Array<Source> => |
| 16 | [...sources].sort((left, right) => { | ||
| 17 | const leftName = left.name.toLowerCase(); | ||
| 18 | const rightName = right.name.toLowerCase(); | ||
| 19 | |||
| 20 | if (leftName === rightName) { | ||
| 21 | return sourceStoreKey(left).localeCompare(sourceStoreKey(right)); | ||
| 22 | } | ||
| 23 | |||
| 24 | return leftName.localeCompare(rightName); | ||
| 25 | }); | ||
| 15 | 26 | ||
| 16 | export const sourcesResultByWorkspace = Atom.family( | 27 | export const sourcesResultByWorkspace = Atom.family( |
| 17 | (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> => | 28 | (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> => |
| @@ -22,6 +33,25 @@ export const sourcesResultByWorkspace = Atom.family( · 4 unmodified lines | |||
| 22 | }) as Atom.Atom<SourcesResult>, | 33 | }) as Atom.Atom<SourcesResult>, |
| 23 | ); | 34 | ); |
| 24 | 35 | ||
| 36 | type OptimisticPendingAck = | ||
| 37 | | { | ||
| 38 | kind: "upsert"; | ||
| 39 | sourceId: SourceId; | ||
| 40 | } | ||
| 41 | | { | ||
| 42 | kind: "remove"; | ||
| 43 | sourceId: SourceId; | ||
| 44 | }; | ||
| 45 | |||
| 46 | type OptimisticSources = { | ||
| 47 | items: ReadonlyArray<Source>; | ||
| 48 | pendingAck: OptimisticPendingAck; | ||
| 49 | }; | ||
| 50 | |||
| 51 | export const optimisticSourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 52 | Atom.make<OptimisticSources | null>(null), | ||
| 53 | ); | ||
| 54 | |||
| 25 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); | 55 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); |
| 26 | export const removeSource = controlPlaneClient.mutation("sources", "remove"); | 56 | export const removeSource = controlPlaneClient.mutation("sources", "remove"); |
| 27 | 57 | ||
| @@ -37,37 +67,131 @@ export type SourcesState = · 9 unmodified lines | |||
| 37 | message: string; | 67 | message: string; |
| 38 | } | 68 | } |
| 39 | | { | 69 | | { |
| 40 | state: "ready" | "refreshing"; | 70 | state: "ready"; |
| 41 | items: ReadonlyArray<Source>; | 71 | items: ReadonlyArray<Source>; |
| 42 | message: null; | 72 | message: null; |
| 43 | }; | 73 | }; |
| 44 | 74 | ||
| 45 | export const sourcesLoading = Atom.make<SourcesState>({ | 75 | const sourceStateFromResult = (result: SourcesResult): SourcesState => |
| 46 | state: "loading", | 76 | Result.match(result, { |
| 47 | items: emptySources, | 77 | onInitial: () => ({ |
| 48 | message: null, | 78 | state: "loading", |
| 79 | items: emptySources, | ||
| 80 | message: null, | ||
| 81 | }), | ||
| 82 | onFailure: (failure) => ({ | ||
| 83 | state: "error", | ||
| 84 | items: Option.getOrElse(Result.value(result), () => emptySources), | ||
| 85 | message: Cause.pretty(failure.cause), | ||
| 86 | }), | ||
| 87 | onSuccess: (success) => ({ | ||
| 88 | state: "ready", | ||
| 89 | items: success.value, | ||
| 90 | message: null, | ||
| 91 | }), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const isAcknowledged = ( | ||
| 95 | serverSources: ReadonlyArray<Source>, | ||
| 96 | pendingAck: OptimisticPendingAck, | ||
| 97 | ): boolean => { | ||
| 98 | const hasSource = serverSources.some((source) => source.id === pendingAck.sourceId); | ||
| 99 | return pendingAck.kind === "upsert" ? hasSource : !hasSource; | ||
| 100 | }; | ||
| 101 | |||
| 102 | export const optimisticUpsertSources = ( | ||
| 103 | currentSources: ReadonlyArray<Source>, | ||
| 104 | workspaceId: WorkspaceId, | ||
| 105 | payload: UpsertSourcePayload, | ||
| 106 | ): { | ||
| 107 | sourceId: SourceId; | ||
| 108 | items: ReadonlyArray<Source>; | ||
| 109 | } => { | ||
| 110 | const sourceId = payload.id as SourceId; | ||
| 111 | const existing = currentSources.find((source) => source.id === sourceId); | ||
| 112 | const now = Date.now(); | ||
| 113 | |||
| 114 | const source: Source = { | ||
| 115 | id: sourceId, | ||
| 116 | workspaceId, | ||
| 117 | name: payload.name, | ||
| 118 | kind: payload.kind, | ||
| 119 | endpoint: payload.endpoint, | ||
| 120 | status: payload.status ?? "draft", | ||
| 121 | enabled: payload.enabled ?? true, | ||
| 122 | configJson: payload.configJson ?? "{}", | ||
| 123 | sourceHash: payload.sourceHash ?? null, | ||
| 124 | lastError: payload.lastError ?? null, | ||
| 125 | createdAt: existing?.createdAt ?? now, | ||
| 126 | updatedAt: now, | ||
| 127 | }; | ||
| 128 | |||
| 129 | const nextSources = currentSources.filter((item) => item.id !== sourceId); | ||
| 130 | return { | ||
| 131 | sourceId, | ||
| 132 | items: sortSources([...nextSources, source]), | ||
| 133 | }; | ||
| 134 | }; | ||
| 135 | |||
| 136 | export const optimisticRemoveSources = ( | ||
| 137 | currentSources: ReadonlyArray<Source>, | ||
| 138 | sourceId: SourceId, | ||
| 139 | ): { | ||
| 140 | sourceId: SourceId; | ||
| 141 | items: ReadonlyArray<Source>; | ||
| 142 | } => ({ | ||
| 143 | sourceId, | ||
| 144 | items: currentSources.filter((source) => source.id !== sourceId), | ||
| 49 | }); | 145 | }); |
| 50 | 146 | ||
| 147 | export const sourcesPendingByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 148 | Atom.make((get): boolean => { | ||
| 149 | const optimistic = get(optimisticSourcesByWorkspace(workspaceId)); | ||
| 150 | |||
| 151 | if (optimistic === null) { | ||
| 152 | return false; | ||
| 153 | } | ||
| 154 | |||
| 155 | const serverResult = get(sourcesResultByWorkspace(workspaceId)); | ||
| 156 | |||
| 157 | return Result.match(serverResult, { | ||
| 158 | onInitial: () => true, | ||
| 159 | onFailure: () => true, | ||
| 160 | onSuccess: (success) => | ||
| 161 | !isAcknowledged(success.value, optimistic.pendingAck), | ||
| 162 | }); | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 51 | export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => | 166 | export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => |
| 52 | Atom.make((get): SourcesState => { | 167 | Atom.make((get): SourcesState => { |
| 53 | const result = get(sourcesResultByWorkspace(workspaceId)); | 168 | const serverResult = get(sourcesResultByWorkspace(workspaceId)); |
| 169 | const serverState = sourceStateFromResult(serverResult); | ||
| 170 | const optimistic = get(optimisticSourcesByWorkspace(workspaceId)); | ||
| 54 | 171 | ||
| 55 | return Result.match(result, { | 172 | if (optimistic === null) { |
| 173 | return serverState; | ||
| 174 | } | ||
| 175 | |||
| 176 | return Result.match(serverResult, { | ||
| 56 | onInitial: () => ({ | 177 | onInitial: () => ({ |
| 57 | state: "loading", | 178 | state: "ready", |
| 58 | items: emptySources, | 179 | items: optimistic.items, |
| 59 | message: null, | 180 | message: null, |
| 60 | }), | 181 | }), |
| 61 | onFailure: (failure) => ({ | 182 | onFailure: () => ({ |
| 62 | state: "error", | 183 | state: "ready", |
| 63 | items: Option.getOrElse(Result.value(result), () => emptySources), | 184 | items: optimistic.items, |
| 64 | message: Cause.pretty(failure.cause), | ||
| 65 | }), | ||
| 66 | onSuccess: (success) => ({ | ||
| 67 | state: success.waiting ? "refreshing" : "ready", | ||
| 68 | items: success.value, | ||
| 69 | message: null, | 185 | message: null, |
| 70 | }), | 186 | }), |
| 187 | onSuccess: (success) => | ||
| 188 | isAcknowledged(success.value, optimistic.pendingAck) | ||
| 189 | ? serverState | ||
| 190 | : { | ||
| 191 | state: "ready", | ||
| 192 | items: optimistic.items, | ||
| 193 | message: null, | ||
| 194 | }, | ||
| 71 | }); | 195 | }); |
| 72 | }), | 196 | }), |
| 73 | ); | 197 | ); |
| @@ -1,5 +1,12 @@ | |||
| 1 | import { createControlPlaneAtomClient } from "@executor-v2/control-plane"; | 1 | import { createControlPlaneAtomClient } from "@executor-v2/control-plane"; |
| 2 | 2 | ||
| 3 | const controlPlaneBaseUrl = | ||
| 4 | typeof window === "undefined" | ||
| 5 | ? process.env.CONTROL_PLANE_SERVER_BASE_URL ?? | ||
| 6 | process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? | ||
| 7 | "http://127.0.0.1:3000/api/control-plane" | ||
| 8 | : process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane"; | ||
| 9 | |||
| 3 | export const controlPlaneClient = createControlPlaneAtomClient({ | 10 | export const controlPlaneClient = createControlPlaneAtomClient({ |
| 4 | baseUrl: process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane", | 11 | baseUrl: controlPlaneBaseUrl, |
| 5 | }); | 12 | }); |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | |||
| 6 | "dev": "next dev --port 3000", | 6 | "dev": "next dev --port 3000", |
| 7 | "build": "next build", | 7 | "build": "next build", |
| 8 | "start": "next start --port 3000", | 8 | "start": "next start --port 3000", |
| 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 9 | "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json" |
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@effect-atom/atom": "^0.4.11", | 12 | "@effect-atom/atom": "^0.4.11", |
| @@ -7,7 +7,6 @@ · 6 unmodified lines | |||
| 7 | "esnext" | 7 | "esnext" |
| 8 | ], | 8 | ], |
| 9 | "jsx": "preserve", | 9 | "jsx": "preserve", |
| 10 | "incremental": true, | ||
| 11 | "plugins": [ | 10 | "plugins": [ |
| 12 | { | 11 | { |
| 13 | "name": "next" | 12 | "name": "next" |
| @@ -20,12 +19,13 @@ · 6 unmodified lines | |||
| 20 | "node" | 19 | "node" |
| 21 | ], | 20 | ], |
| 22 | "allowJs": true, | 21 | "allowJs": true, |
| 23 | "esModuleInterop": true | 22 | "esModuleInterop": true, |
| 23 | "incremental": true | ||
| 24 | }, | 24 | }, |
| 25 | "include": [ | 25 | "include": [ |
| 26 | "next-env.d.ts", | ||
| 27 | "**/*.ts", | 26 | "**/*.ts", |
| 28 | "**/*.tsx", | 27 | "**/*.tsx", |
| 28 | "next-env.d.ts", | ||
| 29 | ".next/types/**/*.ts" | 29 | ".next/types/**/*.ts" |
| 30 | ], | 30 | ], |
| 31 | "exclude": [ | 31 | "exclude": [ |