feat(frontend): extract console data layer into api/domain/hooks modules
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response): Promise<T> {
|
||||
const text = await res.text()
|
||||
if (!text) return undefined as T
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return text as unknown as T
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, init)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`Request failed (${res.status})`, res.status)
|
||||
}
|
||||
return readJson<T>(res)
|
||||
}
|
||||
|
||||
export function get<T>(path: string): Promise<T> {
|
||||
return request<T>(path)
|
||||
}
|
||||
|
||||
export function post<T = void>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { post } from './client'
|
||||
import type { RegistryItem, Service } from '../types'
|
||||
|
||||
/** The payload shape the backend expects for install/uninstall/start/stop. */
|
||||
function registryPayload(s: Service): RegistryItem {
|
||||
return {
|
||||
name: s.name,
|
||||
version: s.latestVersion || s.version || '',
|
||||
image: s.registryImage || s.image || '',
|
||||
title: s.title,
|
||||
vendor: s.vendor,
|
||||
author: s.author,
|
||||
description: s.description,
|
||||
source: s.source,
|
||||
url: s.registryUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export function installContainer(service: Service): Promise<void> {
|
||||
return post<void>('/api/containers/install', registryPayload(service))
|
||||
}
|
||||
|
||||
export function uninstallContainer(service: Service): Promise<void> {
|
||||
return post<void>('/api/containers/uninstall', registryPayload(service))
|
||||
}
|
||||
|
||||
export function startContainer(service: Service): Promise<void> {
|
||||
return post<void>('/api/containers/start', registryPayload(service))
|
||||
}
|
||||
|
||||
export function stopContainer(service: Service): Promise<void> {
|
||||
return post<void>('/api/containers/stop', registryPayload(service))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { get } from './client'
|
||||
import type { RegistryItem } from '../types'
|
||||
|
||||
export function fetchMasterRegistry(): Promise<RegistryItem[]> {
|
||||
return get<RegistryItem[]>('/api/registry')
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FilterOption, Service, ServiceFilter } from '../types'
|
||||
|
||||
export const FILTERS: FilterOption[] = [
|
||||
{ id: 'all', label: 'All', hint: 'Every service on the node' },
|
||||
{ id: 'running', label: 'Running', hint: 'Services currently running' },
|
||||
{ id: 'updates', label: 'Updates', hint: 'A newer version is published' },
|
||||
{ id: 'available', label: 'Available', hint: 'In the registry, not yet installed' },
|
||||
]
|
||||
|
||||
export function filterServices(
|
||||
services: Service[],
|
||||
query: string,
|
||||
filter: ServiceFilter
|
||||
): Service[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return services.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
(s.image ?? '').toLowerCase().includes(q) ||
|
||||
(s.source ?? '').toLowerCase().includes(q)
|
||||
|
||||
if (!matchesSearch) return false
|
||||
if (filter === 'running') return s.status.toLowerCase() === 'running'
|
||||
if (filter === 'updates') return s.hasUpdate
|
||||
if (filter === 'available') return s.origin === 'remote'
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function serviceCounts(services: Service[]) {
|
||||
const running = services.filter((s) => s.status.toLowerCase() === 'running').length
|
||||
const updates = services.filter((s) => s.hasUpdate).length
|
||||
const available = services.filter((s) => s.origin === 'remote').length
|
||||
const installed = services.length - available
|
||||
|
||||
return { total: services.length, installed, running, updates, available }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Container, RegistryItem, Service } from '../types'
|
||||
import { baseImageOf, compareVersions, normalizeVersion, versionFromImage } from './version'
|
||||
|
||||
/** Statuses that should win the merge when a name maps to several containers. */
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
running: 4,
|
||||
restarting: 3,
|
||||
starting: 3,
|
||||
stopped: 2,
|
||||
exited: 2,
|
||||
created: 2,
|
||||
installed: 1,
|
||||
}
|
||||
|
||||
function priority(status?: string): number {
|
||||
return STATUS_PRIORITY[(status ?? '').toLowerCase()] ?? 0
|
||||
}
|
||||
|
||||
function isMaestro(name?: string): boolean {
|
||||
return (name ?? '').toUpperCase().includes('MAESTRO')
|
||||
}
|
||||
|
||||
function toService(c: Container): Service {
|
||||
return {
|
||||
id: c.id || c.name,
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
origin: 'local',
|
||||
title: c.service_name || c.name,
|
||||
image: c.image,
|
||||
version: c.version || versionFromImage(c.image),
|
||||
hasUpdate: false,
|
||||
}
|
||||
}
|
||||
|
||||
function findMatch(
|
||||
map: Map<string, Service>,
|
||||
name: string,
|
||||
registryImage: string
|
||||
): Service | undefined {
|
||||
const byName = map.get(name)
|
||||
if (byName) return byName
|
||||
|
||||
const base = baseImageOf(registryImage)
|
||||
if (!base) return undefined
|
||||
|
||||
for (const item of map.values()) {
|
||||
if (baseImageOf(item.image) === base) return item
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge live containers with the remote catalog into a single list of
|
||||
* services, one entry per name. Local state wins for status; remote metadata
|
||||
* (title, description, source, latest version) is layered on top.
|
||||
*/
|
||||
export function mergeServices(containers: Container[], registry: RegistryItem[]): Service[] {
|
||||
const map = new Map<string, Service>()
|
||||
|
||||
for (const c of containers) {
|
||||
if (isMaestro(c.name)) continue
|
||||
const key = c.name.toLowerCase()
|
||||
const next = toService(c)
|
||||
const existing = map.get(key)
|
||||
if (!existing || priority(next.status) >= priority(existing.status)) {
|
||||
map.set(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of registry) {
|
||||
if (isMaestro(r.name)) continue
|
||||
const key = r.name.toLowerCase()
|
||||
const existing = findMatch(map, key, r.image)
|
||||
|
||||
if (existing) {
|
||||
existing.origin = 'both'
|
||||
existing.registryImage = r.image
|
||||
existing.registryUrl = r.url || r.source
|
||||
existing.source = r.source || existing.source
|
||||
existing.title = r.title || existing.title
|
||||
existing.vendor = r.vendor || existing.vendor
|
||||
existing.author = r.author || existing.author
|
||||
existing.description = r.description || existing.description
|
||||
existing.latestVersion = r.version
|
||||
|
||||
const current =
|
||||
normalizeVersion(existing.version) ?? normalizeVersion(versionFromImage(existing.image))
|
||||
if (current && compareVersions(r.version, current) > 0) {
|
||||
existing.hasUpdate = true
|
||||
}
|
||||
} else {
|
||||
map.set(key, {
|
||||
id: `registry-${r.name}`,
|
||||
name: r.name,
|
||||
status: 'uninstalled',
|
||||
origin: 'remote',
|
||||
title: r.title || r.name,
|
||||
image: r.image,
|
||||
registryImage: r.image,
|
||||
source: r.source,
|
||||
registryUrl: r.url,
|
||||
description: r.description,
|
||||
vendor: r.vendor,
|
||||
author: r.author,
|
||||
latestVersion: r.version,
|
||||
hasUpdate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Version and image-reference helpers.
|
||||
*
|
||||
* Registry versions are stored in full X.Y.Z form by the backend; installed
|
||||
* versions may come through as partial tags ("v3", "3.1", "4.0.0-hotfix").
|
||||
* Everything here normalizes to a comparable X.Y.Z[-pre] string.
|
||||
*/
|
||||
|
||||
/** Base image reference without registry host, tag, or digest. */
|
||||
export function baseImageOf(imageRef?: string): string {
|
||||
if (!imageRef) return ''
|
||||
return imageRef.split('@')[0].split(':')[0].toLowerCase()
|
||||
}
|
||||
|
||||
/** The tag of an image reference, "" when it is `latest` or absent. */
|
||||
export function versionFromImage(imageRef?: string): string {
|
||||
if (!imageRef) return ''
|
||||
const tag = imageRef.split('@')[0].split(':').pop() ?? ''
|
||||
return tag === 'latest' ? '' : tag
|
||||
}
|
||||
|
||||
/** Normalize "v3", "3.1", "4.0.0-hotfix" → "3.0.0", "3.1.0", "4.0.0-hotfix". */
|
||||
export function normalizeVersion(value?: string): string | null {
|
||||
if (!value) return null
|
||||
const s = value.trim().replace(/^v/i, '')
|
||||
if (!/^\d+/.test(s)) return null
|
||||
|
||||
const match = /^(\d+(?:\.\d+){0,2})(?:[-+].*)?$/.exec(s)
|
||||
if (!match) return null
|
||||
|
||||
const parts = match[1].split('.').map(Number)
|
||||
while (parts.length < 3) parts.push(0)
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings. Returns a negative number when `a < b`.
|
||||
* Pre-release versions sort below their release (4.0.0 > 4.0.0-beta).
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const na = normalizeVersion(a) ?? a
|
||||
const nb = normalizeVersion(b) ?? b
|
||||
|
||||
const coreA = na.replace(/[-+].*$/, '').split('.').map(Number)
|
||||
const coreB = nb.replace(/[-+].*$/, '').split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (coreA[i] ?? 0) - (coreB[i] ?? 0)
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
|
||||
const preA = na.includes('-') ? 1 : 0
|
||||
const preB = nb.includes('-') ? 1 : 0
|
||||
return preA - preB
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
interface AsyncAction {
|
||||
pending: boolean
|
||||
run: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards an async operation against double submission and exposes a pending
|
||||
* flag for busy states. Errors are the caller's to surface (toast/banner).
|
||||
*/
|
||||
export function useAsyncAction(action: () => Promise<void>): AsyncAction {
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const run = async () => {
|
||||
if (pending) return
|
||||
setPending(true)
|
||||
try {
|
||||
await action()
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, run }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { store, type ContainerStoreState } from '../ws/registry'
|
||||
import { connectWebSocket } from '../ws/client'
|
||||
|
||||
/**
|
||||
* Subscribes the component to the live container store and owns the single
|
||||
* WebSocket connection for the app.
|
||||
*/
|
||||
export function useContainerStore(): ContainerStoreState {
|
||||
const [state, setState] = useState<ContainerStoreState>(() => store.getState())
|
||||
|
||||
useEffect(() => {
|
||||
connectWebSocket()
|
||||
const unsubscribe = store.subscribe(() => setState(store.getState()))
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { fetchMasterRegistry } from '../api/registry'
|
||||
import type { RegistryItem } from '../types'
|
||||
|
||||
export type RegistryLoadState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ok'; items: RegistryItem[] }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export function useMasterRegistry() {
|
||||
const [state, setState] = useState<RegistryLoadState>({ kind: 'loading' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState({ kind: 'loading' })
|
||||
try {
|
||||
const items = await fetchMasterRegistry()
|
||||
setState({ kind: 'ok', items })
|
||||
} catch {
|
||||
setState({ kind: 'error', message: 'The dcntrlized registry is unreachable.' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { state, retry: load }
|
||||
}
|
||||
@@ -1,75 +1,70 @@
|
||||
export type ContainerOrigin = 'local' | 'remote' | 'both'
|
||||
|
||||
export interface PortMapping {
|
||||
host: number;
|
||||
guest: number;
|
||||
host: number
|
||||
guest: number
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
name: string;
|
||||
mode: "bridge" | "host" | "none" | "overlay" | "ipvlan" | "macvlan" | string;
|
||||
ports: PortMapping[];
|
||||
name: string
|
||||
mode: string
|
||||
ports: PortMapping[]
|
||||
}
|
||||
|
||||
export interface RegistryItem {
|
||||
name: string
|
||||
version: string
|
||||
image?: string
|
||||
source?: string
|
||||
url?: string
|
||||
title?: string
|
||||
vendor?: string
|
||||
author?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
|
||||
// Runtime properties
|
||||
id?: string;
|
||||
status?: string;
|
||||
origin?: ContainerOrigin;
|
||||
remoteSource?: string;
|
||||
latestVersion?: string;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const STATUS_PRIORITY: Record<string, number> = {
|
||||
running: 3,
|
||||
restarting: 2,
|
||||
starting: 2,
|
||||
stopped: 1,
|
||||
exited: 1,
|
||||
created: 1,
|
||||
installed: 1,
|
||||
}
|
||||
|
||||
// --- SINGLE UNIFIED CONTAINER TYPE ---
|
||||
|
||||
export type ContainerOrigin = 'local' | 'remote' | 'both';
|
||||
|
||||
/** A live container as broadcast over the WebSocket. */
|
||||
export interface Container {
|
||||
id: string;
|
||||
name: string;
|
||||
service_name?: string;
|
||||
// Status can be from Docker ("running", "stopped") or our UI ("uninstalled")
|
||||
status: string;
|
||||
id: string
|
||||
name: string
|
||||
service_name?: string
|
||||
docker_name?: string
|
||||
image?: string
|
||||
version?: string
|
||||
status: string
|
||||
network?: NetworkConfig
|
||||
}
|
||||
|
||||
// Origin tracking (populated by our App logic during merge)
|
||||
origin?: ContainerOrigin;
|
||||
/** A remote catalog entry, as served by GET /api/registry. */
|
||||
export interface RegistryItem {
|
||||
name: string
|
||||
version: string
|
||||
image: string
|
||||
digest?: string
|
||||
title?: string
|
||||
vendor?: string
|
||||
author?: string
|
||||
description?: string
|
||||
source?: string
|
||||
revision?: string
|
||||
licenses?: string
|
||||
url?: string
|
||||
documentation?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
// Fields populated by local Docker Daemon
|
||||
docker_name?: string;
|
||||
image?: string;
|
||||
network?: {
|
||||
name: string;
|
||||
mode: "bridge" | "host" | "none" | "overlay" | "ipvlan" | "macvlan" | string;
|
||||
ports: Array<{ host: number, guest: number }>;
|
||||
};
|
||||
/** Merged view model: one card per service, local state and remote catalog fused. */
|
||||
export interface Service {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
origin: ContainerOrigin
|
||||
title?: string
|
||||
vendor?: string
|
||||
author?: string
|
||||
description?: string
|
||||
image?: string
|
||||
/** The image reference published in the registry (for install/update). */
|
||||
registryImage?: string
|
||||
source?: string
|
||||
registryUrl?: string
|
||||
version?: string
|
||||
latestVersion?: string
|
||||
hasUpdate: boolean
|
||||
}
|
||||
|
||||
// Fields populated by remote Git Registry
|
||||
source?: string;
|
||||
url?: string;
|
||||
version?: string;
|
||||
export type ServiceFilter = 'all' | 'running' | 'updates' | 'available'
|
||||
|
||||
// Optional properties for update state
|
||||
remoteSource?: string;
|
||||
latestVersion?: string;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
export interface FilterOption {
|
||||
id: ServiceFilter
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,48 +1,50 @@
|
||||
import { registry } from "./registry";
|
||||
import { type Container } from "../types";
|
||||
import { store } from './registry'
|
||||
import type { Container } from '../types'
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
const PORT = "8080";
|
||||
const PATH = "api/ws";
|
||||
const URL = `ws://${HOST}:${PORT}/${PATH}`;
|
||||
const URL = 'ws://127.0.0.1:8080/api/ws'
|
||||
const RECONNECT_DELAY = 3000
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectTimer: number | undefined
|
||||
|
||||
export function connectWebSocket(): void {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
if (
|
||||
socket &&
|
||||
(socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const ws = new WebSocket(URL)
|
||||
socket = ws
|
||||
store.setStatus('connecting')
|
||||
|
||||
ws.onopen = () => store.setStatus('open')
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data as string) as Container[]
|
||||
if (Array.isArray(payload)) store.setContainers(payload)
|
||||
} catch {
|
||||
console.error('[ws] Failed to parse message payload')
|
||||
}
|
||||
}
|
||||
|
||||
socket = new WebSocket(URL);
|
||||
registry.setStatus("connecting");
|
||||
ws.onclose = () => {
|
||||
store.setStatus('closed')
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log("[WS] Connected");
|
||||
registry.setStatus("open");
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
// Parses into the unified type (missing fields will safely be undefined)
|
||||
const containers: Array<Container> = JSON.parse(event.data)
|
||||
registry.setContainers(containers);
|
||||
} catch (err) {
|
||||
console.error("[WS] Failed to parse message payload:", err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.warn("[WS] Closed");
|
||||
registry.setStatus("closed");
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error("[WS] Error", err);
|
||||
socket?.close();
|
||||
};
|
||||
function scheduleReconnect(): void {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
export function isConnected(): boolean {
|
||||
return socket !== null && socket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
return socket !== null && socket.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
@@ -1,51 +1,41 @@
|
||||
import { type Container } from "../types";
|
||||
import type { Container } from '../types'
|
||||
|
||||
export type ConnectionStatus = "connecting" | "open" | "closing" | "closed";
|
||||
export type ConnectionStatus = 'connecting' | 'open' | 'closing' | 'closed'
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class ContainerRegistry {
|
||||
private containers: Container[] = [];
|
||||
private status: ConnectionStatus = "closed";
|
||||
private listeners: Set<Listener> = new Set();
|
||||
|
||||
private state = {
|
||||
containers: this.containers,
|
||||
status: this.status,
|
||||
};
|
||||
|
||||
public getState = () => {
|
||||
return this.state;
|
||||
};
|
||||
|
||||
public setContainers(nextContainers: Container[]) {
|
||||
this.containers = nextContainers;
|
||||
this.updateSnapshot();
|
||||
}
|
||||
|
||||
public setStatus(nextStatus: ConnectionStatus) {
|
||||
this.status = nextStatus;
|
||||
this.updateSnapshot();
|
||||
}
|
||||
|
||||
private updateSnapshot() {
|
||||
this.state = {
|
||||
containers: this.containers,
|
||||
status: this.status,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
public subscribe = (listener: Listener): () => void => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
export interface ContainerStoreState {
|
||||
containers: Container[]
|
||||
status: ConnectionStatus
|
||||
}
|
||||
|
||||
export const registry = new ContainerRegistry();
|
||||
type Listener = () => void
|
||||
|
||||
class ContainerStore {
|
||||
private containers: Container[] = []
|
||||
private status: ConnectionStatus = 'closed'
|
||||
private listeners = new Set<Listener>()
|
||||
|
||||
getState(): ContainerStoreState {
|
||||
return { containers: this.containers, status: this.status }
|
||||
}
|
||||
|
||||
setContainers(next: Container[]): void {
|
||||
this.containers = next
|
||||
this.notify()
|
||||
}
|
||||
|
||||
setStatus(next: ConnectionStatus): void {
|
||||
this.status = next
|
||||
this.notify()
|
||||
}
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new ContainerStore()
|
||||
|
||||
Reference in New Issue
Block a user