Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.11.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Type-check
run: pnpm check

- name: Test
run: pnpm test

- name: Build
run: pnpm build

- name: Install example dependencies
working-directory: example
run: pnpm install --frozen-lockfile

- name: Build example
working-directory: example
run: pnpm build
166 changes: 59 additions & 107 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,121 +1,100 @@
# @flanksource/plugin-ui-sdk

Browser SDK for Mission Control plugin UIs and host apps embedding Mission Control plugins.

The SDK has two layers:

1. **Plugin runtime API**: used inside a plugin UI to call its own backend operations.
2. **Host client API**: used by Backstage/flanksource-ui/customer apps to connect to Mission Control via pass-through cookies or backend proxy auth.
Browser SDK for calling Mission Control plugin operations.

## Install

```sh
pnpm add @flanksource/plugin-ui-sdk
```

## Plugin runtime API
## Plugin client API

For code running inside a Mission Control plugin UI:
Create a Mission Control plugin client, then create an instance for a specific plugin/config pair:

```ts
import { invoke, ready, stream } from "@flanksource/plugin-ui-sdk";
import { createMissionControlPluginClient } from "@flanksource/plugin-ui-sdk";

const pluginClient = createMissionControlPluginClient({
mode: "proxy",
baseUrl: "/api/mission-control",
});

const kubernetes = pluginClient.New("kubernetes", "config-123");
```

### `invoke(operation, body?, options?)`
### `pluginClient.New(pluginRef, configId?)`

Calls a plugin backend operation and returns the native `Response`.
Creates a plugin instance scoped to a plugin ref and optional catalog config id.
The instance exposes the operation methods.

### `instance.invoke(operation, bodyOrQueryParams?, options?)`

Calls a plugin operation and returns the native `Response`.

```ts
const res = await invoke("query", {
sql: "select * from users limit 10",
});
const res = await kubernetes.invoke("list-pods");

if (!res.ok) throw new Error(await res.text());
const rows = await res.json();
```

With query parameters:
With params/body:

```ts
const res = await invoke("logs", undefined, {
method: "GET",
query: { tail: 100, container: "api" },
const res = await kubernetes.invoke("create-pod", {
namespace: "default",
name: "nginx",
image: "nginx:latest",
});
```

Behavior:

- Uses the current plugin UI URL to find the installed plugin name.
- Automatically includes the current `config_id`.
- Defaults to `POST` when `body` is provided.
- Defaults to `GET` when no `body` is provided.
- Defaults to `POST /api/plugins/:pluginRef/invoke/:operation`.
- Set `options.proxy: true` to use `/api/plugins/:pluginRef/proxy/:operation` instead; `pluginRef` comes from `pluginClient.New(pluginRef, configId)` and the HTTP method comes from `options.method`.
- Sends the instance `configId` as the `config_id` query parameter.
- Sends `{}` when no body is provided for methods that support a body.
- For `GET`/`HEAD`, treats the second argument as query params.
- JSON-encodes non-`BodyInit` bodies and sets `content-type: application/json`.

The current URL convention is:
HTTP-style proxy request:

```text
/api/plugins/:pluginRef/ui?config_id=:configId
/api/plugins/:pluginRef/proxy/:operation?config_id=:configId
```ts
const res = await kubernetes.invoke("list-pods", {
namespace: "default",
labelSelector: "app=web",
}, {
method: "GET",
proxy: true,
});
// GET /api/plugins/kubernetes/proxy/list-pods?config_id=config-123&namespace=default&labelSelector=app%3Dweb
```

### `stream(operation, query?, options?)`
### `instance.stream(operation, query?)`

Opens an SSE stream to a plugin operation.
Opens an SSE stream to a plugin operation via Mission Control's `/proxy/` endpoint.

```ts
const events = stream("tail-logs", { pod: "api-123", tail: 100 });
const logs = pluginClient.New("kubernetes-logs", "config-123");
const events = logs.stream("tail-logs", {
pod: "api-123",
tail: 100,
});

events.onmessage = event => {
console.log(event.data);
};
```

### `ready()`

Signals the host frame that the plugin UI has loaded.

```ts
ready();
```

Posts:

```ts
{ type: "mc.tab.ready" }
```

## Explicit plugin runtime

For native embedding or tests, create a runtime explicitly instead of deriving context from the iframe URL:

```ts
import { createPluginRuntime } from "@flanksource/plugin-ui-sdk";

const runtime = createPluginRuntime({
pluginRef: "kubernetes-logs",
configId: "config-123",
basePath: "/api/plugins",
});

