0.3.01
This commit is contained in:
@@ -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
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user