app store: a screen, so this can be clicked instead of curled
/app-store, built to the platform's own conventions: a locked WorkspaceView with two panels, the selection in `?selected=` rather than a channel, and rows that are real links so cmd-click and a pasted URL both work. `?selected=` and not a /app-store/:id detail route, per docs/navigation-audit.md: this is a master list with a live preview, and linking rows to a detail route would make the detail the whole page and destroy the side-by-side. Both panels read the URL independently — the list and the detail cannot disagree if neither is telling the other anything. The install form is generated from the catalogue's fields rather than written per service, which is what lets a sidecar shipping from its own repository present a form nobody here wrote. `existing` is first in `modes` by catalogue rule, so the default selection is "I already have one" — the answer that avoids starting a second copy of something already running. States are distinguished rather than flattened. Blocked is amber and titled "Needs you", not an error: everything worked and it is waiting for a token only a person can mint. Installed-and-enabled but with a dead process shows a warning rather than a tick that lies. And the disable/uninstall copy says plainly that data, configuration and tables are kept either way, because that is the question anyone hesitates over before clicking. The dock tile is CORE, not plugin-derived: the store is how every other feature arrives, so it must never be one of the things that disappears. Verified through the API the screen uses — 14 items, email reporting installed/enabled with its process online, and /app-store present in the capability routes so the tile renders. NOT verified in a browser: no page has been opened, so the rendering itself is reasoned rather than seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@ export function App() {
|
||||
<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="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /app-store — what this server can run, what it is running, and the four verbs that change it.
|
||||
//
|
||||
// Owner-only, and gated server-side: every route under /api/app-store refuses a non-owner before it
|
||||
// reaches a handler. This screen is the courtesy half of that, and would show an empty store rather
|
||||
// than a working one if it were ever reached by someone else.
|
||||
//
|
||||
// Which app is open lives in `?selected=`, read by both panels independently rather than passed between
|
||||
// them — the list and the detail cannot disagree if neither is telling the other anything.
|
||||
export const AppStoreScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/app-store', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['app-store-list', 'app-store-detail'], fallback: 'app-store-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// List on the left, detail on the right — a master list with a live preview, which is why the selection
|
||||
// is `?selected=` rather than a detail route: linking rows to /app-store/:id would make the detail the
|
||||
// whole page and destroy the side-by-side. See docs/navigation-audit.md.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'app-store-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'app-store-list', appType: 'app-store-list' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'app-store-detail', appType: 'app-store-detail' }, size: 70 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AppStoreScreen';
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
Contact,
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -176,6 +177,9 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
// 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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './AppStore';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
|
||||
@@ -21,6 +21,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
||||
{ 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('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
|
||||
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
|
||||
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
|
||||
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||
@@ -43,6 +44,7 @@ export const apps = [
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
...appStoreMetas,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
||||
|
||||
// One entry: what it is, how to install it, and what to do about it once installed.
|
||||
//
|
||||
// The install form is generated from the catalogue's fields rather than written per service, which is
|
||||
// what lets a sidecar shipping from its own repository present a form nobody here wrote.
|
||||
|
||||
const modeLabel: Record<InstallMode, string> = {
|
||||
existing: 'I already have one',
|
||||
provisioned: 'Set one up for me',
|
||||
config: 'Configure',
|
||||
};
|
||||
|
||||
const modeHelp: Record<InstallMode, string> = {
|
||||
existing: 'Point Officer at an instance you already run — here, on another machine, or anywhere reachable.',
|
||||
provisioned: 'Officer writes a compose file into your own dockers directory and starts it.',
|
||||
config: 'Nothing to install. Officer just needs the settings.',
|
||||
};
|
||||
|
||||
function Field({ field, value, onChange }: { field: ConfigField; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{field.label}
|
||||
{!field.required && <span className="ml-1 text-xs opacity-60">(optional)</span>}
|
||||
</span>
|
||||
<input
|
||||
type={field.type === 'secret' ? 'password' : 'text'}
|
||||
value={value}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
className="rounded-md border border-input bg-background/80 px-2 py-1.5 text-sm focus:border-duck-teal/50 focus:outline-none focus:ring-2 focus:ring-duck-teal/30"
|
||||
/>
|
||||
{field.help && <span className="text-xs text-muted-foreground/80">{field.help}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppStoreDetail = () => {
|
||||
const [params] = useSearchParams();
|
||||
const id = params.get('selected');
|
||||
const { items, install, setEnabled, uninstall } = useAppStore();
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
const [mode, setMode] = useState<InstallMode | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
|
||||
}
|
||||
|
||||
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
||||
// the answer that avoids starting a second copy of something the user is already running.
|
||||
const chosen = mode ?? item.modes[0]!;
|
||||
const fields = chosen === 'existing' ? (item.existingFields ?? []) : (item.configFields ?? []);
|
||||
const isInstalled = item.install.status === 'installed' || item.install.status === 'blocked';
|
||||
|
||||
const run = async (replace = false) => {
|
||||
setLog([]);
|
||||
const result = await install.mutateAsync({
|
||||
id: item.id,
|
||||
mode: chosen,
|
||||
values: replace ? { ...values, replaceConnection: 'true' } : values,
|
||||
});
|
||||
setLog(result.log ?? []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 overflow-y-auto p-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{item.label}</h2>
|
||||
<p className="text-sm text-muted-foreground">{item.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Blocked is deliberately not styled as an error: everything worked and it is waiting for a
|
||||
person. `completeAt` links to the page that mints the token. */}
|
||||
{item.install.status === 'blocked' && item.install.lastError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<div className="font-medium">Needs you</div>
|
||||
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.install.status === 'failed' && item.install.lastError && (
|
||||
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
||||
<div className="font-medium">Install failed</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInstalled ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
||||
{item.install.enabled ? 'enabled' : 'disabled'}
|
||||
{item.processStatus && ` · process ${item.processStatus}`}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={setEnabled.isPending}
|
||||
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
||||
>
|
||||
{item.install.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
|
||||
configuration and this app’s tables are kept either way.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{item.modes.length > 1 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.modes.map((m) => (
|
||||
<label key={m} className="flex items-start gap-2 text-sm">
|
||||
<input type="radio" checked={chosen === m} onChange={() => setMode(m)} className="mt-1" />
|
||||
<span>
|
||||
<span className="font-medium">{modeLabel[m]}</span>
|
||||
<span className="block text-xs text-muted-foreground">{modeHelp[m]}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.map((field) => (
|
||||
<Field
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button className="self-start" disabled={install.isPending} onClick={() => run()}>
|
||||
{install.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The install log. Streaming it into a terminal panel is the intended shape; until then it is
|
||||
shown whole, because an install that says only "failed" is not diagnosable. */}
|
||||
{log.length > 0 && (
|
||||
<pre className="max-h-64 overflow-y-auto rounded-md bg-black/40 p-3 text-xs leading-relaxed text-foreground/80">
|
||||
{log.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{item.members !== 'none' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.members === 'accounts'
|
||||
? 'Members get their own account on this service automatically.'
|
||||
: 'Members are invited and set their own password — this service is end-to-end encrypted, so Officer cannot do it for them.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
||||
import { useAppStore, type StoreItem } from './useAppStore';
|
||||
|
||||
// The list of everything installable, with what has happened to each.
|
||||
//
|
||||
// Rows are real links carrying `?selected=<id>`, per docs/navigation-audit.md: the selection is
|
||||
// addressable, so it belongs in the URL rather than a panel channel. That is what makes cmd-click,
|
||||
// middle-click and a pasted link all work, and what stops this list and the detail pane disagreeing.
|
||||
|
||||
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
||||
function StatusBadge({ item }: { item: StoreItem }) {
|
||||
const { status, enabled } = item.install;
|
||||
|
||||
if (status === 'not-installed') return null;
|
||||
if (status === 'installing') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
|
||||
}
|
||||
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
|
||||
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
||||
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
|
||||
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
|
||||
|
||||
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
||||
// showing a tick that lies.
|
||||
if (item.processStatus && item.processStatus !== 'online') {
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-emerald-500" aria-label="installed" />;
|
||||
}
|
||||
|
||||
export const AppStoreList = () => {
|
||||
const { items, isLoading, error } = useAppStore();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
||||
// problem the user can act on.
|
||||
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
|
||||
}
|
||||
|
||||
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
||||
const available = items.filter((i) => i.install.status === 'not-installed');
|
||||
|
||||
const Row = ({ item }: { item: StoreItem }) => (
|
||||
<Link
|
||||
to={`?selected=${item.id}`}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||
selected === item.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<StatusBadge item={item} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-2">
|
||||
{installed.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
|
||||
{installed.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
|
||||
{available.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Store } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { AppStoreList } from './AppStoreList';
|
||||
import { AppStoreDetail } from './AppStoreDetail';
|
||||
|
||||
export { AppStoreList } from './AppStoreList';
|
||||
export { AppStoreDetail } from './AppStoreDetail';
|
||||
export { useAppStore } from './useAppStore';
|
||||
export type { StoreItem, InstallMode, ConfigField } from './useAppStore';
|
||||
|
||||
// Two panels rather than one screen: the list and the detail answer different questions and are read
|
||||
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
|
||||
// sense together, on /app-store.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false },
|
||||
{ key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Reading and driving the app store. One query, four verbs.
|
||||
|
||||
export type InstallMode = 'existing' | 'provisioned' | 'config';
|
||||
|
||||
export type ConfigField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'url' | 'text' | 'secret';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
};
|
||||
|
||||
export type StoreItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
modes: InstallMode[];
|
||||
members: 'accounts' | 'invite' | 'none';
|
||||
memberOutcome: string;
|
||||
existingFields?: ConfigField[];
|
||||
configFields?: ConfigField[];
|
||||
install: {
|
||||
status: 'not-installed' | 'pending' | 'installing' | 'installed' | 'failed' | 'blocked';
|
||||
enabled: boolean;
|
||||
mode: string | null;
|
||||
lastError: string | null;
|
||||
completedSteps: string[];
|
||||
};
|
||||
processStatus: string | null;
|
||||
};
|
||||
|
||||
export type InstallOutcome =
|
||||
| { status: 'installed'; completed: string[] }
|
||||
| { status: 'blocked'; completed: string[]; at: string; reason: string; completeAt?: string }
|
||||
| { status: 'failed'; completed: string[]; at: string; error: string };
|
||||
|
||||
const STORE_KEY = ['app-store'];
|
||||
|
||||
export function useAppStore() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: STORE_KEY,
|
||||
queryFn: () => client.get<{ items: StoreItem[] }>('/app-store'),
|
||||
});
|
||||
|
||||
// Every verb invalidates the same key, and also the capability answer: installing changes which dock
|
||||
// tiles exist, and a store that updated while the dock kept the old list would be visibly wrong on
|
||||
// the same screen.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: STORE_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
|
||||
};
|
||||
|
||||
const install = useMutation({
|
||||
mutationFn: (vars: { id: string; mode: InstallMode; values: Record<string, string> }) =>
|
||||
client.post<{ outcome: InstallOutcome; log: string[] }>(`/app-store/${vars.id}/install`, {
|
||||
mode: vars.mode,
|
||||
values: vars.values,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: (vars: { id: string; enabled: boolean }) =>
|
||||
client.post(`/app-store/${vars.id}/${vars.enabled ? 'enable' : 'disable'}`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const uninstall = useMutation({
|
||||
mutationFn: (id: string) => client.post(`/app-store/${id}/uninstall`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { items: data?.items ?? [], isLoading, error, install, setEnabled, uninstall };
|
||||
}
|
||||
@@ -160,3 +160,7 @@ export type {
|
||||
DropPosition,
|
||||
HomeRoot,
|
||||
} from './components/Workspace';
|
||||
|
||||
// App store — install, enable and remove the sidecars this server runs.
|
||||
export { AppStoreList, AppStoreDetail, useAppStore } from './apps/AppStore';
|
||||
export type { StoreItem as AppStoreItem } from './apps/AppStore';
|
||||
|
||||
Reference in New Issue
Block a user