mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-10 12:13:17 +08:00
Plugins card gains an alarm-push toggle; when enabled, netxops subscribes outbound and followups a Netx Ops session without requiring im/WhatsApp. Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
6.9 KiB
TypeScript
239 lines
6.9 KiB
TypeScript
/**
|
|
* Shared staged-form model for the Netx Ops Plugins settings card.
|
|
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
|
|
*/
|
|
|
|
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
|
|
|
|
export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
|
|
|
export interface CardFieldSpec {
|
|
field: string
|
|
format: (value: unknown) => string
|
|
parse: (text: string) => FieldWrite | undefined
|
|
}
|
|
|
|
export interface CardSecretSpec {
|
|
field: string
|
|
write: (text: string) => Promise<boolean>
|
|
}
|
|
|
|
export interface CardFieldState {
|
|
text: string
|
|
overridden: boolean
|
|
invalid: boolean
|
|
}
|
|
|
|
export interface CardShell {
|
|
available: boolean
|
|
writable: boolean
|
|
dirty: boolean
|
|
invalid: boolean
|
|
saving: boolean
|
|
failed: boolean
|
|
}
|
|
|
|
export interface CardActions {
|
|
edit: (field: string, text: string) => void
|
|
resetField: (field: string) => void
|
|
save: () => void
|
|
discard: () => void
|
|
}
|
|
|
|
interface StagedEdit {
|
|
text: string
|
|
clear: boolean
|
|
}
|
|
|
|
interface PlannedWrite {
|
|
field: string
|
|
run: (() => Promise<boolean>) | undefined
|
|
}
|
|
|
|
export function textField(field: string): CardFieldSpec {
|
|
return {
|
|
field,
|
|
format: value => typeof value === 'string' ? value : '',
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
|
|
export function booleanField(field: string): CardFieldSpec {
|
|
return {
|
|
field,
|
|
format: value => (value === true ? 'true' : 'false'),
|
|
parse: (text) => {
|
|
const normalized = text.trim().toLowerCase()
|
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
|
return { kind: 'set', value: true }
|
|
}
|
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === '') {
|
|
return { kind: 'clear' }
|
|
}
|
|
return undefined
|
|
},
|
|
}
|
|
}
|
|
|
|
export class CardForm<T> {
|
|
private readonly specs: Map<string, CardFieldSpec>
|
|
private readonly secretSpecs: Map<string, CardSecretSpec>
|
|
private readonly staged = new Map<string, StagedEdit>()
|
|
private readonly listeners = new Set<() => void>()
|
|
private saving = false
|
|
private failed = false
|
|
|
|
constructor(
|
|
private readonly scope: SettingsScope<T>,
|
|
specs: CardFieldSpec[],
|
|
secrets: CardSecretSpec[] = [],
|
|
) {
|
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
|
|
scope.subscribe(() => { this.publish() })
|
|
}
|
|
|
|
bind<S>(project: () => S): SnapshotStore<S> {
|
|
const store = createSnapshotStore(project())
|
|
this.listeners.add(() => { store.set(project()) })
|
|
return store
|
|
}
|
|
|
|
shell(): CardShell {
|
|
const snapshot = this.scope.getSnapshot()
|
|
const plan = this.plan()
|
|
return {
|
|
available: snapshot.status === 'ready',
|
|
writable: snapshot.writable,
|
|
dirty: plan.length > 0,
|
|
invalid: plan.some(item => item.run === undefined),
|
|
saving: this.saving,
|
|
failed: this.failed,
|
|
}
|
|
}
|
|
|
|
field(field: string): CardFieldState {
|
|
const staged = this.staged.get(field)
|
|
if (this.secretSpecs.has(field)) {
|
|
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
|
}
|
|
const spec = this.spec(field)
|
|
if (staged === undefined) {
|
|
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
}
|
|
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
return {
|
|
text: staged.text,
|
|
overridden: write?.kind === 'set',
|
|
invalid: write === undefined,
|
|
}
|
|
}
|
|
|
|
actions(): CardActions {
|
|
return {
|
|
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
resetField: (field) => {
|
|
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
|
|
},
|
|
save: () => { void this.save() },
|
|
discard: () => {
|
|
if (this.staged.size === 0 && !this.failed) return
|
|
this.staged.clear()
|
|
this.failed = false
|
|
this.publish()
|
|
},
|
|
}
|
|
}
|
|
|
|
async save(): Promise<void> {
|
|
const plan = this.plan()
|
|
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
|
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
|
this.saving = true
|
|
this.failed = false
|
|
this.publish()
|
|
let landed = true
|
|
for (const write of writes) {
|
|
landed = await write() && landed
|
|
}
|
|
if (landed) this.staged.clear()
|
|
this.saving = false
|
|
this.failed = !landed
|
|
this.publish()
|
|
}
|
|
|
|
private plan(): PlannedWrite[] {
|
|
const plan: PlannedWrite[] = []
|
|
for (const [field, staged] of this.staged) {
|
|
const secret = this.secretSpecs.get(field)
|
|
if (secret !== undefined) {
|
|
const value = staged.text.trim()
|
|
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
|
continue
|
|
}
|
|
const spec = this.spec(field)
|
|
if (staged.clear) {
|
|
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
continue
|
|
}
|
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
const write = spec.parse(staged.text)
|
|
if (write === undefined) plan.push({ field, run: undefined })
|
|
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
}
|
|
return plan
|
|
}
|
|
|
|
private async clear(field: string): Promise<boolean> {
|
|
await this.scope.unset(field)
|
|
return !this.stored(field)
|
|
}
|
|
|
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
await this.scope.set(field, value)
|
|
return this.userLayer()?.[field] === value
|
|
}
|
|
|
|
private stage(field: string, edit: StagedEdit): void {
|
|
this.staged.set(field, edit)
|
|
this.failed = false
|
|
this.publish()
|
|
}
|
|
|
|
private spec(field: string): CardFieldSpec {
|
|
const spec = this.specs.get(field)
|
|
if (spec === undefined) throw new Error(`netxops card has no field ${field}`)
|
|
return spec
|
|
}
|
|
|
|
private snapshotOf(): SettingsScopeSnapshot<T> {
|
|
return this.scope.getSnapshot()
|
|
}
|
|
|
|
private sectionValue(field: string): unknown {
|
|
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
}
|
|
|
|
private baseValue(field: string): unknown {
|
|
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
}
|
|
|
|
private userLayer(): Record<string, unknown> | undefined {
|
|
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
}
|
|
|
|
private stored(field: string): boolean {
|
|
const user = this.userLayer()
|
|
return user !== undefined && Object.hasOwn(user, field)
|
|
}
|
|
|
|
private publish(): void {
|
|
for (const listener of this.listeners) listener()
|
|
}
|
|
}
|