This commit is contained in:
ponzischeme89
2026-08-22 12:38:26 +12:00
parent f982d391b9
commit b3e3f62c54
26 changed files with 1279 additions and 108 deletions
+15
View File
@@ -211,6 +211,21 @@ export interface FeaturePolicy {
updatedAt?: string;
canRollback: boolean;
features: Feature[] | null;
configuration: ConfigurationValue[] | null;
}
export interface ConfigurationValue {
key: string;
name: string;
description: string;
type: 'boolean' | 'integer' | 'enum' | 'json';
scopes: string[];
default: unknown;
options?: string[];
min?: number;
max?: number;
value: unknown;
source: string;
}
export interface AdminStatus {
+3 -3
View File
@@ -219,9 +219,9 @@ export const nav: NavGroup[] = [
{
id: 'features',
path: '/admin/features',
label: 'Features',
title: 'Features',
intro: 'Roll out, stop and recover optional behaviour with no app release.',
label: 'Client configuration',
title: 'Client configuration',
intro: 'Control everything the thin TV client renders, without releasing an APK.',
icon: 'sliders',
},
{
+79 -2
View File
@@ -36,6 +36,7 @@ export function FeaturesPage() {
const policy = status?.features;
const features = policy?.features ?? [];
const configuration = policy?.configuration ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
@@ -64,12 +65,16 @@ export function FeaturesPage() {
),
[key]: enabled,
});
const valuesFor = (key: string, value: unknown) => ({
...Object.fromEntries(configuration.map((item) => [item.key, item.value])),
[key]: value,
});
return (
<>
<PageHead
title="Features"
intro="Roll out, stop and recover optional behaviour with no app release."
title="Client configuration"
intro="One server-owned control plane for what Memby renders: flags, values, page composition and contextual discovery."
/>
<Banner message={error} />
@@ -77,6 +82,18 @@ export function FeaturesPage() {
<Loading />
) : (
<>
<Card
title="Thin client control plane"
intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer."
icon="tv"
>
<div className="chips">
<a className="chip" href="/admin/hero">Hero sources and pinned overrides</a>
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
</div>
</Card>
<Card
title="Control plane"
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
@@ -121,6 +138,66 @@ export function FeaturesPage() {
/>
</Card>
<Card
title="Central configuration"
intro="Typed values use the same revisioned control plane as feature flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow."
icon="sliders"
>
<div className="stack">
{configuration.map((item) => {
const value = item.value;
return (
<div className="row" key={item.key}>
<div className="grow">
<strong>{item.name}</strong>
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
<Chip>{item.key}</Chip>
</div>
{item.type === 'boolean' ? (
<Toggle
label={value ? 'On' : 'Off'}
checked={Boolean(value)}
disabled={busy === item.key}
onChange={(next) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}
/>
) : item.type === 'enum' ? (
<select value={String(value)} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}>
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : item.type === 'json' ? (
<textarea
rows={4}
value={JSON.stringify(value, null, 2)}
disabled={busy === item.key}
aria-label={item.name}
onChange={(event) => {
try {
const next = JSON.parse(event.target.value);
void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
});
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
)}
</div>
);
})}
</div>
</Card>
<Grid cols="2">
{features.length === 0 ? (
<Card title="Nothing registered" icon="sliders">