const res = await runtime.invoke("list-pods", undefined, {
query: { namespace: "default" },
});
```

## Host client API

For apps embedding Mission Control plugin functionality:

```ts
import { createMissionControlClient } from "@flanksource/plugin-ui-sdk";
```
## Connection modes

### Proxy mode

Browser calls the host backend. The host backend injects service auth and proxies to Mission Control.

```ts
const mc = createMissionControlClient({
const pluginClient = createMissionControlPluginClient({
mode: "proxy",
baseUrl: "/api/mission-control",
});
Expand All @@ -126,61 +105,34 @@ const mc = createMissionControlClient({
Browser calls Mission Control directly using Mission Control cookies/session.

```ts
const mc = createMissionControlClient({
const pluginClient = createMissionControlPluginClient({
mode: "pass-through",
baseUrl: "https://mission-control.example.com",
});
```

Pass-through requires Mission Control cookies and CORS to support credentialed browser requests.

### Plugin discovery

```ts
const plugins = await mc.plugins.list();
const plugin = await mc.plugins.get("kubernetes-logs");
```

### Invoke plugin operations from the host

```ts
const res = await mc.plugins.invoke("kubernetes-logs", "list-pods", {
configId: "config-123",
query: { namespace: "default" },
});

const pods = await res.json();
```

### Stream plugin operations from the host

```ts
const events = mc.plugins.stream("kubernetes-logs", "tail-logs", {
configId: "config-123",
query: { pod: "api-123", container: "api" },
});
```

## Types

Important exported types:

```ts
type ConnectionMode = "pass-through" | "proxy";
type QueryParams = Record<string, string | number | boolean | null | undefined>;
type QueryValue = string | number | boolean | null | undefined;
type QueryParams = Record<string, QueryValue | readonly QueryValue[]>;

interface MissionControlClient {
interface MissionControlPluginClient {
mode: ConnectionMode;
baseUrl: string;
request(path: string, init?: RequestInit): Promise<Response>;
plugins: PluginRegistryApi;
New(pluginRef: string, configId?: string): MissionControlPluginInstance;
}

interface PluginRegistryApi {
list(): Promise<PluginManifest[]>;
get(pluginRef: string): Promise<PluginManifest>;
invoke(pluginRef: string, operation: string, request?: PluginInvokeRequest): Promise<Response>;
stream(pluginRef: string, operation: string, request?: PluginStreamRequest): EventSource;
interface MissionControlPluginInstance {
pluginRef: string;
configId?: string;
invoke(operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise<Response>;
stream(operation: string, query?: QueryParams): EventSource;
}
```

Expand All @@ -190,7 +142,7 @@ Build plugin UIs as relocatable static apps:

- Use relative asset URLs. For Vite, set `base: "./"`.
- Use hash routing for internal UI routes.
- Use `invoke()` and `stream()` for plugin backend calls instead of hardcoding `/api/plugins/...` URLs.
- Use `instance.invoke()` and `instance.stream()` for plugin backend calls instead of hardcoding `/api/plugins/...` URLs.

Vite example:

Expand Down
36 changes: 14 additions & 22 deletions example/src/Logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type PodRow = {
}

export function Logs() {
const { plugins } = useMissionControl()
const pluginClient = useMissionControl()
const [configId, setConfigId] = useState(defaultConfigId)
const [namespace, setNamespace] = useState('default')
const [pod, setPod] = useState('')
Expand All @@ -33,9 +33,7 @@ export function Logs() {
setEvents([])

try {
const res = await plugins.invoke(pluginRef, 'list-pods', {
configId: configId || undefined,
})
const res = await pluginClient.New(pluginRef, configId || undefined).invoke('list-pods')
const text = await res.text()
const rows = JSON.parse(text) as PodRow[]
setPods(rows)
Expand All @@ -58,15 +56,12 @@ export function Logs() {
setEvents([])

try {
const res = await plugins.invoke(pluginRef, 'logs', {
configId: configId || undefined,
query: {
namespace,
pod: pod || undefined,
container: container || undefined,
tailLines: Number(tail) || undefined,
follow: false,
},
const res = await pluginClient.New(pluginRef, configId || undefined).invoke('logs', {
namespace,
pod: pod || undefined,
container: container || undefined,
tailLines: Number(tail) || undefined,
follow: false,
})

const text = await res.text()
Expand All @@ -85,15 +80,12 @@ export function Logs() {
setResult({ status: 'idle' })

try {
const source = plugins.stream(pluginRef, 'logs', {
configId: configId || undefined,
query: {
namespace,
pod: pod || undefined,
container: container || undefined,
tailLines: Number(tail) || undefined,
follow: true,
},
const source = pluginClient.New(pluginRef, configId || undefined).stream('logs', {
namespace,
pod: pod || undefined,
container: container || undefined,
tailLines: Number(tail) || undefined,
follow: true,
})

source.onmessage = event => {
Expand Down
10 changes: 5 additions & 5 deletions example/src/MissionControlContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { createContext, useContext, useMemo } from 'react';
import {
createMissionControlClient,
createMissionControlPluginClient,
type ConnectionMode,
type MissionControlClient,
type MissionControlPluginClient,
} from '@flanksource/plugin-ui-sdk';

type MissionControlProviderProps = {
Expand All @@ -11,11 +11,11 @@ type MissionControlProviderProps = {
children: React.ReactNode;
};

const MissionControlContext = createContext<MissionControlClient | null>(null);
const MissionControlContext = createContext<MissionControlPluginClient | null>(null);

export function MissionControlProvider({ mode, baseUrl, children }: MissionControlProviderProps) {
const client = useMemo(
() => createMissionControlClient({ mode, baseUrl }),
() => createMissionControlPluginClient({ mode, baseUrl }),
[mode, baseUrl],
);

Expand All @@ -26,7 +26,7 @@ export function MissionControlProvider({ mode, baseUrl, children }: MissionContr
);
}

export function useMissionControl(): MissionControlClient {
export function useMissionControl(): MissionControlPluginClient {
const client = useContext(MissionControlContext);
if (!client) {
throw new Error('useMissionControl must be used inside MissionControlProvider');
Expand Down
Loading
Loading