Compare commits
49
Commits
fe0012635a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 |
@@ -63,3 +63,10 @@ scripts/setup/officer-setup/.setup-progress
|
||||
# the repository has no ecosystem file at all any more, and the next machine
|
||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||
ecosystem.config.cjs
|
||||
|
||||
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||
# rewritten on every install. See servers/plugins/generate.ts.
|
||||
build/
|
||||
build.next/
|
||||
src/apps/officer-web/Plugins.gen.tsx
|
||||
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||
|
||||
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# Extracting a feature into a plugin
|
||||
|
||||
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||
|
||||
**Read first, in this order:**
|
||||
|
||||
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||
3. `plugins/offscale/` — the worked example, all four parts
|
||||
4. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||
|
||||
---
|
||||
|
||||
## The rules. These are not preferences
|
||||
|
||||
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||
|
||||
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||
of any sort. The platform's answer is uniform; what a grant *means* — whose rows a member sees, whether a
|
||||
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||
|
||||
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||
|
||||
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||
structural is convention — presence is the declaration:
|
||||
|
||||
```
|
||||
manifest.ts required
|
||||
api/router.ts a backend router, mounted at mountPrefix()
|
||||
db/schema.ts tables, prefixed <app-name>_
|
||||
sidecar/index.ts a process (.mjs instead means node)
|
||||
web/panels.ts panels — REQUIRED with web/
|
||||
web/layout.ts layout — REQUIRED with web/
|
||||
```
|
||||
|
||||
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||
|
||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||
anywhere else means two systems, and only one gets tested.
|
||||
|
||||
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||
on uninstall. Only deleting a plugin's source can lose its data.
|
||||
|
||||
---
|
||||
|
||||
## The order that worked
|
||||
|
||||
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||
`plugins/<name>/db/`.
|
||||
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||
reference a plugin may make.
|
||||
4. **Write `manifest.ts`.**
|
||||
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||
and `helpers/clipboard` stay as they are.
|
||||
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||
**both** database barrels (`index.ts` and `schema.ts`).
|
||||
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||
8. **Verify on the live server** — see below.
|
||||
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||
|
||||
---
|
||||
|
||||
## Verification — run all of it
|
||||
|
||||
```
|
||||
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||
|
||||
| Step | Expect |
|
||||
| --- | --- |
|
||||
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||
| the plugin's API | answers |
|
||||
| the plugin's screen | renders as a Workspace |
|
||||
| dock | tile appears |
|
||||
| permissions page | its permission is listed, read/write/none |
|
||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||
| enable | comes back |
|
||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||
| install again | identical to the first install |
|
||||
|
||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||
|
||||
---
|
||||
|
||||
## Traps, all of which cost real time once
|
||||
|
||||
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||
`install.ts`; do not reorder it.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||
pending for the whole protocol.
|
||||
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||
contract change. Regenerate rather than hand-edit.
|
||||
|
||||
---
|
||||
|
||||
## Music specifically — READ THIS BEFORE STARTING
|
||||
|
||||
**Music is bigger than offscale, and one part of it has nowhere to go.** Two others did, and the owner cut
|
||||
both from tonight's scope — see below.
|
||||
Mapped 2026-08-15.
|
||||
|
||||
**Decide these yourself. Do not stop to ask.** Every one of them has two defensible answers, the owner has
|
||||
said either is fine, and a corrected decision is cheap where a stalled extraction is not. Record what you
|
||||
chose and why — in the manifest, in a comment, in the commit message — and keep going. The failure mode to
|
||||
avoid is not a wrong call; it is a tree left half-moved with a question attached.
|
||||
|
||||
### What has no home yet
|
||||
|
||||
1. ~~**Two websocket providers.**~~ **OUT OF SCOPE — do not touch cliamp.**
|
||||
|
||||
`cliamp` and `cliamp-audio` exist for one thing: running the `cliamp` TUI player on the server and
|
||||
piping its terminal and its audio (a PulseAudio null sink tapped by `parec`) to the browser. It is a
|
||||
second, separate playback path and the owner's word is that it is the least important part of music —
|
||||
fun, wanted eventually, not tonight.
|
||||
|
||||
**This is the whole reason the websocket gap does not block this extraction.** Both sockets are already
|
||||
inert (routes upgrade into commented-out handlers). Leave `sidecar/music/cliamp-ws.ts`,
|
||||
`sidecar/music/pulse-audio.ts`, `sidecar/music/asoundrc`, `src/servers/api/cliamp/relay.ts` and the two
|
||||
providers in `server.tsx` exactly where they are, untouched. If the relay's import of
|
||||
`getMusicServerWsUrl` is the only thing keeping `api/music/router.ts` alive, leave that file too and
|
||||
say so — a small documented seam is fine.
|
||||
|
||||
What music actually is: the `/music` screen, the library, and the phone and tablet apps that stream
|
||||
from it. That is what has to work.
|
||||
|
||||
2. **A global UI overlay.** `MusicPlayerHost` is rendered by `DashboardLayout` on **every route**, not
|
||||
inside a panel, and gates itself on `can('music')`. A plugin contributes panels and a layout; there is
|
||||
no "render this everywhere" slot, and inventing one is a platform change.
|
||||
3. ~~**A dashboard widget.**~~ **OUT OF SCOPE — leave it where it is.**
|
||||
|
||||
`src/workspaces/widgets/MusicPlayer/` sits in a third workspace package and is wired through
|
||||
`WidgetRegistry`. Plugins cannot contribute widgets and are not going to learn how tonight. Leave the
|
||||
directory, the registration and the barrel export alone; note the seam in the manifest.
|
||||
|
||||
### What else differs
|
||||
|
||||
- **External binaries**: with cliamp out of scope, what the plugin still needs is `ffmpeg` and `ffprobe`,
|
||||
for tag reading, cover compression and poster frames. A manifest has no way to declare a host
|
||||
dependency; note it and move on. (`cliamp`, `parec`, `pulseaudio`, `pactl` and `asoundrc` stay behind
|
||||
with cliamp.)
|
||||
- **Range requests**: `stream-audio.ts` does 206 / `Content-Range` / 416 and a custom `X-Audio-Duration`;
|
||||
the proxy runs at `timeoutSeconds: 1800` for reindexes. Verify `Range` survives the hop.
|
||||
- **Cross-capability**: `MusicBrowser` calls `/file-browser/ls`, not the music sidecar.
|
||||
- **A CLI tool**: `scripts/reindex-music.ts` reads `DATA_PATH/music/.server`.
|
||||
- **Music is the platform's worked example.** `registry.ts` explains `personal` through it and
|
||||
`registry.test.ts` tests the mechanism *through the music capability*. Those tests must be rewritten
|
||||
against something else, not deleted.
|
||||
- **Already half-disabled**: `hono.ts` mount and `schema.ts` export are commented out since 2026-08-13;
|
||||
`officer_db/src/index.ts:49` is still live. So the tree is mid-migration already.
|
||||
|
||||
### The per-user model is NOT tonight's work
|
||||
|
||||
`personal: ['/favorites', '/now-playing', '/playlists', '/queue']` on music's registry entry, the four
|
||||
user-scoped tables, and the question of what a member's grant *means* — **all of it stays exactly as it
|
||||
is. Do not design it, do not improve it, do not think about it.** It will be built inside the plugin
|
||||
later, which is the whole reason the platform's answer is a uniform read/write and nothing more.
|
||||
|
||||
Two facts that make leaving it alone safe rather than lazy:
|
||||
|
||||
- **The web frontend does not use those routes at all.** Favourites, playlists and now-playing are used
|
||||
only by the phone and tablet apps. Nothing you can see in a browser depends on them, so nothing in this
|
||||
extraction can regress by carrying them across untouched.
|
||||
- `/queue` is in that list and **no such route exists**, in the sidecar or anywhere else. Dead or
|
||||
aspirational. Carry it as-is; do not investigate.
|
||||
|
||||
Move the routes, the tables and the queries verbatim. `userId` keeps arriving from `X-Officer-User` and
|
||||
the queries keep scoping by it, exactly as today.
|
||||
|
||||
**The one mechanical consequence you cannot avoid:** `registry.test.ts` tests the `personal` mechanism
|
||||
*through* the music entry, and `registry.ts` uses music as its worked example in prose. Removing the
|
||||
entry breaks those tests. Re-anchor them on another entry that has `personal` — the smallest edit that
|
||||
makes them green and still test the mechanism. That is a test fix, not a redesign.
|
||||
|
||||
### How to decide the three, if you want a default
|
||||
|
||||
Each of these is "close the platform gap" or "leave the piece behind". **Closing the gap is better when
|
||||
you have the runway**, because every later plugin needs it too — but a music that works with cliamp left
|
||||
in the platform is worth far more than a perfect design that did not land.
|
||||
|
||||
| Part | Close the gap | Leave it behind |
|
||||
| --- | --- | --- |
|
||||
| ~~websockets~~ | — | **out of scope. Do not spend a minute on cliamp.** |
|
||||
| ~~widget~~ | — | **out of scope. Leave `widgets/MusicPlayer` exactly as it is.** |
|
||||
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated as it already is |
|
||||
|
||||
**One open call, then.** The player overlay is the only judgement left, and either answer is fine.
|
||||
|
||||
Whatever you pick, the plugin must **install, enable, disable and uninstall cleanly** at the end. A piece
|
||||
left in the platform is a documented seam; a piece left dangling is a bug.
|
||||
|
||||
`assertCapabilityTotality` is **not** part of this. It is worth fixing eventually and the cliamp routes
|
||||
are the live example of the drift it cannot see, but both are someone else's evening.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Still open, platform-wide. Do not rediscover these
|
||||
|
||||
- **Websocket providers** — `server.reload({ routes })` proven, never called
|
||||
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||
owner's. Its own job, not the platform's.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
|
||||
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||
//
|
||||
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||
// the smallest thing that is still a real one: a manifest and one route.
|
||||
//
|
||||
// Everything structural is convention, so this directory IS the documentation:
|
||||
//
|
||||
// manifest.ts you are here — only what a directory listing cannot say
|
||||
// api/router.ts exports `router`; mounted at /api/example
|
||||
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||
// web/Router.tsx a frontend, if it had one
|
||||
//
|
||||
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||
// code sits.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Example',
|
||||
summary: 'The reference plugin — one route, nothing else',
|
||||
icon: 'Puzzle',
|
||||
color: '#94a3b8',
|
||||
|
||||
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||
permissions: [
|
||||
{
|
||||
key: 'example',
|
||||
label: 'Example',
|
||||
description: 'The reference plugin',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// The reference sidecar: a long-lived process PM2 supervises.
|
||||
//
|
||||
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||
//
|
||||
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||
// installer, and adding a socket here would test Bun rather than that.
|
||||
|
||||
const name = 'officer-example';
|
||||
console.log(`[${name}] started (pid ${process.pid})`);
|
||||
|
||||
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[${name}] ${signal} — exiting`);
|
||||
clearInterval(beat);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
// The second panel, reading the URL rather than being told by its sibling.
|
||||
//
|
||||
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||
// independently; nothing is passed between them, so they cannot disagree.
|
||||
export const ExampleDetail = () => {
|
||||
const { section } = useParams();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-duck-dark/40">
|
||||
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||
the panel beside it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||
//
|
||||
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||
export const ExampleOverview = () => {
|
||||
const client = useClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['example', 'ping'],
|
||||
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||
//
|
||||
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'example-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Puzzle, ListTree } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { ExampleOverview } from './ExampleOverview';
|
||||
import { ExampleDetail } from './ExampleDetail';
|
||||
|
||||
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||
//
|
||||
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||
// a rule someone has to remember.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,732 @@
|
||||
# Offscale — the first real plugin
|
||||
|
||||
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||
|
||||
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||
history of something it no longer knows exists.
|
||||
|
||||
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
|
||||
|
||||
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||
|
||||
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||
|
||||
---
|
||||
|
||||
## The reframe
|
||||
|
||||
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||
intuition is that it is one plugin requiring two sidecars.
|
||||
|
||||
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||
written on runs five.
|
||||
|
||||
### Two words, because "core" was doing two jobs
|
||||
|
||||
- **baseline** — what a fresh install actually runs
|
||||
- **first-party** — what Officer Dev publishes
|
||||
|
||||
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||
"is X core?" a question with two answers.
|
||||
|
||||
---
|
||||
|
||||
## What a plugin is made of
|
||||
|
||||
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||
|
||||
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||
- an **ID** (see below)
|
||||
- a **sidecar**
|
||||
- a **backend router** and its routes
|
||||
- a **db schema**
|
||||
- **default permissions per user group**
|
||||
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||
- a **frontend router**, its routes, and the frontend code
|
||||
- how it **mounts into the file browser context menu**
|
||||
- a set of **capabilities added to officer-items**
|
||||
- **plugin settings page** definitions
|
||||
- an accompanying **mobile app**
|
||||
|
||||
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||
|
||||
### What offscale needs
|
||||
|
||||
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||
|
||||
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
|
||||
|
||||
---
|
||||
|
||||
## Identity and routing
|
||||
|
||||
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||
|
||||
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||
|
||||
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||
Needed because a private plugin never asks the marketplace anything.
|
||||
|
||||
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||
restrictions; publishing is what invites review.
|
||||
|
||||
### Mount prefixes
|
||||
|
||||
```
|
||||
first-party /api/<app-name> e.g. /api/offscale
|
||||
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||
```
|
||||
|
||||
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||
|
||||
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||
become two systems, and only one of them gets tested.
|
||||
|
||||
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||
adding core routes forever without breaking installs.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||
|
||||
### A Postgres schema per plugin was tested and rejected
|
||||
|
||||
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||
earns. Recorded so nobody re-runs the experiment:
|
||||
|
||||
| Property | Result |
|
||||
| ----------------------------------------------------------------- | ------------------------- |
|
||||
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||
| Cross-schema FK to `public.users` | works |
|
||||
| Partial unique index preserved | works |
|
||||
| Push is idempotent, no spurious re-creation | works |
|
||||
| Cascade delete across the schema boundary | works |
|
||||
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||
|
||||
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||
naming the schema in `schemaFilter` made the identical push work.
|
||||
|
||||
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||
costume of a normal result.
|
||||
|
||||
---
|
||||
|
||||
## Mounting — rebuild and swap, at runtime
|
||||
|
||||
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||
|
||||
### What was actually tested
|
||||
|
||||
| Router | `app.route()` after serving has begun |
|
||||
| -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||
| `RegExpRouter` | **throws**, same reason |
|
||||
| `TrieRouter` | works |
|
||||
| `PatternRouter` | works |
|
||||
|
||||
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||
remove a route**, which uninstall needs.
|
||||
|
||||
### The approach that solves both
|
||||
|
||||
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||
|
||||
```ts
|
||||
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||
|
||||
// install: app = buildApp([...installed, 'offscale'])
|
||||
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||
```
|
||||
|
||||
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||
|
||||
```
|
||||
no plugins /offscale/x -> 404 | /core -> 200
|
||||
installed /offscale/x -> 200 | /core -> 200
|
||||
uninstalled /offscale/x -> 404 | /core -> 200
|
||||
```
|
||||
|
||||
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||
|
||||
### The one line that has to change
|
||||
|
||||
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||
This is the whole mechanical cost.
|
||||
|
||||
### Websockets are a separate table, and they reload
|
||||
|
||||
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||
|
||||
```
|
||||
before reload /api/offscale/ws -> refused | /core -> 200
|
||||
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||
after remove /api/offscale/ws -> refused | /core -> 200
|
||||
```
|
||||
|
||||
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||
|
||||
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||
before a plugin install can interrupt somebody's terminal.
|
||||
|
||||
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||
|
||||
### What this means for `assertCapabilityTotality`
|
||||
|
||||
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||
where mounting actually happens instead of once at start-up.
|
||||
|
||||
Two things it must survive, both live today:
|
||||
|
||||
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||
still needs pointing at reality.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
|
||||
|
||||
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||
platform's to assign.
|
||||
|
||||
### The platform grants read or write. Everything richer is the plugin's own job
|
||||
|
||||
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
|
||||
the surface; `write` permits everything.
|
||||
|
||||
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||
means; the platform only knows whether this account got through the door.
|
||||
|
||||
### Offscale v1 uses that model exactly, with nothing added
|
||||
|
||||
One shared resource, role-gated:
|
||||
|
||||
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||
- **write** — can change them, including deleting a server the owner registered
|
||||
|
||||
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||
|
||||
Two implementation consequences, both inside the plugin:
|
||||
|
||||
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||
|
||||
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||
|
||||
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||
delete, policy `PUT` — is a genuine write.
|
||||
|
||||
### Music is where the richer model gets designed
|
||||
|
||||
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||
where offscale's is just "the owner's".
|
||||
|
||||
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||
|
||||
### Three different things are called "capability" here
|
||||
|
||||
A manifest needs three names, not one:
|
||||
|
||||
1. `capabilities/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||
3. `sidecar-registry` `capabilities: ['music']` — **routing keys** for `sendCommand`
|
||||
|
||||
Offscale needs (1) and (3), and not (2).
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||
|
||||
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||
parties touch it.
|
||||
|
||||
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||
the host:
|
||||
|
||||
```ts
|
||||
import { db } from '../db'; // the connection
|
||||
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||
```
|
||||
|
||||
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||
the pilot rather than being invented.
|
||||
|
||||
---
|
||||
|
||||
## `/api/vpn` is being deleted
|
||||
|
||||
Officer had two headscale surfaces:
|
||||
|
||||
| | `/api/vpn` | `/api/headscale` |
|
||||
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||
| surface | one route, `POST /enroll` | the whole admin API |
|
||||
|
||||
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||
anywhere.** Verified against the mobile monorepo:
|
||||
|
||||
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||
3. `embedded` is optional and defaults to `false`
|
||||
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||
|
||||
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||
does the Officer web app. The live database holds no `vpn` grants.
|
||||
|
||||
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||
|
||||
### Everything collapses to one namespace
|
||||
|
||||
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||
contract has no counterparty.
|
||||
|
||||
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||
|
||||
- **phone → Companion** — untouched by anything here
|
||||
- **web admin → Officer → sidecar** — ours to rename freely
|
||||
|
||||
### There are THREE components, not two
|
||||
|
||||
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||
|
||||
| Component | Repo | Enrolment surface |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||
|
||||
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||
|
||||
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||
in the invite (`https://<domain>/officer-api`).
|
||||
|
||||
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||
`POST /_officer/enroll` handler below it is dead.
|
||||
|
||||
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||
|
||||
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||
is correct. The invite flow supersedes it completely:
|
||||
|
||||
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||
3. the Companion turns that into the redirect the phone app claims
|
||||
4. the device joins
|
||||
|
||||
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
|
||||
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||
|
||||
---
|
||||
|
||||
## What headscale actually is — the inventory
|
||||
|
||||
Read end to end on 2026-08-14. This is what has to move.
|
||||
|
||||
### Backend — 2,406 lines
|
||||
|
||||
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||
|
||||
Three things worth knowing before touching it:
|
||||
|
||||
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||
undo.
|
||||
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||
branches on whether the body parses.
|
||||
|
||||
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||
|
||||
### Frontend — 29 files, 27 endpoints
|
||||
|
||||
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Diagnostics · Console.
|
||||
|
||||
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||
|
||||
The whole frontend↔host coupling, which becomes the plugin API:
|
||||
|
||||
| Import | Why it matters |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||
| `AppRegistryMeta` | the panel-contribution contract |
|
||||
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
|
||||
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||
|
||||
### `assist.ts` travels, but stays unwired
|
||||
|
||||
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||
later. Do not tidy it away as unused code.
|
||||
|
||||
---
|
||||
|
||||
## The manifest — proposal
|
||||
|
||||
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||
is the floor, not the ceiling.
|
||||
|
||||
```ts
|
||||
// plugins/offscale/manifest.ts
|
||||
export const manifest = {
|
||||
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||
publisher: 'officerdev',
|
||||
/** The plugin's own semver. Updates compare against this. */
|
||||
version: '1.0.0',
|
||||
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
|
||||
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes and ACLs',
|
||||
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
```
|
||||
|
||||
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||
|
||||
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||
|
||||
```
|
||||
web/panels.ts exports appRegistryMetas — at least one panel
|
||||
web/layout.ts exports defaultLayout — how they are arranged
|
||||
```
|
||||
|
||||
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||
name and with the reason:
|
||||
|
||||
```
|
||||
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||
```
|
||||
|
||||
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||
is nowhere to put a screen.
|
||||
|
||||
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||
than rendering another plugin's panel inside this one.
|
||||
|
||||
### Everything the tree can say, the tree says
|
||||
|
||||
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||
|
||||
| Path | Means |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||
|
||||
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||
duplication that could only ever drift.
|
||||
|
||||
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||
field that can contradict the file it describes.
|
||||
|
||||
### Install asks nothing, and that is the default
|
||||
|
||||
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||
and no external service to point at.
|
||||
|
||||
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||
how it works today.
|
||||
|
||||
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||
|
||||
### Dropped from the first draft
|
||||
|
||||
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||
when something consumes it.
|
||||
- **`kind`** — see below.
|
||||
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||
|
||||
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||
|
||||
### `admin` has to be allowed, and the pilot proved it immediately
|
||||
|
||||
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||
|
||||
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||
nothing is gained by forbidding it.
|
||||
|
||||
Corrected rule:
|
||||
|
||||
| Kind | May a plugin declare it? | Why |
|
||||
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `app` | yes | the ordinary grantable surface |
|
||||
| `admin` | yes | self-restriction, never an escalation |
|
||||
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||
|
||||
### One function decides the prefix
|
||||
|
||||
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||
|
||||
```ts
|
||||
const mountPrefix = (m: Manifest) =>
|
||||
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||
```
|
||||
|
||||
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||
|
||||
### Notes on the fields
|
||||
|
||||
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||
degrade. This is for the human reading the store.
|
||||
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||
|
||||
---
|
||||
|
||||
## What is built — complete, as of 2026-08-15
|
||||
|
||||
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||
time.
|
||||
|
||||
| Piece | Where |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||
| Capability registration | `capabilities/registry.ts` → `setPluginCapabilities` |
|
||||
| Install state | `plugin_installs` |
|
||||
| The screen | `/plugins`, two panels, SSE log |
|
||||
| The reference plugin | `plugins/example/` |
|
||||
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||
|
||||
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
|
||||
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||
|
||||
### Three bugs the extraction found
|
||||
|
||||
Worth recording because none were visible from reading:
|
||||
|
||||
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||
has no listener to announce. Install and enable now mount first.
|
||||
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||
|
||||
### Still open
|
||||
|
||||
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||
still the hardcoded providers. No plugin owns a socket yet.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||
`buildHonoApp`, which is now the single place routes are mounted.
|
||||
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||
plugin system. One when the app store is rebuilt on this.
|
||||
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||
|
||||
---
|
||||
|
||||
## The state of the app store, as found
|
||||
|
||||
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||
|
||||
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||
|
||||
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||
> and it is not built."_
|
||||
|
||||
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||
|
||||
Also found:
|
||||
|
||||
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||
`bun db:push`.
|
||||
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||
that the directory exists.
|
||||
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||
means editing two files and rebuilding.
|
||||
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||
renders against it. Not to be confused with any of the above.
|
||||
|
||||
---
|
||||
|
||||
## Where the code lives
|
||||
|
||||
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||
|
||||
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||
`extensions/` may become plugins later.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||
platform checkout, so dev-time and build-time are the same mechanism.
|
||||
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||
future, not before.
|
||||
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||
Console section does not travel with the plugin.
|
||||
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
|
||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||
// this file must never grow app logic.
|
||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'headscale',
|
||||
prefix: '/api/headscale',
|
||||
prefix: '/api/offscale',
|
||||
});
|
||||
|
||||
export const headscaleRouter = proxy.router;
|
||||
export const router = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { db } from 'officerdb/db';
|
||||
import { headscaleServers } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../auth/schema';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||
//
|
||||
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||
// distinct product rather than a badge on someone else's.
|
||||
//
|
||||
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||
//
|
||||
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||
// db/ offscale_servers, and the only table this plugin owns
|
||||
// web/ panels and a layout; the shell renders the Workspace
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||
//
|
||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||
// That is a change inside these queries, not a flag on the manifest.
|
||||
//
|
||||
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
|
||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||
//
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
|
||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled once:
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||
|
||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import type { OfficerUser } from './normalize';
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
import { arrayField, toUser } from './normalize';
|
||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||
//
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// DELETE /_officer/keys/:id delete outright
|
||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||
// for a joining device. userId is only required when the server
|
||||
// has more than one user; reached via /api/vpn/enroll.
|
||||
// has more than one user.
|
||||
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||
// which is deleted. Kept because it is the handler a route under
|
||||
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||
// IS live — dispatches through the same function.
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
} from 'officerdb';
|
||||
} from '../db/queries';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||
import { headscaleSectionPath } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { TerminalView } from '../Terminal/Terminal';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { Button } from './Cards';
|
||||
|
||||
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'headscale-root',
|
||||
id: 'offscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||
import { HeadscaleNav } from './HeadscaleNav';
|
||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||
+1
-1
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
||||
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||
|
||||
const BASE = '/headscale/_officer/companion';
|
||||
const BASE = '/offscale/_officer/companion';
|
||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||
|
||||
/**
|
||||
+15
-18
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: NODES_KEY,
|
||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
|
||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||
refetchInterval: 20_000,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setTags = useMutation({
|
||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
|
||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||
const moveToUser = useMutation({
|
||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
||||
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
|
||||
// because Headscale's approve_routes replaces the whole set.
|
||||
const toggleRoute = useMutation({
|
||||
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const expire = useMutation({
|
||||
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
|
||||
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: USERS_KEY,
|
||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
|
||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
||||
post('/headscale/_officer/users', input),
|
||||
post('/offscale/_officer/users', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
post(`/headscale/_officer/users/${id}/rename`, { name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: KEYS_KEY,
|
||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
|
||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
|
||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||
const create = useMutation({
|
||||
mutationFn: (input: CreateKeyInput) =>
|
||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
|
||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const expire = useMutation({
|
||||
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
|
||||
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
||||
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||
|
||||
const BASE = '/headscale/_officer/enroll/invites';
|
||||
const BASE = '/offscale/_officer/enroll/invites';
|
||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
||||
|
||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||
|
||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
||||
const PATH = '/headscale/_officer/policy';
|
||||
const PATH = '/offscale/_officer/policy';
|
||||
|
||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||
+2
-2
@@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||
const EMPTY: HeadscaleServer[] = [];
|
||||
|
||||
const BASE = '/headscale/_officer/servers';
|
||||
const BASE = '/offscale/_officer/servers';
|
||||
|
||||
/**
|
||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||
@@ -89,7 +89,7 @@ export function useHeadscaleServers() {
|
||||
export function useHeadscaleSshTest() {
|
||||
const { post } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
|
||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,23 +96,24 @@ pkgs_core() {
|
||||
# separate decision from removing the tool that wanted it.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
echo gnupg git jq wget btop htop tree ripgrep fd eza
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -2311,6 +2311,41 @@ EOF
|
||||
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
||||
fi
|
||||
|
||||
# ── Keys ──
|
||||
#
|
||||
# This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
|
||||
# PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
|
||||
# the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
|
||||
# history-prefix search, no Home/End. Confirmed on this machine before writing it, with
|
||||
# `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
|
||||
#
|
||||
# The two files are still separate — one is a template the platform copies, the other is an
|
||||
# idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
|
||||
# web terminal and neither should have to learn which account they are on.
|
||||
if append_once "$ZSHRC" keybindings <<'EOF'
|
||||
bindkey -e
|
||||
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
|
||||
zle -N up-line-or-beginning-search
|
||||
zle -N down-line-or-beginning-search
|
||||
bindkey '^[[A' up-line-or-beginning-search
|
||||
bindkey '^[[B' down-line-or-beginning-search
|
||||
bindkey '^[[1;5C' forward-word
|
||||
bindkey '^[[1;5D' backward-word
|
||||
bindkey '^[[1;3C' forward-word
|
||||
bindkey '^[[1;3D' backward-word
|
||||
bindkey '^[[3~' delete-char
|
||||
bindkey '^[[H' beginning-of-line
|
||||
bindkey '^[[F' end-of-line
|
||||
bindkey '^[[1~' beginning-of-line
|
||||
bindkey '^[[4~' end-of-line
|
||||
bindkey '^H' backward-kill-word
|
||||
bindkey '^[^?' backward-kill-word
|
||||
bindkey '^[[3;5~' kill-word
|
||||
EOF
|
||||
then
|
||||
ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
|
||||
fi
|
||||
|
||||
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
||||
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
||||
# failed to install leaves the owner with no working `ls` at all, in every new
|
||||
|
||||
@@ -446,6 +446,11 @@ if ! skip; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
# The client goes on the HOST, before any of the container work, because it is the half
|
||||
# that is not in the container. A member has their own Postgres role and no access to the
|
||||
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
|
||||
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
# ── The CLIENT, on the host, matching the server in the container ──
|
||||
#
|
||||
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||
# owner's Docker socket.
|
||||
#
|
||||
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
|
||||
# archive package is not merely old, it is unusable for dumps. That is also why this lives
|
||||
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||
# one place to bump.
|
||||
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||
|
||||
pg_client_installed() {
|
||||
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||
}
|
||||
|
||||
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
return 1
|
||||
}
|
||||
|
||||
if pg_client_installed; then
|
||||
ok "psql ${major} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||
warn "could not fetch the PostgreSQL signing key"
|
||||
return 1
|
||||
}
|
||||
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||
>/etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||
warn "postgresql-client-${major} did not install"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||
# succeed while holding an older client back.
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||
}
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
|
||||
@@ -44,7 +44,6 @@ CORE_PROCESSES=(
|
||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
|
||||
)
|
||||
|
||||
write_ecosystem() {
|
||||
|
||||
@@ -24,6 +24,12 @@ bind - split-window -v
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||
# in one TCP frame, which over a websocket relay it always does.
|
||||
set -sg escape-time 10
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useAuth } from 'hooks/useAuth';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
import { useServerEnvironment } from 'state/useServerEnvironment';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
@@ -59,11 +63,24 @@ export function App() {
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.appName}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
])}
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ResetPassword() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
|
||||
@@ -105,4 +105,3 @@ const validateForm = (state: Partial<LoginFormState>) => {
|
||||
if (!email || !password) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<div className="absolute inset-0 z-20">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
{children}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DuckAvatar } from "./DuckAvatar";
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
import { DuckAvatar } from './DuckAvatar';
|
||||
import { PixelGrid } from '@/components/PixelGrid';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
const landscapebg = '/landscape1.webp';
|
||||
|
||||
@@ -19,4 +19,4 @@ export function Background() {
|
||||
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,4 +14,3 @@ export const SignoutScreen = () => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
|
||||
// Poll the registry (harness task files + announced detached jobs).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
|
||||
const tick = () =>
|
||||
get<Registry>('/activity/tasks')
|
||||
.then((r) => {
|
||||
if (alive) {
|
||||
setReg(r);
|
||||
setRegLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
tick();
|
||||
const iv = setInterval(tick, POLL_MS);
|
||||
return () => { alive = false; clearInterval(iv); };
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The row backing the open id, and the stream query it implies. A string rather than the row object,
|
||||
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
|
||||
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
|
||||
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
|
||||
const row = selectedId
|
||||
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
|
||||
: undefined;
|
||||
const query = !row
|
||||
? null
|
||||
: row.source === 'harness'
|
||||
? `task=${encodeURIComponent(row.id)}`
|
||||
: `path=${encodeURIComponent(row.path)}`;
|
||||
|
||||
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
||||
useEffect(() => {
|
||||
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
|
||||
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
|
||||
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch { /* ignore */ }
|
||||
else if (d.kind === 'line' && typeof d.text === 'string')
|
||||
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, [query, token]);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||||
}, [lines]);
|
||||
|
||||
const rowCls = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
|
||||
@@ -68,20 +90,36 @@ export const ActivityScreen = () => {
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
|
||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
|
||||
<Link
|
||||
key={d.id}
|
||||
to={`/activity/${encodeURIComponent(d.id)}`}
|
||||
className={rowCls(selectedId === d.id)}
|
||||
title={d.path}
|
||||
>
|
||||
<FileText size={13} className="shrink-0" />
|
||||
<span className="truncate">{d.id}</span>
|
||||
</Link>
|
||||
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
|
||||
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
|
||||
{progress.status ? ` (${progress.status})` : ''}
|
||||
</span>
|
||||
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
|
||||
<span className="shrink-0 pl-2">
|
||||
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
|
||||
</span>
|
||||
</div>
|
||||
{typeof progress.pct === 'number' && (
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{query ? 'waiting for output…' : regLoaded ? (
|
||||
{query ? (
|
||||
'waiting for output…'
|
||||
) : regLoaded ? (
|
||||
<>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or it never started.{' '}
|
||||
<Link to="/activity" className="underline">Back to the list</Link>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
</Link>
|
||||
</>
|
||||
) : 'loading…'}
|
||||
) : (
|
||||
'loading…'
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-words">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,13 @@ export const TabPreview = () => {
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* URL bar */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => void refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<form
|
||||
@@ -104,7 +110,13 @@ export const TabPreview = () => {
|
||||
placeholder="Navigate to URL..."
|
||||
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
type="submit"
|
||||
disabled={isNavigating || !navUrl.trim()}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</form>
|
||||
@@ -143,7 +155,13 @@ export const TabPreview = () => {
|
||||
placeholder="Evaluate JavaScript..."
|
||||
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 shrink-0"
|
||||
type="submit"
|
||||
disabled={isEvaluating || !evalExpr.trim()}
|
||||
>
|
||||
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
|
||||
type Contact = { address: string; name: string };
|
||||
|
||||
// A recipient field with contact autocomplete on the last comma-separated segment.
|
||||
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
|
||||
const RecipientInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
autoFocus?: boolean;
|
||||
}) => {
|
||||
const client = useClient();
|
||||
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
|
||||
client
|
||||
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
|
||||
.then(setSuggestions)
|
||||
.catch(() => {});
|
||||
}, 180);
|
||||
return () => clearTimeout(t);
|
||||
}, [seg]);
|
||||
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
|
||||
inlineMap.current.clear();
|
||||
nextImgId.current = 0;
|
||||
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
||||
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||
if (editorRef.current)
|
||||
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||
refreshEmpty();
|
||||
}
|
||||
if (!draft) seeded.current = null;
|
||||
}, [draft]);
|
||||
|
||||
// Clipboard images often come nameless — give them a sensible filename.
|
||||
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
|
||||
const named = (f: File) =>
|
||||
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
|
||||
|
||||
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
||||
const addAttachments = (incoming: FileList | File[]) => {
|
||||
@@ -248,7 +263,8 @@ export const ComposeModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
||||
const fmtSize = (n: number) =>
|
||||
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
@@ -315,7 +331,9 @@ export const ComposeModal = () => {
|
||||
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>}
|
||||
{bodyEmpty && (
|
||||
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
||||
<button
|
||||
onClick={close}
|
||||
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -382,12 +403,21 @@ export const ComposeModal = () => {
|
||||
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
||||
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
||||
// is a follow-up: store the Message-Id header on ingest.)
|
||||
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
|
||||
export const replyDraft = (m: {
|
||||
from: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
text?: string;
|
||||
snippet?: string;
|
||||
}): ComposeDraft => {
|
||||
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
||||
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
||||
const original = (m.text || m.snippet || '').trim();
|
||||
const quoted = original
|
||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
|
||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
|
||||
.split('\n')
|
||||
.map((l) => `> ${l}`)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
return { to: addr, subject, body: quoted };
|
||||
};
|
||||
|
||||
@@ -63,8 +63,13 @@ type MessagePanelProps = {
|
||||
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
|
||||
if (!open) {
|
||||
return (
|
||||
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
|
||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
|
||||
>
|
||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
|
||||
{senderName(message.from)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
|
||||
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
|
||||
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
|
||||
@@ -114,7 +119,11 @@ const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: Me
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
|
||||
{message.html ? (
|
||||
<HtmlBody html={message.html} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -209,7 +218,11 @@ export const EmailReader = () => {
|
||||
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
||||
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
||||
{openAttachment && (
|
||||
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
|
||||
<FileViewerProvider
|
||||
filePath={openAttachment.filePath}
|
||||
fileName={openAttachment.fileName}
|
||||
root={openAttachment.root}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
||||
<FileViewerHeader />
|
||||
</div>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker
|
||||
// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view
|
||||
// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale
|
||||
// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live
|
||||
// in the sidecar.
|
||||
//
|
||||
// The open section is :section in the URL, so every panel reads it with useParams instead of passing it
|
||||
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
|
||||
// single place that decides what an absent or bogus section means.
|
||||
|
||||
function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||
if (node.type === 'panel') return node.appType === appType;
|
||||
return node.children.some((c) => hasAppType(c.node, appType));
|
||||
}
|
||||
|
||||
export const HeadscaleScreen = () => {
|
||||
const { section } = useParams();
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
||||
|
||||
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
|
||||
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
|
||||
// cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is
|
||||
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
|
||||
const workspace = useMemo(() => {
|
||||
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: defaultLayout };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
// Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL, so a URL
|
||||
// that names nothing would leave nothing highlighted.
|
||||
if (!isHeadscaleSection(section)) {
|
||||
return <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{
|
||||
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
|
||||
fallback: 'headscale-view',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './HeadscaleScreen';
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
|
||||
ChevronRight, Wrench,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Square,
|
||||
ChevronRight,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
@@ -53,21 +61,58 @@ type JobData = {
|
||||
// Output entries for the right panel
|
||||
type OutputEntry =
|
||||
| { id: string; type: 'text'; text: string }
|
||||
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
|
||||
| {
|
||||
id: string;
|
||||
type: 'tool';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
|
||||
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'step:start';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iteration?: { current: number; total: number; label: string };
|
||||
}
|
||||
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
|
||||
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'step:parallel';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iterations: string[];
|
||||
concurrency: number;
|
||||
}
|
||||
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
|
||||
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
|
||||
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'tool:start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'tool:result';
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
|
||||
| { jobId: string; type: 'error'; message: string }
|
||||
| { jobId: string; type: 'stopped' }
|
||||
@@ -86,7 +131,7 @@ const formatElapsed = (seconds: number) => {
|
||||
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
|
||||
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
||||
|
||||
/** Build a unique key for grouping output by step/iteration */
|
||||
const outputKey = (stepIndex: number, iterationLabel?: string) =>
|
||||
@@ -128,15 +173,12 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
|
||||
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
|
||||
{entry.output !== undefined && (
|
||||
<StatusIcon
|
||||
status={entry.isError ? 'error' : 'complete'}
|
||||
className="h-3 w-3 shrink-0 ml-auto"
|
||||
/>
|
||||
<StatusIcon status={entry.isError ? 'error' : 'complete'} className="h-3 w-3 shrink-0 ml-auto" />
|
||||
)}
|
||||
{entry.output === undefined && (
|
||||
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
|
||||
)}
|
||||
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
{entry.output === undefined && <Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />}
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
||||
@@ -149,7 +191,9 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
{entry.output !== undefined && (
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
||||
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -194,9 +238,18 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
||||
const StepsPanel = () => {
|
||||
const ctx = useJobPanel();
|
||||
const {
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
|
||||
skippedItems, outputMap,
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
jobStatus,
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
@@ -261,16 +314,16 @@ const StepsPanel = () => {
|
||||
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
|
||||
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
|
||||
{it.cost && (
|
||||
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
|
||||
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">
|
||||
{formatCost(it.cost.totalUSD)}
|
||||
</span>
|
||||
)}
|
||||
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{skippedItems.length > 0 && (
|
||||
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
|
||||
{skippedItems.length} skipped
|
||||
</div>
|
||||
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -294,7 +347,9 @@ const OutputPanel = () => {
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
||||
{selectedKey && (
|
||||
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
|
||||
<span className="text-xs text-duck-dark/40 truncate">
|
||||
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
||||
@@ -316,9 +371,7 @@ const OutputPanel = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ToolCallEntry key={entry.id} entry={entry} />
|
||||
);
|
||||
return <ToolCallEntry key={entry.id} entry={entry} />;
|
||||
})}
|
||||
{selectedStreaming && (
|
||||
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
|
||||
@@ -365,7 +418,10 @@ export const PipelineJobDetail = () => {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addCost = useCallback((cost: Cost) => {
|
||||
@@ -390,9 +446,10 @@ export const PipelineJobDetail = () => {
|
||||
const arr = prev.get(key);
|
||||
if (!arr) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(key, arr.map((e) =>
|
||||
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
|
||||
));
|
||||
next.set(
|
||||
key,
|
||||
arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -461,181 +518,205 @@ export const PipelineJobDetail = () => {
|
||||
};
|
||||
}, [id, job?.status]);
|
||||
|
||||
const handleEvent = useCallback((msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'job:state':
|
||||
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
||||
const handleEvent = useCallback(
|
||||
(msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'job:state':
|
||||
if (
|
||||
msg.status === 'completed' ||
|
||||
msg.status === 'failed' ||
|
||||
msg.status === 'stopped' ||
|
||||
msg.status === 'interrupted'
|
||||
) {
|
||||
setLiveStatus('done');
|
||||
if (msg.cost) setTotalCost(msg.cost as Cost);
|
||||
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
|
||||
stopTimer();
|
||||
if (id) {
|
||||
client
|
||||
.get<JobData>(`/pipeline-jobs/${id}`)
|
||||
.then(setJob)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
if (msg.progress) {
|
||||
const p = msg.progress as ProgressData;
|
||||
if (p?.steps) setSteps(p.steps);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pipeline:init':
|
||||
setIsLive(true);
|
||||
setSteps(msg.steps);
|
||||
break;
|
||||
|
||||
case 'step:start': {
|
||||
setParallelStep(null);
|
||||
setActiveStepIndex(msg.stepIndex);
|
||||
const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex);
|
||||
if (autoFollowRef.current) setSelectedKey(key);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'step:complete':
|
||||
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
|
||||
setParallelStep(null);
|
||||
setActiveStepIndex(-1);
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
// Flush any remaining stream buffer for this step
|
||||
flushStreamBuffer(outputKey(msg.stepIndex));
|
||||
break;
|
||||
|
||||
case 'step:skip':
|
||||
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
||||
break;
|
||||
|
||||
case 'step:parallel':
|
||||
setActiveStepIndex(msg.stepIndex);
|
||||
setParallelStep({
|
||||
stepIndex: msg.stepIndex,
|
||||
taskName: msg.taskName,
|
||||
concurrency: msg.concurrency,
|
||||
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
|
||||
});
|
||||
// Auto-select first iteration
|
||||
if (autoFollowRef.current && msg.iterations.length > 0) {
|
||||
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'iteration:start':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'iteration:complete':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
||||
break;
|
||||
|
||||
case 'iteration:error':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
||||
break;
|
||||
|
||||
case 'assistant:delta': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const buf = streamBuffers.current;
|
||||
buf.set(key, (buf.get(key) ?? '') + msg.text);
|
||||
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant:text': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const text = msg.text || streamBuffers.current.get(key) || '';
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
}
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => {
|
||||
const n = new Map(prev);
|
||||
n.delete(key);
|
||||
return n;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
// Flush any streaming text before the tool call
|
||||
flushStreamBuffer(key);
|
||||
appendOutput(key, {
|
||||
id: randomId(),
|
||||
type: 'tool',
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:result': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pipeline:complete':
|
||||
setTotalCost(msg.totalCost);
|
||||
setLiveStatus('done');
|
||||
if (msg.cost) setTotalCost(msg.cost as Cost);
|
||||
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
setSteps((s) => {
|
||||
s.forEach((_, i) => next.add(i));
|
||||
return s;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setActiveStepIndex(-1);
|
||||
setParallelStep(null);
|
||||
stopTimer();
|
||||
if (id) {
|
||||
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
||||
client
|
||||
.get<JobData>(`/pipeline-jobs/${id}`)
|
||||
.then(setJob)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
if (msg.progress) {
|
||||
const p = msg.progress as ProgressData;
|
||||
if (p?.steps) setSteps(p.steps);
|
||||
}
|
||||
break;
|
||||
break;
|
||||
|
||||
case 'pipeline:init':
|
||||
setIsLive(true);
|
||||
setSteps(msg.steps);
|
||||
break;
|
||||
case 'error':
|
||||
setHasError(true);
|
||||
setLiveStatus('done');
|
||||
stopTimer();
|
||||
break;
|
||||
|
||||
case 'step:start': {
|
||||
setParallelStep(null);
|
||||
setActiveStepIndex(msg.stepIndex);
|
||||
const key = msg.iteration
|
||||
? outputKey(msg.stepIndex, msg.iteration.label)
|
||||
: outputKey(msg.stepIndex);
|
||||
if (autoFollowRef.current) setSelectedKey(key);
|
||||
break;
|
||||
case 'stopped':
|
||||
setLiveStatus('done');
|
||||
stopTimer();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[id, stopTimer, addCost, appendOutput, updateToolOutput],
|
||||
);
|
||||
|
||||
case 'step:complete':
|
||||
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
|
||||
setParallelStep(null);
|
||||
setActiveStepIndex(-1);
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
// Flush any remaining stream buffer for this step
|
||||
flushStreamBuffer(outputKey(msg.stepIndex));
|
||||
break;
|
||||
|
||||
case 'step:skip':
|
||||
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
||||
break;
|
||||
|
||||
case 'step:parallel':
|
||||
setActiveStepIndex(msg.stepIndex);
|
||||
setParallelStep({
|
||||
stepIndex: msg.stepIndex,
|
||||
taskName: msg.taskName,
|
||||
concurrency: msg.concurrency,
|
||||
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
|
||||
});
|
||||
// Auto-select first iteration
|
||||
if (autoFollowRef.current && msg.iterations.length > 0) {
|
||||
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'iteration:start':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'running' } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'iteration:complete':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
||||
break;
|
||||
|
||||
case 'iteration:error':
|
||||
setParallelStep((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
||||
break;
|
||||
|
||||
case 'assistant:delta': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const buf = streamBuffers.current;
|
||||
buf.set(key, (buf.get(key) ?? '') + msg.text);
|
||||
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant:text': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const text = msg.text || streamBuffers.current.get(key) || '';
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
}
|
||||
const flushStreamBuffer = useCallback(
|
||||
(key: string) => {
|
||||
const text = streamBuffers.current.get(key);
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
// Flush any streaming text before the tool call
|
||||
flushStreamBuffer(key);
|
||||
appendOutput(key, {
|
||||
id: randomId(),
|
||||
type: 'tool',
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
setStreamingMap((prev) => {
|
||||
const n = new Map(prev);
|
||||
n.delete(key);
|
||||
return n;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:result': {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pipeline:complete':
|
||||
setTotalCost(msg.totalCost);
|
||||
setLiveStatus('done');
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
|
||||
return next;
|
||||
});
|
||||
setActiveStepIndex(-1);
|
||||
setParallelStep(null);
|
||||
stopTimer();
|
||||
if (id) {
|
||||
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setHasError(true);
|
||||
setLiveStatus('done');
|
||||
stopTimer();
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
setLiveStatus('done');
|
||||
stopTimer();
|
||||
break;
|
||||
}
|
||||
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
|
||||
|
||||
const flushStreamBuffer = useCallback((key: string) => {
|
||||
const text = streamBuffers.current.get(key);
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
}
|
||||
}, [appendOutput]);
|
||||
},
|
||||
[appendOutput],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
|
||||
@@ -648,10 +729,13 @@ export const PipelineJobDetail = () => {
|
||||
setSelectedKey(key);
|
||||
}, []);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(() => ({
|
||||
steps: StepsPanel,
|
||||
output: OutputPanel,
|
||||
}), []);
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
steps: StepsPanel,
|
||||
output: OutputPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
|
||||
const isRunning = displayStatus === 'running';
|
||||
@@ -659,37 +743,65 @@ export const PipelineJobDetail = () => {
|
||||
const jobDone = !isRunning && !isLive;
|
||||
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
|
||||
|
||||
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
|
||||
stepIndex: progressStepIndex,
|
||||
taskName: job.progress.parallel.taskName,
|
||||
concurrency: job.progress.parallel.concurrency,
|
||||
iterations: job.progress.parallel.iterations.map((it) => ({
|
||||
label: it.label,
|
||||
status: it.status as IterationStatus['status'],
|
||||
})),
|
||||
} : null);
|
||||
const displayParallel: ParallelStep | null =
|
||||
parallelStep ??
|
||||
(jobDone && job?.progress?.parallel
|
||||
? {
|
||||
stepIndex: progressStepIndex,
|
||||
taskName: job.progress.parallel.taskName,
|
||||
concurrency: job.progress.parallel.concurrency,
|
||||
iterations: job.progress.parallel.iterations.map((it) => ({
|
||||
label: it.label,
|
||||
status: it.status as IterationStatus['status'],
|
||||
})),
|
||||
}
|
||||
: null);
|
||||
|
||||
const panelCtx = useMemo<JobPanelContext>(() => ({
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
|
||||
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
|
||||
}), [
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, job?.status, selectedKey, selectOutput,
|
||||
displayParallel, skippedItems, outputMap, streamingMap,
|
||||
]);
|
||||
const panelCtx = useMemo<JobPanelContext>(
|
||||
() => ({
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
jobStatus: job?.status ?? 'pending',
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
streamingMap,
|
||||
outputPanelRef,
|
||||
}),
|
||||
[
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
job?.status,
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
streamingMap,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
|
||||
);
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
||||
<span>Job not found</span>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
||||
Back to jobs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -699,7 +811,9 @@ export const PipelineJobDetail = () => {
|
||||
? elapsed
|
||||
: job.startedAt && job.completedAt
|
||||
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
|
||||
: elapsed > 0 ? elapsed : null;
|
||||
: elapsed > 0
|
||||
? elapsed
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
|
||||
@@ -758,7 +872,9 @@ export const PipelineJobDetail = () => {
|
||||
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
{job.error ?? 'An error occurred during execution'}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
import { PixelGrid } from '@/components/PixelGrid';
|
||||
const landscapebg = '/landscape1.webp';
|
||||
|
||||
export function Background() {
|
||||
@@ -14,4 +14,4 @@ export function Background() {
|
||||
<PixelGrid />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ type BugReportDialogProps = {
|
||||
onSubmit: (description: string) => void;
|
||||
};
|
||||
|
||||
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
|
||||
export const BugReportDialog = ({
|
||||
open,
|
||||
capturing,
|
||||
submitting,
|
||||
screenshot,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BugReportDialogProps) => {
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -177,10 +178,11 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
|
||||
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
|
||||
//
|
||||
// ── The rule, and why it is shape rather than policy ──
|
||||
//
|
||||
// Every plugin route renders a Workspace with at least one panel. A plugin that exported a component
|
||||
// could render anything at all — a bare div, a full-page form, its own navigation — and the platform
|
||||
// would be a shell hosting strangers' layouts rather than one application. So a plugin does not get to
|
||||
// render the screen: it contributes panels and says how they are arranged, and this renders the
|
||||
// Workspace around them.
|
||||
//
|
||||
// Non-compliance is therefore not refused, it is unrepresentable. There is nowhere to put a screen.
|
||||
//
|
||||
// `locked`, like every core screen: a plugin's layout is its author's design, not a workspace the user
|
||||
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
||||
// something else falls back rather than rendering another plugin's panel inside this one.
|
||||
export function PluginScreen({
|
||||
appName,
|
||||
panels,
|
||||
layout,
|
||||
}: {
|
||||
appName: string;
|
||||
panels: AppRegistryMeta[];
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
||||
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
|
||||
const allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView workspace={workspace} locked appTypes={{ allowed, fallback: allowed[0] ?? '' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
|
||||
// handler. This screen is the courtesy half of that.
|
||||
//
|
||||
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
|
||||
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
|
||||
export const PluginsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
|
||||
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
|
||||
// and destroy the side-by-side. See docs/navigation-audit.md.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'plugins-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PluginsScreen';
|
||||
+9
-2
@@ -51,7 +51,9 @@ export const ApifyConfig = () => {
|
||||
<div className="grid gap-5">
|
||||
{status && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
|
||||
<div
|
||||
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
|
||||
/>
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{status.configured ? 'API token configured' : 'Not configured'}
|
||||
</span>
|
||||
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Get your token at{' '}
|
||||
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.apify.com/account/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
console.apify.com/account/integrations
|
||||
</a>
|
||||
</span>
|
||||
|
||||
+14
-14
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
|
||||
{' — '}
|
||||
<a href="/browser" className="text-duck-teal underline">view tabs</a>
|
||||
<a href="/browser" className="text-duck-teal underline">
|
||||
view tabs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<li>
|
||||
Open{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
|
||||
chrome://extensions
|
||||
</code>{' '}
|
||||
in Chrome
|
||||
</li>
|
||||
<li>
|
||||
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => regenerate.mutate()}
|
||||
disabled={regenerate.isPending}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Regenerate
|
||||
</Button>
|
||||
@@ -166,8 +165,8 @@ export const BrowserRelay = () => {
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
|
||||
the tab is connected. Then go to{' '}
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
|
||||
means the tab is connected. Then go to{' '}
|
||||
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
||||
/browser <ExternalLink className="h-3 w-3" />
|
||||
</a>{' '}
|
||||
@@ -189,10 +188,11 @@ type CredentialRowProps = {
|
||||
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
|
||||
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
|
||||
<code className="flex-1 text-xs truncate">
|
||||
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
|
||||
</code>
|
||||
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+1
-4
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
|
||||
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||
>
|
||||
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={account.provider} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
+78
-26
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
|
||||
<p className="mt-1">
|
||||
Go to the{' '}
|
||||
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/projectcreate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
New Project
|
||||
</a>{' '}
|
||||
page. Give it a name (e.g. "Officer") and click <strong>Create</strong>.
|
||||
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
API Library
|
||||
</a>
|
||||
. Search for and enable each of these:
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li><strong>Gmail API</strong></li>
|
||||
<li><strong>Google Calendar API</strong></li>
|
||||
<li>
|
||||
<strong>Gmail API</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google Calendar API</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
|
||||
<p className="mt-1">
|
||||
Click each one, then click <strong>Enable</strong>.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/branding"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Branding
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Set <strong>App name</strong> to your organization name or "Officer"</li>
|
||||
<li>Set <strong>User support email</strong> to your admin email</li>
|
||||
<li>Add your admin email under <strong>Developer contact information</strong></li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
<li>
|
||||
Set <strong>App name</strong> to your organization name or "Officer"
|
||||
</li>
|
||||
<li>
|
||||
Set <strong>User support email</strong> to your admin email
|
||||
</li>
|
||||
<li>
|
||||
Add your admin email under <strong>Developer contact information</strong>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Save</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/audience"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Audience
|
||||
</a>
|
||||
.
|
||||
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
|
||||
</li>
|
||||
<li>
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
|
||||
(required while the app is unverified; limit of 100 test users)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/scopes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Data Access
|
||||
</a>
|
||||
, then click <strong>Add or remove scopes</strong>. Search for and add:
|
||||
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
{SCOPES.map((s) => (
|
||||
<li key={s.scope}>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
||||
{s.scope}
|
||||
</code>{' '}
|
||||
— {s.description}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
|
||||
{s.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
|
||||
<p className="mt-1">
|
||||
Click <strong>Update</strong>, then <strong>Save</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
|
||||
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
|
||||
is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
|
||||
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
|
||||
in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
, then click <strong>Create OAuth client</strong>.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Application type: <strong>Web application</strong></li>
|
||||
<li>
|
||||
Application type: <strong>Web application</strong>
|
||||
</li>
|
||||
<li>Name: anything (e.g. "Officer")</li>
|
||||
<li>
|
||||
Authorized redirect URIs: add{' '}
|
||||
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
{redirectUri}
|
||||
</code>
|
||||
</li>
|
||||
<li>Click <strong>Create</strong></li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
|
||||
<p className="mt-1">
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
|
||||
them into the fields below.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
||||
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
|
||||
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,19 +87,25 @@ export const AIModels = () => {
|
||||
<div className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat from the home screen
|
||||
</p>
|
||||
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat inside a project dashboard
|
||||
</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when running tasks from the file browser
|
||||
</p>
|
||||
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
|
||||
+38
-11
@@ -1,7 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
|
||||
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
await audio.play();
|
||||
setListening('playing');
|
||||
} catch {
|
||||
@@ -120,7 +136,9 @@ export const VoicePreference = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
|
||||
<SelectValue placeholder="Server default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
|
||||
<SelectItem value={SERVER_DEFAULT}>
|
||||
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
|
||||
</SelectItem>
|
||||
{groups.length > 0
|
||||
? groups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
|
||||
<SelectItem key={v} value={v}>
|
||||
{prettify(v)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
+8
-3
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
|
||||
{editingProvider &&
|
||||
(() => {
|
||||
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
|
||||
return null;
|
||||
return (
|
||||
<div key={provider.providerId} className="flex items-center gap-2">
|
||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
||||
@@ -507,7 +510,9 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
|
||||
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
|
||||
provider,
|
||||
fromName,
|
||||
fromEmail,
|
||||
...(provider === 'resend'
|
||||
? { apiKey }
|
||||
: { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Connection failed';
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
|
||||
if (isTesting || !testEmail) return;
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
|
||||
...buildConfig(),
|
||||
to: testEmail,
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Failed to send test email';
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
|
||||
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type Provider = 'openai' | 'elevenlabs';
|
||||
@@ -47,7 +55,11 @@ export const TTSSection = () => {
|
||||
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
|
||||
setVoicesLoading(true);
|
||||
try {
|
||||
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
|
||||
const res = await client.post<{
|
||||
voices?: string[];
|
||||
groups?: { label: string; voices: string[] }[];
|
||||
error?: string;
|
||||
}>('/server-settings/tts/voices', {
|
||||
provider: p,
|
||||
url: u,
|
||||
apiKey: key || undefined,
|
||||
@@ -167,7 +179,9 @@ export const TTSSection = () => {
|
||||
)}
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">
|
||||
API Key {provider === 'openai' ? '(optional)' : ''}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
@@ -197,7 +211,9 @@ export const TTSSection = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{voices.length > 0 ? (
|
||||
@@ -209,16 +225,21 @@ export const TTSSection = () => {
|
||||
{voiceGroups.length > 0
|
||||
? voiceGroups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
|
||||
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
isActive
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
|
||||
<section.icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{section.title}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
|
||||
@@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
|
||||
export const SettingsSidebar = ({
|
||||
basePath,
|
||||
icon: Icon,
|
||||
label,
|
||||
sections,
|
||||
groups,
|
||||
hideHeader,
|
||||
}: SettingsSidebarProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||
{groups
|
||||
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: sections.filter(matchesSearch).map((s) => (
|
||||
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
|
||||
))}
|
||||
: sections
|
||||
.filter(matchesSearch)
|
||||
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
|
||||
groups?: SettingsSectionGroup[];
|
||||
};
|
||||
|
||||
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
|
||||
export const createSettingsPanelComponents = ({
|
||||
basePath,
|
||||
sidebarIcon,
|
||||
sidebarLabel,
|
||||
sections = [],
|
||||
groups,
|
||||
}: CreateSettingsPanelParams) => {
|
||||
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
||||
const Sidebar: ComponentType = () => (
|
||||
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
|
||||
<SettingsSidebar
|
||||
basePath={basePath}
|
||||
icon={sidebarIcon}
|
||||
label={sidebarLabel}
|
||||
sections={allSections}
|
||||
groups={groups}
|
||||
/>
|
||||
);
|
||||
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
|
||||
return { Sidebar, Content, allSections };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './AppStore';
|
||||
export * from './Plugins';
|
||||
export * from './PluginScreen';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
@@ -13,7 +15,6 @@ export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Photos';
|
||||
export * from './Jellyfin';
|
||||
export * from './Transmission';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { seedAppRegistry, seedWidgetRegistry } from 'officerdev';
|
||||
import { plugins } from './Plugins.gen';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
|
||||
import { I18nBridge } from '@/lib/I18nBridge';
|
||||
@@ -20,7 +21,11 @@ const queryClient = new QueryClient({
|
||||
|
||||
// Before the first render, not from a component inside it: a panel that renders before its registry is
|
||||
// populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it.
|
||||
seedAppRegistry(queryClient);
|
||||
// Panels contributed by installed plugins, from the generated module. See servers/plugins/generate.ts.
|
||||
seedAppRegistry(
|
||||
queryClient,
|
||||
plugins.flatMap((p) => p.panels),
|
||||
);
|
||||
seedWidgetRegistry(queryClient);
|
||||
|
||||
const elem = document.getElementById('root')!;
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Officer Dev (Alpha)</title>
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta
|
||||
name="description"
|
||||
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
|
||||
/>
|
||||
|
||||
<!-- OpenGraph. Crawlers fetch these standalone, so they need absolute URLs. __PUBLIC_URL__ is
|
||||
substituted from .env by scripts/gen-index.ts into index.gen.html, which is what the server
|
||||
@@ -12,7 +15,10 @@
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
|
||||
/>
|
||||
<meta property="og:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
|
||||
<meta property="og:image:secure_url" content="__PUBLIC_URL__/og-image-v3.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
@@ -24,7 +30,10 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
|
||||
/>
|
||||
<meta name="twitter:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
|
||||
|
||||
<!-- Favicons & App Icons. These must stay absolute: Bun's HTML bundler treats a root-relative
|
||||
@@ -38,7 +47,10 @@
|
||||
<meta name="theme-color" content="#1F2620" />
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||
<script>eruda.init(); eruda._entryBtn.hide();</script>
|
||||
<script>
|
||||
eruda.init();
|
||||
eruda._entryBtn.hide();
|
||||
</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -23,10 +23,10 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
@import "./globals.css";
|
||||
@import "./prose.css";
|
||||
@import './globals.css';
|
||||
@import './prose.css';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user