ldelalande+alexhancock/shared-config-client (#1219)

Co-authored-by: Lily Delalande <ldelalande@squareup.com>
This commit is contained in:
Alex Hancock
2025-02-13 13:50:02 -05:00
committed by GitHub
parent 4ed0a3fac3
commit 3ef552d7bf
20 changed files with 3981 additions and 1117 deletions

View File

@@ -9,6 +9,8 @@ release-binary:
@echo "Building release version..."
cargo build --release
@just copy-binary
@echo "Generating OpenAPI schema..."
cargo run -p goose-server --bin generate_schema
# Build Windows executable
release-windows:

View File

@@ -1,4 +1,5 @@
node_modules
dist
out
src/api/generated
*.lock

View File

@@ -37,6 +37,7 @@ const noWindowLocationHref = {
module.exports = [
js.configs.recommended,
{
ignores: ['src/api/generated/**'],
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,

View File

@@ -0,0 +1,7 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: './openapi.json',
output: './src/api/generated',
plugins: ['@hey-api/client-fetch'],
});

264
ui/desktop/openapi.json Normal file
View File

@@ -0,0 +1,264 @@
{
"openapi": "3.0.3",
"info": {
"title": "goose-server",
"description": "An AI agent",
"contact": {
"name": "Block",
"email": "ai-oss-tools@block.xyz"
},
"license": {
"name": "Apache-2.0"
},
"version": "1.0.5"
},
"paths": {
"/config": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "read_all_config",
"responses": {
"200": {
"description": "All configuration values retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigResponse"
}
}
}
}
}
}
},
"/config/extension": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "add_extension",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExtensionQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Extension added successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"super::routes::config_management"
],
"operationId": "remove_extension",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigKeyQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Extension removed successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Extension not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/config/read": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "read_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigKeyQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Configuration value retrieved successfully",
"content": {
"application/json": {
"schema": {}
}
}
},
"404": {
"description": "Configuration key not found"
}
}
}
},
"/config/remove": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "remove_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigKeyQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Configuration value removed successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Configuration key not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/config/upsert": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "upsert_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertConfigQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Configuration value upserted successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
}
}
},
"components": {
"schemas": {
"ConfigKeyQuery": {
"type": "object",
"required": [
"key"
],
"properties": {
"key": {
"type": "string"
}
}
},
"ConfigResponse": {
"type": "object",
"required": [
"config"
],
"properties": {
"config": {
"type": "object",
"additionalProperties": {}
}
}
},
"ExtensionQuery": {
"type": "object",
"required": [
"name",
"config"
],
"properties": {
"config": {},
"name": {
"type": "string"
}
}
},
"UpsertConfigQuery": {
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"is_secret": {
"type": "boolean",
"nullable": true
},
"key": {
"type": "string"
},
"value": {}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"description": "Goose App",
"main": ".vite/build/main.js",
"scripts": {
"start-gui": "electron-forge start",
"generate-api": "openapi-ts",
"start-gui": "npm run generate-api && electron-forge start",
"start": "cd ../.. && just run-ui",
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
"package": "electron-forge package",
@@ -31,6 +32,7 @@
"@electron-forge/plugin-vite": "^7.5.0",
"@electron/fuses": "^1.8.0",
"@eslint/js": "^8.56.0",
"@hey-api/openapi-ts": "^0.64.4",
"@tailwindcss/typography": "^0.5.15",
"@types/cors": "^2.8.17",
"@types/electron-squirrel-startup": "^1.0.2",
@@ -64,6 +66,7 @@
"dependencies": {
"@ai-sdk/openai": "^0.0.72",
"@ai-sdk/ui-utils": "^1.0.2",
"@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.4",

View File

@@ -24,6 +24,7 @@ import Splash from './components/Splash';
import Settings from './components/settings/Settings';
import MoreModelsSettings from './components/settings/models/MoreModels';
import ConfigureProviders from './components/settings/providers/ConfigureProviders';
import { ConfigPage } from './components/pages/ConfigPage';
export interface Chat {
id: number;
@@ -35,7 +36,13 @@ export interface Chat {
}>;
}
export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders';
export type View =
| 'welcome'
| 'chat'
| 'settings'
| 'moreModels'
| 'configureProviders'
| 'configPage';
// This component is our main chat content.
// We'll move the majority of chat logic here, minus the 'view' state.
@@ -388,6 +395,14 @@ export default function ChatWindow() {
setView={setView}
/>
)}
{view === 'configPage' && (
<ConfigPage
onClose={() => {
setView('chat');
}}
setView={setView}
/>
)}
{view === 'configureProviders' && (
<ConfigureProviders
onClose={() => {

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { Button } from './components/ui/button';
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { Card } from './components/ui/card';
import { useNavigate } from 'react-router-dom';
import BackButton from './components/ui/BackButton';
import { useConfig } from './hooks/useConfig';
export default function ConfigPage() {
const [configs, setConfigs] = useState<Record<string, any>>({});
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const navigate = useNavigate();
const { loading, error, loadConfigs, addConfig, removeConfig } = useConfig();
// Fetch all configs on component mount
useEffect(() => {
const fetchConfigs = async () => {
const result = await loadConfigs();
setConfigs(result);
};
fetchConfigs();
}, [loadConfigs]);
const handleAddConfig = async () => {
if (!newKey || !newValue) {
return;
}
let parsedValue = newValue;
// Try to parse as JSON if it looks like JSON
if (newValue.trim().startsWith('{') || newValue.trim().startsWith('[')) {
try {
parsedValue = JSON.parse(newValue);
} catch (e) {
// If parsing fails, use the original string value
console.log('Value is not valid JSON, using as string');
}
}
const success = await addConfig(newKey, parsedValue);
if (success) {
setNewKey('');
setNewValue('');
const updatedConfigs = await loadConfigs();
setConfigs(updatedConfigs);
}
};
const handleRemoveConfig = async (key: string) => {
const success = await removeConfig(key);
if (success) {
const updatedConfigs = await loadConfigs();
setConfigs(updatedConfigs);
}
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<div className="flex flex-col pb-24">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={() => navigate('/settings')} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Configuration</h1>
</div>
<div className="flex-1 py-8 pt-[20px] px-8">
<div className="space-y-8 max-w-2xl">
{/* Add new config form */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Add New Configuration</h2>
<div className="grid gap-4">
<div>
<Label htmlFor="configKey">Key</Label>
<Input
id="configKey"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Enter config key"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="configValue">Value</Label>
<Input
id="configValue"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Enter config value (string or JSON)"
className="mt-1"
/>
</div>
<Button onClick={handleAddConfig} disabled={loading}>
{loading ? 'Adding...' : 'Add Configuration'}
</Button>
</div>
</Card>
{/* Error display */}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error.message}
</div>
)}
{/* Config list */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Current Configurations</h2>
<div className="grid gap-4">
{loading ? (
<div className="text-center text-gray-500">Loading configurations...</div>
) : Object.keys(configs).length === 0 ? (
<div className="text-center text-gray-500">No configurations found</div>
) : (
Object.entries(configs).map(([key, value]) => (
<div
key={key}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded"
>
<div className="break-all">
<span className="font-medium">{key}:</span>{' '}
<span className="text-gray-600 dark:text-gray-300">
{typeof value === 'object'
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
<Button
variant="destructive"
onClick={() => handleRemoveConfig(key)}
size="sm"
className="ml-4 shrink-0"
disabled={loading}
>
{loading ? '...' : 'Remove'}
</Button>
</div>
))
)}
</div>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import {
readAllConfig,
readConfig,
removeConfig,
upsertConfig,
addExtension,
removeExtension,
} from './generated';
import { client } from './generated/client.gen';
// Initialize client configuration
client.setConfig({
baseUrl: window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'),
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': window.appConfig.get('secretKey'),
},
});
export class Config {
static async upsert(key: string, value: any, isSecret?: boolean) {
return await upsertConfig({
body: {
key,
value,
is_secret: isSecret,
},
});
}
static async read(key: string) {
return await readConfig({
body: { key },
});
}
static async remove(key: string) {
return await removeConfig({
body: { key },
});
}
static async readAll() {
const response = await readAllConfig();
return response.data.config;
}
static async addExtension(name: string, config: any) {
return await addExtension({
body: { name, config },
});
}
static async removeExtension(name: string) {
return await removeExtension({
body: { key: name },
});
}
}

View File

@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions>());

View File

@@ -0,0 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './types.gen';
export * from './sdk.gen';

View File

@@ -0,0 +1,81 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Options<ReadAllConfigData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ReadAllConfigResponse, unknown, ThrowOnError>({
url: '/config',
...options
});
};
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const addExtension = <ThrowOnError extends boolean = false>(options: Options<AddExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<AddExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({
url: '/config/read',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const removeConfig = <ThrowOnError extends boolean = false>(options: Options<RemoveConfigData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RemoveConfigResponse, unknown, ThrowOnError>({
url: '/config/remove',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const upsertConfig = <ThrowOnError extends boolean = false>(options: Options<UpsertConfigData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<UpsertConfigResponse, unknown, ThrowOnError>({
url: '/config/upsert',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};

View File

@@ -0,0 +1,165 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ConfigKeyQuery = {
key: string;
};
export type ConfigResponse = {
config: {};
};
export type ExtensionQuery = {
config: unknown;
name: string;
};
export type UpsertConfigQuery = {
is_secret?: boolean | null;
key: string;
value: unknown;
};
export type ReadAllConfigData = {
body?: never;
path?: never;
query?: never;
url: '/config';
};
export type ReadAllConfigResponses = {
/**
* All configuration values retrieved successfully
*/
200: ConfigResponse;
};
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
export type RemoveExtensionData = {
body: ConfigKeyQuery;
path?: never;
query?: never;
url: '/config/extension';
};
export type RemoveExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type RemoveExtensionResponses = {
/**
* Extension removed successfully
*/
200: string;
};
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
export type AddExtensionData = {
body: ExtensionQuery;
path?: never;
query?: never;
url: '/config/extension';
};
export type AddExtensionErrors = {
/**
* Invalid request
*/
400: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type AddExtensionResponses = {
/**
* Extension added successfully
*/
200: string;
};
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
export type ReadConfigData = {
body: ConfigKeyQuery;
path?: never;
query?: never;
url: '/config/read';
};
export type ReadConfigErrors = {
/**
* Configuration key not found
*/
404: unknown;
};
export type ReadConfigResponses = {
/**
* Configuration value retrieved successfully
*/
200: unknown;
};
export type RemoveConfigData = {
body: ConfigKeyQuery;
path?: never;
query?: never;
url: '/config/remove';
};
export type RemoveConfigErrors = {
/**
* Configuration key not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type RemoveConfigResponses = {
/**
* Configuration value removed successfully
*/
200: string;
};
export type RemoveConfigResponse = RemoveConfigResponses[keyof RemoveConfigResponses];
export type UpsertConfigData = {
body: UpsertConfigQuery;
path?: never;
query?: never;
url: '/config/upsert';
};
export type UpsertConfigErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type UpsertConfigResponses = {
/**
* Configuration value upserted successfully
*/
200: string;
};
export type UpsertConfigResponse = UpsertConfigResponses[keyof UpsertConfigResponses];
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Card } from '../ui/card';
import { useNavigate } from 'react-router-dom';
import BackButton from '../ui/BackButton';
import { useConfig } from '../../hooks/useConfig';
import type { View } from '@/src/ChatWindow';
interface ConfigItem {
key: string;
value: any;
}
export function ConfigPage({
onClose,
setView,
}: {
onClose: () => void;
setView?: (view: View) => void;
}) {
const [configs, setConfigs] = useState<Record<string, any>>({});
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const navigate = useNavigate();
const { loading, error, loadConfigs, addConfig, removeConfig } = useConfig();
// Fetch all configs on component mount
useEffect(() => {
const fetchConfigs = async () => {
const result = await loadConfigs();
setConfigs(result);
};
fetchConfigs();
}, [loadConfigs]);
const handleAddConfig = async () => {
if (!newKey || !newValue) {
return;
}
let parsedValue = newValue;
// Try to parse as JSON if it looks like JSON
if (newValue.trim().startsWith('{') || newValue.trim().startsWith('[')) {
try {
parsedValue = JSON.parse(newValue);
} catch (e) {
// If parsing fails, use the original string value
console.log('Value is not valid JSON, using as string');
}
}
const success = await addConfig(newKey, parsedValue);
if (success) {
setNewKey('');
setNewValue('');
const updatedConfigs = await loadConfigs();
setConfigs(updatedConfigs);
}
};
const handleRemoveConfig = async (key: string) => {
const success = await removeConfig(key);
if (success) {
const updatedConfigs = await loadConfigs();
setConfigs(updatedConfigs);
}
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<div className="flex flex-col pb-24">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={() => navigate('/settings')} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Configuration</h1>
</div>
<div className="flex-1 py-8 pt-[20px] px-8">
<div className="space-y-8 max-w-2xl">
{/* Add new config form */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Add New Configuration</h2>
<div className="grid gap-4">
<div>
<Label htmlFor="configKey">Key</Label>
<Input
id="configKey"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Enter config key"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="configValue">Value</Label>
<Input
id="configValue"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Enter config value (string or JSON)"
className="mt-1"
/>
</div>
<Button onClick={handleAddConfig} disabled={loading}>
{loading ? 'Adding...' : 'Add Configuration'}
</Button>
</div>
</Card>
{/* Error display */}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error.message}
</div>
)}
{/* Config list */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Current Configurations</h2>
<div className="grid gap-4">
{loading ? (
<div className="text-center text-gray-500">Loading configurations...</div>
) : Object.keys(configs).length === 0 ? (
<div className="text-center text-gray-500">No configurations found</div>
) : (
Object.entries(configs).map(([key, value]) => (
<div
key={key}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded"
>
<div className="break-all">
<span className="font-medium">{key}:</span>{' '}
<span className="text-gray-600 dark:text-gray-300">
{typeof value === 'object'
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
<Button
variant="destructive"
onClick={() => handleRemoveConfig(key)}
size="sm"
className="ml-4 shrink-0"
disabled={loading}
>
{loading ? '...' : 'Remove'}
</Button>
</div>
))
)}
</div>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -181,6 +181,8 @@ export default function Settings({
return BUILT_IN_EXTENSIONS.some((builtIn) => builtIn.id === extensionId);
};
function navigate(s: string) {}
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
@@ -259,6 +261,27 @@ export default function Settings({
)}
</div>
</section>
<section id="configuration">
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-semibold text-textStandard">Configuration</h2>
<div className="flex gap-4">
<button
onClick={() => navigate('/settings/config')}
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Manage
</button>
</div>
</div>
<div className="px-8">
<p className="text-sm text-textStandard mb-4">
Manage application configuration and settings. You can view, add, edit, and
remove configuration values.
</p>
</div>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
import { ConfigAPI, ConfigResponse } from './api';
export const ConfigManager: React.FC = () => {
const [config, setConfig] = useState<ConfigResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const data = await ConfigAPI.readAllConfig();
setConfig(data);
setError(null);
} catch (err) {
setError('Failed to load configuration');
console.error(err);
}
};
const handleUpsert = async () => {
try {
await ConfigAPI.upsertConfig({
key: newKey,
value: newValue as any, // You might want to add proper parsing here
});
await loadConfig();
setNewKey('');
setNewValue('');
setError(null);
} catch (err) {
setError('Failed to update configuration');
console.error(err);
}
};
const handleRemove = async (key: string) => {
try {
await ConfigAPI.removeConfig(key);
await loadConfig();
setError(null);
} catch (err) {
setError('Failed to remove configuration');
console.error(err);
}
};
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">Configuration Manager</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2">Add/Update Configuration</h3>
<div className="flex gap-2">
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
className="border p-2 rounded"
/>
<input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Value"
className="border p-2 rounded"
/>
<button onClick={handleUpsert} className="bg-blue-500 text-white px-4 py-2 rounded">
Save
</button>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Current Configuration</h3>
{config && (
<div className="border rounded">
{Object.entries(config.config).map(([key, value]) => (
<div key={key} className="p-2 border-b flex justify-between items-center">
<div>
<span className="font-medium">{key}:</span> <span>{JSON.stringify(value)}</span>
</div>
<button
onClick={() => handleRemove(key)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,98 @@
import { Value } from 'yaml';
export interface UpsertConfigQuery {
key: string;
value: Value;
isSecret?: boolean;
}
export interface ConfigKeyQuery {
key: string;
}
export interface ExtensionQuery {
name: string;
config: Value;
}
export interface ConfigResponse {
config: Record<string, Value>;
}
const API_BASE = 'http://localhost:3000'; // Update this with your actual API base URL
export class ConfigAPI {
static async readAllConfig(): Promise<ConfigResponse> {
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) {
throw new Error('Failed to fetch config');
}
return response.json();
}
static async upsertConfig(query: UpsertConfigQuery): Promise<string> {
const params = new URLSearchParams({
key: query.key,
value: JSON.stringify(query.value),
...(query.isSecret && { is_secret: String(query.isSecret) }),
});
const response = await fetch(`${API_BASE}/config/upsert?${params}`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to upsert config');
}
return response.text();
}
static async removeConfig(key: string): Promise<string> {
const params = new URLSearchParams({ key });
const response = await fetch(`${API_BASE}/config/remove?${params}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove config');
}
return response.text();
}
static async readConfig(key: string): Promise<Value> {
const params = new URLSearchParams({ key });
const response = await fetch(`${API_BASE}/config/read?${params}`);
if (!response.ok) {
throw new Error('Failed to read config');
}
return response.json();
}
static async addExtension(extension: ExtensionQuery): Promise<string> {
const response = await fetch(`${API_BASE}/config/extension`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(extension),
});
if (!response.ok) {
throw new Error('Failed to add extension');
}
return response.text();
}
static async removeExtension(name: string): Promise<string> {
const params = new URLSearchParams({ key: name });
const response = await fetch(`${API_BASE}/config/extension?${params}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove extension');
}
return response.text();
}
}

View File

@@ -0,0 +1,111 @@
import { useState, useCallback } from 'react';
import { Config } from '../api/config';
import { toast } from 'react-toastify';
export interface UseConfigOptions {
onError?: (error: Error) => void;
showToasts?: boolean;
}
export function useConfig(options: UseConfigOptions = {}) {
const { onError, showToasts = true } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback(
(error: Error, message: string) => {
setError(error);
if (showToasts) {
toast.error(message);
}
if (onError) {
onError(error);
}
},
[onError, showToasts]
);
const loadConfigs = useCallback(async () => {
try {
setLoading(true);
setError(null);
const configs = await Config.readAll();
return configs;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to load configurations');
handleError(error, 'Failed to load configurations');
return {};
} finally {
setLoading(false);
}
}, [handleError]);
const addConfig = useCallback(
async (key: string, value: any) => {
try {
setLoading(true);
setError(null);
await Config.upsert(key, value);
if (showToasts) {
toast.success(`Successfully added configuration: ${key}`);
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to add configuration');
handleError(error, `Failed to add configuration: ${key}`);
return false;
} finally {
setLoading(false);
}
},
[handleError, showToasts]
);
const removeConfig = useCallback(
async (key: string) => {
try {
setLoading(true);
setError(null);
await Config.remove(key);
if (showToasts) {
toast.success(`Successfully removed configuration: ${key}`);
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to remove configuration');
handleError(error, `Failed to remove configuration: ${key}`);
return false;
} finally {
setLoading(false);
}
},
[handleError, showToasts]
);
const readConfig = useCallback(
async (key: string) => {
try {
setLoading(true);
setError(null);
const value = await Config.read(key);
return value;
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to read configuration');
handleError(error, `Failed to read configuration: ${key}`);
return null;
} finally {
setLoading(false);
}
},
[handleError]
);
return {
loading,
error,
loadConfigs,
addConfig,
removeConfig,
readConfig,
};
}