@omercnet/paseo-gas-city 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useMemo, useState } from "react";
5
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
6
+ import {
7
+ discoverSupervisor,
8
+ type GasCitySettings,
9
+ gasCitySettings,
10
+ toGasCityRpcSettings,
11
+ } from "../shared";
12
+ import { CityOperations } from "./city-operations";
13
+ import { selectAvailableCity } from "./view-model";
14
+
15
+ function errorMessage(error: unknown): string {
16
+ return error instanceof Error ? error.message : "An unexpected error occurred.";
17
+ }
18
+
19
+ export function GasCitySurface(props: PluginSurfaceProps) {
20
+ const settings = useSettings(gasCitySettings);
21
+ if (settings.status === "loading") {
22
+ return (
23
+ <SurfaceState
24
+ {...props}
25
+ title="Loading Gas City"
26
+ body="Reading persisted connection settings."
27
+ loading
28
+ />
29
+ );
30
+ }
31
+ if (settings.status !== "ready") {
32
+ return (
33
+ <SurfaceState
34
+ {...props}
35
+ title="Gas City settings unavailable"
36
+ body={settings.error}
37
+ onRetry={() => void settings.reload()}
38
+ />
39
+ );
40
+ }
41
+ return <ReadyGasCitySurface key={settings.revision} {...props} settings={settings.values} />;
42
+ }
43
+
44
+ function ReadyGasCitySurface({
45
+ theme,
46
+ layout,
47
+ host,
48
+ navigation,
49
+ settings,
50
+ }: PluginSurfaceProps & { settings: GasCitySettings }) {
51
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
52
+ const rpcSettings = useMemo(() => toGasCityRpcSettings(settings), [settings]);
53
+ const loadDiscovery = useRpc(discoverSupervisor);
54
+ const discovery = useQuery({
55
+ queryKey: ["gas-city", host.id, settings.endpointUrl, "discovery"],
56
+ queryFn: () => loadDiscovery({ settings: rpcSettings }),
57
+ refetchInterval: settings.refreshIntervalMs,
58
+ });
59
+ const retryDiscovery = () => void discovery.refetch();
60
+ const [preferredCity, setPreferredCity] = useState<string | null>(null);
61
+
62
+ if (discovery.isPending && !discovery.data) {
63
+ return (
64
+ <SurfaceState
65
+ theme={theme}
66
+ layout={layout}
67
+ host={host}
68
+ navigation={navigation}
69
+ title="Finding Gas City"
70
+ body="Contacting the configured supervisor."
71
+ loading
72
+ />
73
+ );
74
+ }
75
+ if (discovery.error && !discovery.data) {
76
+ return (
77
+ <SurfaceState
78
+ theme={theme}
79
+ layout={layout}
80
+ host={host}
81
+ navigation={navigation}
82
+ title="Could not reach Gas City"
83
+ body={errorMessage(discovery.error)}
84
+ onRetry={retryDiscovery}
85
+ />
86
+ );
87
+ }
88
+ if (!discovery.data) {
89
+ return (
90
+ <SurfaceState
91
+ theme={theme}
92
+ layout={layout}
93
+ host={host}
94
+ navigation={navigation}
95
+ title="No supervisor data"
96
+ body="The supervisor returned no usable discovery response."
97
+ onRetry={retryDiscovery}
98
+ />
99
+ );
100
+ }
101
+
102
+ const data = discovery.data;
103
+ const selectedCity = selectAvailableCity(preferredCity, data.cities);
104
+ if (data.state !== "available" || !data.supervisor) {
105
+ const diagnostic = data.diagnostics.map((item) => item.message).join(" ");
106
+ return (
107
+ <SurfaceState
108
+ theme={theme}
109
+ layout={layout}
110
+ host={host}
111
+ navigation={navigation}
112
+ title={
113
+ data.state === "not-configured" ? "Gas City is not configured" : "Gas City is unavailable"
114
+ }
115
+ body={
116
+ diagnostic || `Supervisor state: ${data.state}. Check the endpoint in Gas City settings.`
117
+ }
118
+ onRetry={retryDiscovery}
119
+ />
120
+ );
121
+ }
122
+
123
+ return (
124
+ <View style={styles.screen}>
125
+ <View style={styles.supervisorBar}>
126
+ <View style={styles.supervisorTitleBlock}>
127
+ <View style={styles.supervisorTitleRow}>
128
+ <View style={[styles.statusDot, { backgroundColor: theme.colors.statusSuccess }]} />
129
+ <Text accessibilityRole="header" style={styles.supervisorTitle}>
130
+ Supervisor
131
+ </Text>
132
+ </View>
133
+ <Text style={styles.supervisorMeta} numberOfLines={1}>
134
+ {data.supervisor.endpointUrl} · {data.supervisor.runningCityCount}/
135
+ {data.supervisor.cityCount} cities running
136
+ </Text>
137
+ </View>
138
+ <View style={styles.versionBadge}>
139
+ <Text style={styles.versionText}>{data.supervisor.version ?? "version unknown"}</Text>
140
+ </View>
141
+ </View>
142
+ {data.diagnostics.length > 0 ? (
143
+ <View accessibilityRole="alert" style={styles.discoveryDiagnostics}>
144
+ <Icon name="TriangleAlert" size={14} color={theme.colors.statusWarning} />
145
+ <Text style={styles.discoveryDiagnosticText} numberOfLines={2}>
146
+ {data.diagnostics.map((diagnostic) => diagnostic.message).join(" · ")}
147
+ </Text>
148
+ </View>
149
+ ) : null}
150
+ {data.cities.length > 0 ? (
151
+ <ScrollView
152
+ horizontal
153
+ style={styles.cityRailScroller}
154
+ showsHorizontalScrollIndicator={false}
155
+ contentContainerStyle={styles.cityRail}
156
+ >
157
+ {data.cities.map((city) => {
158
+ const selected = selectedCity === city.name;
159
+ return (
160
+ <Pressable
161
+ key={`${city.path}:${city.name}`}
162
+ accessibilityRole="button"
163
+ accessibilityLabel={`Show ${city.name} city operations`}
164
+ accessibilityState={{ selected }}
165
+ onPress={() => setPreferredCity(city.name)}
166
+ style={({ pressed }) => [
167
+ styles.cityChip,
168
+ selected && styles.cityChipSelected,
169
+ pressed && styles.pressed,
170
+ ]}
171
+ >
172
+ <View
173
+ style={[
174
+ styles.cityDot,
175
+ {
176
+ backgroundColor: city.running
177
+ ? theme.colors.statusSuccess
178
+ : theme.colors.statusWarning,
179
+ },
180
+ ]}
181
+ />
182
+ <Text style={[styles.cityChipText, selected && styles.cityChipTextSelected]}>
183
+ {city.name}
184
+ </Text>
185
+ </Pressable>
186
+ );
187
+ })}
188
+ </ScrollView>
189
+ ) : null}
190
+ <View style={styles.body}>
191
+ {selectedCity ? (
192
+ <CityOperations
193
+ key={`${settings.endpointUrl}:${selectedCity}`}
194
+ theme={theme}
195
+ layout={layout}
196
+ host={host}
197
+ cityName={selectedCity}
198
+ rigName={null}
199
+ settings={settings}
200
+ />
201
+ ) : (
202
+ <SurfaceState
203
+ theme={theme}
204
+ layout={layout}
205
+ host={host}
206
+ navigation={navigation}
207
+ title="No cities discovered"
208
+ body="The supervisor is available but has not reported a city."
209
+ onRetry={retryDiscovery}
210
+ />
211
+ )}
212
+ </View>
213
+ </View>
214
+ );
215
+ }
216
+
217
+ function SurfaceState({
218
+ theme,
219
+ title,
220
+ body,
221
+ loading,
222
+ onRetry,
223
+ }: PluginSurfaceProps & { title: string; body: string; loading?: boolean; onRetry?: () => void }) {
224
+ const styles = useMemo(() => createStyles(theme, true), [theme]);
225
+ return (
226
+ <View style={styles.stateScreen}>
227
+ {loading ? (
228
+ <ActivityIndicator color={theme.colors.accent} />
229
+ ) : (
230
+ <Icon name="Factory" size={28} color={theme.colors.foregroundMuted} />
231
+ )}
232
+ <Text accessibilityRole="header" style={styles.stateTitle}>
233
+ {title}
234
+ </Text>
235
+ <Text style={styles.stateBody}>{body}</Text>
236
+ {onRetry ? (
237
+ <Pressable
238
+ accessibilityRole="button"
239
+ accessibilityLabel="Retry Gas City"
240
+ onPress={onRetry}
241
+ style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
242
+ >
243
+ <Text style={styles.retryText}>Retry</Text>
244
+ </Pressable>
245
+ ) : null}
246
+ </View>
247
+ );
248
+ }
249
+
250
+ function createStyles(theme: PluginSurfaceProps["theme"], compact: boolean) {
251
+ return StyleSheet.create({
252
+ screen: { flex: 1, backgroundColor: theme.colors.surface0 },
253
+ supervisorBar: {
254
+ minHeight: 56,
255
+ flexDirection: "row",
256
+ alignItems: "center",
257
+ justifyContent: "space-between",
258
+ gap: 10,
259
+ paddingHorizontal: compact ? 12 : 18,
260
+ borderBottomWidth: StyleSheet.hairlineWidth,
261
+ borderBottomColor: theme.colors.border,
262
+ backgroundColor: theme.colors.surface0,
263
+ },
264
+ supervisorTitleBlock: { flex: 1, minWidth: 0, gap: 3 },
265
+ supervisorTitleRow: { flexDirection: "row", alignItems: "center", gap: 7 },
266
+ statusDot: { width: 8, height: 8, borderRadius: 4 },
267
+ supervisorTitle: { color: theme.colors.foreground, fontSize: 13, fontWeight: "800" },
268
+ supervisorMeta: { color: theme.colors.foregroundMuted, fontSize: 10 },
269
+ versionBadge: {
270
+ paddingHorizontal: 8,
271
+ paddingVertical: 5,
272
+ borderRadius: 6,
273
+ borderWidth: StyleSheet.hairlineWidth,
274
+ borderColor: theme.colors.border,
275
+ backgroundColor: theme.colors.surface0,
276
+ },
277
+ versionText: { color: theme.colors.foregroundMuted, fontSize: 10, fontWeight: "600" },
278
+ discoveryDiagnostics: {
279
+ flexDirection: "row",
280
+ alignItems: "center",
281
+ gap: 7,
282
+ paddingHorizontal: compact ? 12 : 18,
283
+ paddingVertical: 8,
284
+ borderBottomWidth: StyleSheet.hairlineWidth,
285
+ borderBottomColor: theme.colors.border,
286
+ },
287
+ discoveryDiagnosticText: { flex: 1, color: theme.colors.statusWarning, fontSize: 10 },
288
+ cityRailScroller: { flexGrow: 0 },
289
+ cityRail: {
290
+ gap: 6,
291
+ paddingHorizontal: compact ? 12 : 18,
292
+ paddingVertical: 9,
293
+ borderBottomWidth: StyleSheet.hairlineWidth,
294
+ borderBottomColor: theme.colors.border,
295
+ },
296
+ cityChip: {
297
+ minHeight: 32,
298
+ flexDirection: "row",
299
+ alignItems: "center",
300
+ gap: 6,
301
+ paddingHorizontal: 9,
302
+ borderRadius: 6,
303
+ borderWidth: StyleSheet.hairlineWidth,
304
+ borderColor: theme.colors.border,
305
+ backgroundColor: theme.colors.surface0,
306
+ },
307
+ cityChipSelected: { borderColor: theme.colors.accent, backgroundColor: theme.colors.surface0 },
308
+ cityDot: { width: 6, height: 6, borderRadius: 3 },
309
+ cityChipText: { color: theme.colors.foregroundMuted, fontSize: 11, fontWeight: "600" },
310
+ cityChipTextSelected: { color: theme.colors.foreground },
311
+ body: { flex: 1 },
312
+ stateScreen: {
313
+ flex: 1,
314
+ alignItems: "center",
315
+ justifyContent: "center",
316
+ gap: 9,
317
+ padding: 24,
318
+ backgroundColor: theme.colors.surface0,
319
+ },
320
+ stateTitle: {
321
+ color: theme.colors.foreground,
322
+ fontSize: 17,
323
+ fontWeight: "800",
324
+ textAlign: "center",
325
+ },
326
+ stateBody: {
327
+ maxWidth: 480,
328
+ color: theme.colors.foregroundMuted,
329
+ fontSize: 12,
330
+ lineHeight: 18,
331
+ textAlign: "center",
332
+ },
333
+ retryButton: {
334
+ minHeight: 36,
335
+ justifyContent: "center",
336
+ paddingHorizontal: 13,
337
+ borderRadius: 7,
338
+ backgroundColor: theme.colors.accent,
339
+ },
340
+ retryText: { color: theme.colors.accentForeground, fontSize: 12, fontWeight: "700" },
341
+ pressed: { opacity: 0.72 },
342
+ });
343
+ }
@@ -0,0 +1,286 @@
1
+ import { type PluginSurfaceProps, type SettingsState, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsInput,
6
+ SettingsRow,
7
+ SettingsSection,
8
+ SettingsSelect,
9
+ SettingsSwitch,
10
+ } from "@getpaseo/plugin/client/ui";
11
+ import { useCallback, useMemo, useState } from "react";
12
+ import { Text, View } from "react-native";
13
+ import { endpointUrlSchema, type GasCitySettings, gasCitySettings } from "../shared";
14
+
15
+ type ReadySettings = Extract<SettingsState<typeof gasCitySettings.schema>, { status: "ready" }>;
16
+
17
+ const refreshOptions = [
18
+ { label: "2 seconds", value: "2000" },
19
+ { label: "5 seconds", value: "5000" },
20
+ { label: "10 seconds", value: "10000" },
21
+ { label: "30 seconds", value: "30000" },
22
+ { label: "60 seconds", value: "60000" },
23
+ ] as const;
24
+ const eventLimitOptions = [25, 50, 100, 250, 500].map((value) => ({
25
+ label: `${value} events`,
26
+ value: String(value),
27
+ }));
28
+
29
+ function ReadySettingsScreen({
30
+ settings,
31
+ theme,
32
+ }: {
33
+ settings: ReadySettings;
34
+ theme: PluginSurfaceProps["theme"];
35
+ }) {
36
+ const [endpointUrl, setEndpointUrl] = useState(settings.values.endpointUrl);
37
+ const [workspaceId, setWorkspaceId] = useState("");
38
+ const [cityName, setCityName] = useState("");
39
+ const [rigName, setRigName] = useState("");
40
+ const [mappingError, setMappingError] = useState<string | null>(null);
41
+ const styles = useMemo(
42
+ () => ({
43
+ root: { gap: 16 },
44
+ danger: { color: theme.colors.statusDanger },
45
+ muted: { color: theme.colors.foregroundMuted },
46
+ value: { color: theme.colors.foreground },
47
+ warning: { color: theme.colors.statusWarning },
48
+ }),
49
+ [theme],
50
+ );
51
+ const save = useCallback(
52
+ (patch: Partial<GasCitySettings>) =>
53
+ settings.save({ ...settings.values, ...patch }, settings.revision),
54
+ [settings],
55
+ );
56
+ const normalizedEndpoint = endpointUrl.trim();
57
+ const endpointResult = endpointUrlSchema.safeParse(normalizedEndpoint);
58
+ const endpointError = endpointResult.success
59
+ ? null
60
+ : (endpointResult.error.issues[0]?.message ?? "Enter a valid Gas City endpoint.");
61
+
62
+ async function saveEndpoint() {
63
+ if (!endpointResult.success) return;
64
+ await save({ endpointUrl: endpointResult.data });
65
+ }
66
+
67
+ async function addMapping() {
68
+ const normalized = {
69
+ workspaceId: workspaceId.trim(),
70
+ cityName: cityName.trim(),
71
+ rigName: rigName.trim(),
72
+ };
73
+ if (!normalized.workspaceId || !normalized.cityName || !normalized.rigName) {
74
+ setMappingError("Workspace ID, city, and rig are required.");
75
+ return;
76
+ }
77
+ if (
78
+ settings.values.workspaceMappings.some(
79
+ (mapping) => mapping.workspaceId === normalized.workspaceId,
80
+ )
81
+ ) {
82
+ setMappingError(`A mapping already exists for ${normalized.workspaceId}.`);
83
+ return;
84
+ }
85
+ const saved = await save({
86
+ workspaceMappings: [...settings.values.workspaceMappings, normalized],
87
+ });
88
+ if (saved) {
89
+ setWorkspaceId("");
90
+ setCityName("");
91
+ setRigName("");
92
+ setMappingError(null);
93
+ }
94
+ }
95
+
96
+ return (
97
+ <View style={styles.root}>
98
+ <SettingsSection
99
+ title="Connection"
100
+ info="The endpoint is stored on the Paseo host and shared by connected clients."
101
+ >
102
+ <SettingsCard>
103
+ <SettingsInput
104
+ label="Supervisor endpoint"
105
+ hint="HTTP or HTTPS URL for the Gas City supervisor"
106
+ error={endpointUrl === settings.values.endpointUrl ? null : endpointError}
107
+ initialValue={endpointUrl}
108
+ onChangeText={setEndpointUrl}
109
+ placeholder="http://127.0.0.1:7375"
110
+ disabled={settings.saving}
111
+ />
112
+ <SettingsAction
113
+ label="Save endpoint"
114
+ actionLabel="Save"
115
+ disabled={
116
+ settings.saving ||
117
+ normalizedEndpoint === settings.values.endpointUrl ||
118
+ !endpointResult.success
119
+ }
120
+ onPress={() => void saveEndpoint()}
121
+ />
122
+ <SettingsSwitch
123
+ label="Allow remote endpoint"
124
+ hint="Permit connections beyond localhost. Enable only for a trusted network."
125
+ value={settings.values.allowRemoteEndpoint}
126
+ disabled={settings.saving}
127
+ onValueChange={(allowRemoteEndpoint) => void save({ allowRemoteEndpoint })}
128
+ />
129
+ </SettingsCard>
130
+ </SettingsSection>
131
+
132
+ <SettingsSection title="Operator controls">
133
+ <SettingsCard>
134
+ <SettingsSwitch
135
+ label="Enable mutations"
136
+ hint="Interactive safety interlock for confirmed actions; not an authorization boundary."
137
+ value={settings.values.mutationsEnabled}
138
+ disabled={settings.saving}
139
+ onValueChange={(mutationsEnabled) => void save({ mutationsEnabled })}
140
+ />
141
+ <SettingsSelect
142
+ label="Refresh interval"
143
+ value={String(settings.values.refreshIntervalMs)}
144
+ options={refreshOptions}
145
+ disabled={settings.saving}
146
+ onValueChange={(value) => void save({ refreshIntervalMs: Number(value) })}
147
+ />
148
+ <SettingsSelect
149
+ label="Recent event limit"
150
+ value={String(settings.values.eventLimit)}
151
+ options={eventLimitOptions}
152
+ disabled={settings.saving}
153
+ onValueChange={(value) => void save({ eventLimit: Number(value) })}
154
+ />
155
+ </SettingsCard>
156
+ {!settings.values.mutationsEnabled ? (
157
+ <Text style={styles.muted}>Observe-only mode is active.</Text>
158
+ ) : (
159
+ <Text accessibilityRole="alert" style={styles.warning}>
160
+ Mutations are enabled as an interactive safety interlock. Every operation still requires
161
+ confirmation.
162
+ </Text>
163
+ )}
164
+ </SettingsSection>
165
+
166
+ <SettingsSection
167
+ title="Workspace mappings"
168
+ info="Explicit mappings take precedence over longest-ancestor discovery."
169
+ >
170
+ <SettingsCard>
171
+ {settings.values.workspaceMappings.map((mapping) => (
172
+ <SettingsAction
173
+ key={mapping.workspaceId}
174
+ label={mapping.workspaceId}
175
+ hint={`${mapping.cityName} / ${mapping.rigName}`}
176
+ actionLabel="Remove"
177
+ disabled={settings.saving}
178
+ onPress={() =>
179
+ void save({
180
+ workspaceMappings: settings.values.workspaceMappings.filter(
181
+ (candidate) => candidate.workspaceId !== mapping.workspaceId,
182
+ ),
183
+ })
184
+ }
185
+ />
186
+ ))}
187
+ {settings.values.workspaceMappings.length === 0 ? (
188
+ <SettingsRow label="No overrides" hint="Automatic longest-ancestor mapping is used." />
189
+ ) : null}
190
+ <SettingsInput
191
+ label="Workspace ID"
192
+ initialValue={workspaceId}
193
+ onChangeText={setWorkspaceId}
194
+ placeholder="workspace-id"
195
+ disabled={settings.saving}
196
+ />
197
+ <SettingsInput
198
+ label="City"
199
+ initialValue={cityName}
200
+ onChangeText={setCityName}
201
+ placeholder="city-name"
202
+ disabled={settings.saving}
203
+ />
204
+ <SettingsInput
205
+ label="Rig"
206
+ initialValue={rigName}
207
+ onChangeText={setRigName}
208
+ placeholder="rig-name"
209
+ disabled={settings.saving}
210
+ />
211
+ <SettingsAction
212
+ label="Add explicit mapping"
213
+ error={mappingError}
214
+ actionLabel="Add"
215
+ disabled={settings.saving}
216
+ onPress={() => void addMapping()}
217
+ />
218
+ </SettingsCard>
219
+ </SettingsSection>
220
+
221
+ <SettingsSection title="Persistence">
222
+ <SettingsCard>
223
+ <SettingsRow label="Status" hint="Saved on the Paseo host">
224
+ <Text style={styles.value}>{settings.saving ? "Saving…" : "Saved"}</Text>
225
+ </SettingsRow>
226
+ <SettingsAction
227
+ label="Restore safe defaults"
228
+ hint="Returns Gas City to localhost observe-only mode"
229
+ actionLabel="Reset"
230
+ disabled={settings.saving}
231
+ onPress={() => void settings.reset()}
232
+ />
233
+ {settings.saveError ? (
234
+ <SettingsAction
235
+ label="Reload persisted values"
236
+ error={settings.saveError}
237
+ actionLabel="Reload"
238
+ disabled={settings.saving}
239
+ onPress={settings.reload}
240
+ />
241
+ ) : null}
242
+ </SettingsCard>
243
+ </SettingsSection>
244
+ </View>
245
+ );
246
+ }
247
+
248
+ export function GasCitySettingsScreen({ theme }: PluginSurfaceProps) {
249
+ const settings = useSettings(gasCitySettings);
250
+ const styles = useMemo(
251
+ () => ({
252
+ text: { color: theme.colors.foreground },
253
+ error: { color: theme.colors.statusDanger },
254
+ }),
255
+ [theme],
256
+ );
257
+
258
+ if (settings.status === "loading")
259
+ return <Text style={styles.text}>Loading Gas City settings…</Text>;
260
+ if (settings.status !== "ready") {
261
+ return (
262
+ <SettingsSection title="Gas City settings">
263
+ <Text accessibilityRole="alert" style={styles.error}>
264
+ {settings.error}
265
+ </Text>
266
+ <SettingsCard>
267
+ <SettingsAction
268
+ label="Read settings again"
269
+ actionLabel="Reload"
270
+ onPress={settings.reload}
271
+ />
272
+ {settings.status === "invalid" ? (
273
+ <SettingsAction
274
+ label="Replace invalid data with safe defaults"
275
+ actionLabel="Reset"
276
+ disabled={settings.saving}
277
+ onPress={() => void settings.reset()}
278
+ />
279
+ ) : null}
280
+ </SettingsCard>
281
+ </SettingsSection>
282
+ );
283
+ }
284
+
285
+ return <ReadySettingsScreen key={settings.revision} settings={settings} theme={theme} />;
286
+ }