anbaric-plugins 1.7.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.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # anbaric-plugins
2
+
3
+ Open-source plugins for the Anbaric platform dashboard. A plugin registers
4
+ pages and widgets on the platform's root app: pages appear in the side nav,
5
+ widgets are React components rendered into a page's `<main>`.
6
+
7
+ This package ships the plugins the platform loads by default:
8
+
9
+ | Plugin module | What it registers |
10
+ | --- | --- |
11
+ | `anbaric-plugins/state-machines` | The `/` Dashboard page with the state machines and jobs overview |
12
+
13
+ ## How plugins work
14
+
15
+ A plugin is a TypeScript module that exports a `plugin` object. The platform
16
+ server (`anbaric-cloud-hosting`) loads the modules named in the
17
+ `ANBARIC_PLUGINS` environment variable (comma-separated) at boot, compiles
18
+ each one into a browser bundle, and serves it to the dashboard, which renders
19
+ the components. The dashboard's own build is plugin-agnostic — installing a
20
+ plugin is configuration only:
21
+
22
+ ```
23
+ npm install some-plugin-package
24
+ ANBARIC_PLUGINS=some-plugin-package,anbaric-plugins/state-machines
25
+ ```
26
+
27
+ If `ANBARIC_PLUGINS` is unset the platform loads
28
+ `anbaric-plugins/state-machines`, so the Dashboard is there by default.
29
+ Setting the variable replaces the default list, so include it explicitly if
30
+ you still want the Dashboard.
31
+
32
+ ## Writing a plugin
33
+
34
+ ```tsx
35
+ import type { Plugin } from 'anbaric-cloud-hosting'
36
+ import { Card } from '@anbaric/design-system/components/Card'
37
+
38
+ function GreetingWidget({ fetchData }: { fetchData: (parameters?: Record<string, string>) => Promise<any> }) {
39
+ return <Card>Hello from a plugin.</Card>
40
+ }
41
+
42
+ const plugin: Plugin = {
43
+ name: 'greeting',
44
+ pages: [{ path: '/greeting', title: 'Greeting', icon: 'waving_hand', navOrder: 50 }],
45
+ widgets: [
46
+ {
47
+ page: '/greeting',
48
+ id: 'greeting',
49
+ component: GreetingWidget,
50
+ data: async (parameters) => ({ greeted: parameters.name ?? 'world' }),
51
+ },
52
+ ],
53
+ }
54
+
55
+ export { plugin }
56
+ ```
57
+
58
+ - **Pages** are registered by path; `/` is the homepage. Plugin page paths
59
+ take precedence over deployed apps with the same name; `icon` is a
60
+ Material Symbols name; lower `navOrder` sorts higher in the nav.
61
+ - **Widgets** attach to any page path — including pages registered by other
62
+ plugins. `position` orders widgets on a page; a `title` renders a heading
63
+ above the widget.
64
+ - **`data`** is an optional server-side function. The widget's component
65
+ receives a `fetchData(parameters?)` prop that invokes it on the platform
66
+ (`GET /plugins/data?plugin=<name>&widget=<id>&…`) and resolves with its
67
+ JSON-serialisable return value. Because the function's code is also
68
+ carried (unexecuted) in the browser bundle, do not import server-only
69
+ modules at the top level of files that components also live in.
70
+ - Components render with the **platform's own React and design system**:
71
+ imports of `react`, `react-dom` and `@anbaric/design-system/components/*`
72
+ are provided by the dashboard at runtime, not bundled. Style with design
73
+ system components and tokens; a plugin's own `.css` imports are ignored.
74
+ The chart components (`Graph`, `ConcentrationCurve`, `ExposureBars`) are
75
+ not available to plugins.
76
+
77
+ A package can ship several plugins by exposing one module per plugin via
78
+ `exports` subpaths, as this package does.
79
+
80
+ ## Layout
81
+
82
+ One folder per plugin under `src/`, exported as a subpath:
83
+
84
+ ```
85
+ src/state-machines/index.tsx the plugin object
86
+ src/state-machines/StateMachinesWidget.tsx
87
+ ```
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "anbaric-plugins",
3
+ "version": "1.7.0",
4
+ "description": "Open-source Anbaric platform plugins: pages and widgets loaded by the hosting server via ANBARIC_PLUGINS",
5
+ "license": "MIT",
6
+ "author": "chris@anbaric.ai",
7
+ "type": "module",
8
+ "exports": {
9
+ "./state-machines": "./src/state-machines/index.tsx"
10
+ },
11
+ "peerDependencies": {
12
+ "react": ">=19"
13
+ },
14
+ "devDependencies": {
15
+ "@anbaric/design-system": "file:../anbaric-design-system",
16
+ "@types/react": "^19.2.14",
17
+ "anbaric-cloud-hosting": "^1.7.0",
18
+ "react": "^19.2.6",
19
+ "typescript": "^7.0.2"
20
+ },
21
+ "files": [
22
+ "src"
23
+ ]
24
+ }
@@ -0,0 +1,5 @@
1
+ declare module '*.css'
2
+ declare module '*.svg' {
3
+ const url: string
4
+ export default url
5
+ }
@@ -0,0 +1,230 @@
1
+ import { useEffect, useState, type CSSProperties } from 'react'
2
+
3
+ import { Alert } from '@anbaric/design-system/components/Alert'
4
+ import { Badge } from '@anbaric/design-system/components/Badge'
5
+ import { Card } from '@anbaric/design-system/components/Card'
6
+
7
+ interface StateMachine {
8
+ workflowId: string
9
+ url: string
10
+ }
11
+
12
+ interface Job {
13
+ id: string
14
+ state: string
15
+ workflowId?: string
16
+ properties: Record<string, unknown>
17
+ startedAt?: string
18
+ startedBy?: string
19
+ lastUpdated?: string
20
+ transitions?: Array<{ from: string; to: string; actor: string }>
21
+ }
22
+
23
+ const formatDate = (iso?: string) =>
24
+ iso
25
+ ? new Date(iso).toLocaleString(undefined, {
26
+ day: 'numeric',
27
+ month: 'short',
28
+ year: 'numeric',
29
+ hour: 'numeric',
30
+ minute: '2-digit',
31
+ })
32
+ : '—'
33
+
34
+ const machineHeading: CSSProperties = {
35
+ margin: 0,
36
+ fontFamily: 'var(--font-title)',
37
+ fontSize: '1.15rem',
38
+ textTransform: 'var(--title-transform)' as CSSProperties['textTransform'],
39
+ }
40
+
41
+ const cell: CSSProperties = {
42
+ textAlign: 'left',
43
+ padding: 'calc(var(--space-sm) / 2) var(--space-sm)',
44
+ borderBottom: '1px solid color-mix(in srgb, var(--color-grey) 12%, transparent)',
45
+ }
46
+
47
+ const headerCell: CSSProperties = {
48
+ ...cell,
49
+ fontSize: '0.72rem',
50
+ textTransform: 'uppercase',
51
+ letterSpacing: '0.06em',
52
+ color: 'var(--color-foreground-tint-2)',
53
+ }
54
+
55
+ const mono: CSSProperties = {
56
+ fontFamily: 'var(--font-mono)',
57
+ fontSize: '0.78rem',
58
+ }
59
+
60
+ const muted: CSSProperties = {
61
+ color: 'var(--color-foreground-tint-2)',
62
+ }
63
+
64
+ function PropertyList({ job }: { job: Job }) {
65
+ const entries: Array<[string, unknown]> = [['id', job.id], ...Object.entries(job.properties)]
66
+ return (
67
+ <dl style={{ margin: 0, display: 'flex', flexDirection: 'column', gap: '2px' }}>
68
+ {entries.map(([name, value]) => (
69
+ <div key={name} style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'baseline' }}>
70
+ <dt style={{ ...mono, ...muted, flex: 'none' }}>{name}</dt>
71
+ <dd style={{ margin: 0, overflowWrap: 'anywhere' }}>{String(value)}</dd>
72
+ </div>
73
+ ))}
74
+ </dl>
75
+ )
76
+ }
77
+
78
+ function TransitionHistory({ transitions }: { transitions: Job['transitions'] }) {
79
+ if (!transitions || transitions.length === 0) {
80
+ return <span style={muted}>—</span>
81
+ }
82
+ return (
83
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
84
+ {transitions.map((transition, index) => (
85
+ <div key={index}>
86
+ {transition.from} → {transition.to}{' '}
87
+ <span style={{ ...muted, fontSize: '0.78rem' }}>by {transition.actor}</span>
88
+ </div>
89
+ ))}
90
+ </div>
91
+ )
92
+ }
93
+
94
+ function JobsTable({ jobs }: { jobs: Job[] }) {
95
+ if (jobs.length === 0) {
96
+ return <p style={{ margin: 0, color: 'var(--color-foreground-tint-2)' }}>No jobs yet.</p>
97
+ }
98
+ return (
99
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
100
+ <thead>
101
+ <tr>
102
+ <th style={headerCell}>Properties</th>
103
+ <th style={headerCell}>State</th>
104
+ <th style={headerCell}>Started</th>
105
+ <th style={headerCell}>Updated</th>
106
+ <th style={headerCell}>History</th>
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ {jobs.map((job) => (
111
+ <tr key={job.id}>
112
+ <td style={cell}>
113
+ <PropertyList job={job} />
114
+ </td>
115
+ <td style={cell}>
116
+ <Badge tone="primary" dot>
117
+ {job.state}
118
+ </Badge>
119
+ </td>
120
+ <td style={cell}>
121
+ {formatDate(job.startedAt)}
122
+ <div style={{ ...muted, fontSize: '0.78rem' }}>by {job.startedBy ?? 'system'}</div>
123
+ </td>
124
+ <td style={cell}>{formatDate(job.lastUpdated)}</td>
125
+ <td style={cell}>
126
+ <TransitionHistory transitions={job.transitions} />
127
+ </td>
128
+ </tr>
129
+ ))}
130
+ </tbody>
131
+ </table>
132
+ )
133
+ }
134
+
135
+ function StateMachinesWidget() {
136
+ const [machines, setMachines] = useState<StateMachine[] | undefined>(undefined)
137
+ const [jobs, setJobs] = useState<Job[] | undefined>(undefined)
138
+ const [failed, setFailed] = useState(false)
139
+
140
+ useEffect(() => {
141
+ void (async () => {
142
+ try {
143
+ const [machinesResponse, jobsResponse] = await Promise.all([
144
+ fetch('/state-machines'),
145
+ fetch('/jobs'),
146
+ ])
147
+ if (!machinesResponse.ok || !jobsResponse.ok) {
148
+ setFailed(true)
149
+ return
150
+ }
151
+ setMachines(await machinesResponse.json())
152
+ setJobs(await jobsResponse.json())
153
+ } catch {
154
+ setFailed(true)
155
+ }
156
+ })()
157
+ }, [])
158
+
159
+ const registered = new Set((machines ?? []).map((machine) => machine.workflowId))
160
+ const workflowIds = [
161
+ ...new Set([
162
+ ...registered,
163
+ ...(jobs ?? [])
164
+ .map((job) => job.workflowId)
165
+ .filter((workflowId): workflowId is string => Boolean(workflowId)),
166
+ ]),
167
+ ].sort()
168
+ const unassigned = (jobs ?? []).filter((job) => !job.workflowId)
169
+
170
+ return (
171
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' }}>
172
+ {failed ? (
173
+ <Alert variant="danger" title="Could not load the platform state">
174
+ The platform rejected the request — try reloading the page.
175
+ </Alert>
176
+ ) : null}
177
+ {machines && workflowIds.length === 0 ? (
178
+ <Card>
179
+ <p style={{ margin: 0 }}>
180
+ No state machines yet. Deploy an app with <code>anbaric deploy</code> and its
181
+ machines will appear here.
182
+ </p>
183
+ </Card>
184
+ ) : null}
185
+ {workflowIds.map((workflowId) => {
186
+ const machineJobs = (jobs ?? []).filter((job) => job.workflowId === workflowId)
187
+ return (
188
+ <Card key={workflowId}>
189
+ <div
190
+ style={{
191
+ display: 'flex',
192
+ alignItems: 'center',
193
+ justifyContent: 'space-between',
194
+ gap: 'var(--space-md)',
195
+ marginBottom: 'var(--space-md)',
196
+ }}
197
+ >
198
+ <h2 style={machineHeading}>{workflowId}</h2>
199
+ <div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
200
+ <Badge tone="neutral">
201
+ {machineJobs.length} {machineJobs.length === 1 ? 'job' : 'jobs'}
202
+ </Badge>
203
+ {registered.has(workflowId) ? (
204
+ <Badge tone="success" dot>
205
+ consumer connected
206
+ </Badge>
207
+ ) : (
208
+ <Badge tone="warning" dot>
209
+ no consumer
210
+ </Badge>
211
+ )}
212
+ </div>
213
+ </div>
214
+ <JobsTable jobs={machineJobs} />
215
+ </Card>
216
+ )
217
+ })}
218
+ {unassigned.length > 0 ? (
219
+ <Card>
220
+ <h2 style={{ ...machineHeading, marginBottom: 'var(--space-md)' }}>
221
+ Unassigned jobs
222
+ </h2>
223
+ <JobsTable jobs={unassigned} />
224
+ </Card>
225
+ ) : null}
226
+ </div>
227
+ )
228
+ }
229
+
230
+ export { StateMachinesWidget }
@@ -0,0 +1,11 @@
1
+ import type { Plugin } from 'anbaric-cloud-hosting'
2
+
3
+ import { StateMachinesWidget } from './StateMachinesWidget'
4
+
5
+ const plugin: Plugin = {
6
+ name: 'state-machines',
7
+ pages: [{ path: '/', title: 'Dashboard', icon: 'dashboard', navOrder: 0 }],
8
+ widgets: [{ page: '/', id: 'state-machines', component: StateMachinesWidget }],
9
+ }
10
+
11
+ export { plugin }