feat: implement URL plugins with table/row actions and prefill support

- Add URL plugin effects (register, export_url, clear_urls) in core
- Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types
- Implement plugin URL table in web UI with tableAction button and rowAction overflow menus
- Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions
- Add prefill support to PackageActionData so metadata passes through form dialogs
- Add i18n translations for plugin error messages
- Clean up plugin URLs on package uninstall
This commit is contained in:
Aiden McClelland
2026-02-18 17:51:13 -07:00
parent dce975410f
commit 9c3053f103
53 changed files with 792 additions and 278 deletions

View File

@@ -257,7 +257,7 @@ export class AddressActionsComponent {
showDnsValidation() {
this.domainHealth.showPublicDomainSetup(
this.address().hostnameInfo.host,
this.address().hostnameInfo.hostname,
this.gatewayId(),
)
}
@@ -286,7 +286,7 @@ export class AddressActionsComponent {
const loader = this.loader.open('Removing').subscribe()
try {
const host = addr.hostnameInfo.host
const host = addr.hostnameInfo.hostname
if (addr.hostnameInfo.metadata.kind === 'public-domain') {
if (this.packageId()) {

View File

@@ -209,7 +209,7 @@ export class InterfaceAddressItemComponent {
const kind = addr.hostnameInfo.metadata.kind
if (kind === 'public-domain') {
await this.domainHealth.checkPublicDomain(
addr.hostnameInfo.host,
addr.hostnameInfo.hostname,
this.gatewayId(),
)
} else if (kind === 'private-domain') {

View File

@@ -10,6 +10,7 @@ import {
DialogService,
i18nPipe,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -22,13 +23,28 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { PluginAddressGroup } from '../interface.service'
import { ActionService } from 'src/app/services/action.service'
import {
MappedServiceInterface,
PluginAddress,
PluginAddressGroup,
} from '../interface.service'
@Component({
selector: 'section[pluginGroup]',
template: `
<header>
{{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="runTableAction()"
>
{{ action.metadata.name }}
</button>
}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index) {
@@ -55,6 +71,23 @@ import { PluginAddressGroup } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.rowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.play"
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</div>
<div class="mobile">
<button
@@ -83,6 +116,23 @@ import { PluginAddressGroup } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.rowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
iconStart="@tui.play"
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</tui-data-list>
</button>
</div>
@@ -159,10 +209,13 @@ import { PluginAddressGroup } from '../interface.service'
export class PluginAddressesComponent {
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly actionService = inject(ActionService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
readonly pluginGroup = input.required<PluginAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
showQR(url: string) {
this.dialog
@@ -173,4 +226,59 @@ export class PluginAddressesComponent {
})
.subscribe()
}
runTableAction() {
const group = this.pluginGroup()
if (!group.tableAction || !group.pluginPkgInfo) return
const iface = this.value()
const prefill: Record<string, unknown> = {}
if (iface) {
prefill['urlPluginMetadata'] = {
packageId: this.packageId() || null,
hostId: iface.addressInfo.hostId,
interfaceId: iface.id,
internalPort: iface.addressInfo.internalPort,
}
}
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: group.tableAction,
prefill,
})
}
runRowAction(
actionId: string,
metadata: T.ActionMetadata,
address: PluginAddress,
) {
const group = this.pluginGroup()
if (!group.pluginPkgInfo) return
const iface = this.value()
const prefill: Record<string, unknown> = {}
if (iface && address.hostnameInfo.metadata.kind === 'plugin') {
prefill['urlPluginMetadata'] = {
packageId: this.packageId() || null,
hostId: iface.addressInfo.hostId,
interfaceId: iface.id,
internalPort: iface.addressInfo.internalPort,
hostname: address.hostnameInfo.hostname,
port: address.hostnameInfo.port,
ssl: address.hostnameInfo.ssl,
public: address.hostnameInfo.public,
info: address.hostnameInfo.metadata.info,
}
}
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: { id: actionId, metadata },
prefill,
})
}
}

View File

@@ -16,7 +16,11 @@ import { PluginAddressesComponent } from './addresses/plugin.component'
></section>
}
@for (group of value()?.pluginGroups; track group.pluginId) {
<section [pluginGroup]="group"></section>
<section
[pluginGroup]="group"
[packageId]="packageId()"
[value]="value()"
></section>
}
`,
styles: `

View File

@@ -2,7 +2,12 @@ import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { toAuthorityName } from 'src/app/utils/acme'
import { getManifest } from 'src/app/utils/get-package-data'
function isPublicIp(h: T.HostnameInfo): boolean {
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
@@ -13,12 +18,12 @@ function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
if (h.port === null) return true
const sa =
h.metadata.kind === 'ipv6'
? `[${h.host}]:${h.port}`
: `${h.host}:${h.port}`
? `[${h.hostname}]:${h.port}`
: `${h.hostname}:${h.port}`
return addr.enabled.includes(sa)
} else {
return !addr.disabled.some(
([host, port]) => host === h.host && port === (h.port ?? 0),
([hostname, port]) => hostname === h.hostname && port === (h.port ?? 0),
)
}
}
@@ -46,7 +51,7 @@ function getCertificate(
if (!h.ssl) return '-'
if (h.metadata.kind === 'public-domain') {
const config = host.publicDomains[h.host]
const config = host.publicDomains[h.hostname]
return config ? toAuthorityName(config.acme) : toAuthorityName(null)
}
@@ -60,7 +65,7 @@ function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number {
const isDomain = (addr: GatewayAddress) =>
addr.hostnameInfo.metadata.kind === 'public-domain' ||
(addr.hostnameInfo.metadata.kind === 'private-domain' &&
!addr.hostnameInfo.host.endsWith('.local'))
!addr.hostnameInfo.hostname.endsWith('.local'))
return Number(isDomain(b)) - Number(isDomain(a))
}
@@ -72,7 +77,7 @@ function getAddressType(h: T.HostnameInfo): string {
return 'IPv6'
case 'public-domain':
case 'private-domain':
return h.host
return h.hostname
case 'mdns':
return 'mDNS'
case 'plugin':
@@ -140,6 +145,7 @@ export class InterfaceService {
getPluginGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
allPackageData?: Record<string, T.PackageDataEntry>,
): PluginAddressGroup[] {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
@@ -152,7 +158,7 @@ export class InterfaceService {
if (h.metadata.kind !== 'plugin') continue
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const pluginId = h.metadata.package
const pluginId = h.metadata.packageId
if (!groupMap.has(pluginId)) {
groupMap.set(pluginId, [])
@@ -165,11 +171,35 @@ export class InterfaceService {
})
}
return Array.from(groupMap.entries()).map(([pluginId, addresses]) => ({
pluginId,
pluginName: pluginId.charAt(0).toUpperCase() + pluginId.slice(1),
addresses,
}))
return Array.from(groupMap.entries()).map(([pluginId, addresses]) => {
const pluginPkg = allPackageData?.[pluginId]
const pluginActions = pluginPkg?.actions ?? {}
const tableActionId = pluginPkg?.plugin?.url?.tableAction ?? null
const tableActionMeta = tableActionId ? pluginActions[tableActionId] : undefined
const tableAction = tableActionId && tableActionMeta
? { id: tableActionId, metadata: tableActionMeta }
: null
let pluginPkgInfo: PluginPkgInfo | null = null
if (pluginPkg) {
const manifest = getManifest(pluginPkg)
pluginPkgInfo = {
id: manifest.id,
title: manifest.title,
icon: pluginPkg.icon,
status: renderPkgStatus(pluginPkg).primary,
}
}
return {
pluginId,
pluginName: pluginPkgInfo?.title ?? pluginId.charAt(0).toUpperCase() + pluginId.slice(1),
addresses,
tableAction,
pluginPkgInfo,
pluginActions,
}
})
}
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
@@ -201,7 +231,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv4',
predicate: h => h.host === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
onLan = true
@@ -210,7 +240,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv6',
predicate: h => h.host === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
break
@@ -257,10 +287,20 @@ export type PluginAddress = {
masked: boolean
}
export type PluginPkgInfo = {
id: string
title: string
icon: string
status: PrimaryStatus
}
export type PluginAddressGroup = {
pluginId: string
pluginName: string
addresses: PluginAddress[]
tableAction: { id: string; metadata: T.ActionMetadata } | null
pluginPkgInfo: PluginPkgInfo | null
pluginActions: Record<string, T.ActionMetadata>
}
export type MappedServiceInterface = T.ServiceInterface & {

View File

@@ -42,6 +42,7 @@ export type PackageActionData = {
metadata: T.ActionMetadata
}
requestInfo?: T.Task
prefill?: Record<string, unknown>
}
@Component({
@@ -178,11 +179,14 @@ export class ActionInputModal {
async execute(input: object) {
if (await this.checkConflicts(input)) {
const merged = this.context.data.prefill
? { ...input, ...this.context.data.prefill }
: input
await this.actionService.execute(
this.pkgInfo.id,
this.eventId,
this.actionId,
input,
merged,
)
this.context.$implicit.complete()
}

View File

@@ -97,6 +97,7 @@ export default class ServiceInterfaceRoute {
readonly interfaceId = input('')
readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
readonly isRunning = computed(() => {
const pkg = this.pkg()
@@ -131,7 +132,7 @@ export default class ServiceInterfaceRoute {
host,
gateways,
),
pluginGroups: this.interfaceService.getPluginGroups(iFace, host),
pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()),
addSsl: !!binding?.options.addSsl,
}
})

View File

@@ -71,10 +71,14 @@ export default class StartOsUiComponent {
},
}
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly network = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo', 'network'),
this.patch.watch$('serverInfo', 'network'),
)
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
readonly ui = computed(() => {
const network = this.network()
const gateways = this.gatewayService.gateways()
@@ -91,6 +95,7 @@ export default class StartOsUiComponent {
pluginGroups: this.interfaceService.getPluginGroups(
this.iface,
network.host,
this.allPackageData(),
),
addSsl: true,
}

View File

@@ -46,9 +46,9 @@ export class ActionService {
},
})
.pipe(filter(Boolean))
.subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id))
.subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id, data.prefill))
} else {
this.execute(pkgInfo.id, null, actionInfo.id)
this.execute(pkgInfo.id, null, actionInfo.id, data.prefill)
}
}
}

View File

@@ -262,6 +262,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const MockManifestLnd: T.Manifest = {
@@ -321,6 +322,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const MockManifestBitcoinProxy: T.Manifest = {
@@ -373,6 +375,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const BitcoinDep: T.DependencyMetadata = {
@@ -432,6 +435,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -473,6 +477,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -524,6 +529,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -565,6 +571,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -621,6 +628,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -675,6 +683,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -730,6 +739,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:27.1.0:0': {
title: 'Bitcoin Knots',
@@ -771,6 +781,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -825,6 +836,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -878,6 +890,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin'],
@@ -2121,7 +2134,7 @@ export namespace Mock {
{
ssl: true,
public: false,
host: 'adjective-noun.local',
hostname: 'adjective-noun.local',
port: 1234,
metadata: {
kind: 'mdns',
@@ -2131,28 +2144,28 @@ export namespace Mock {
{
ssl: true,
public: false,
host: '192.168.10.11',
hostname: '192.168.10.11',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: true,
public: false,
host: '10.0.0.2',
hostname: '10.0.0.2',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: true,
public: false,
host: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd',
hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
ssl: true,
public: false,
host: 'fe80:cd00:0000:0cde:1257:0000:211e:1234',
hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:1234',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
@@ -2222,6 +2235,7 @@ export namespace Mock {
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
'bitcoind-config': {
task: {
@@ -2291,6 +2305,7 @@ export namespace Mock {
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {},
}
@@ -2398,6 +2413,7 @@ export namespace Mock {
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
config: {
active: true,

View File

@@ -1822,8 +1822,8 @@ export class MockApiService extends ApiService {
if (h.port === null) return
const sa =
h.metadata.kind === 'ipv6'
? `[${h.host}]:${h.port}`
: `${h.host}:${h.port}`
? `[${h.hostname}]:${h.port}`
: `${h.hostname}:${h.port}`
const arr = [...current.enabled]
@@ -1841,11 +1841,11 @@ export class MockApiService extends ApiService {
} else {
const port = h.port ?? 0
const arr = current.disabled.filter(
([dHost, dPort]) => !(dHost === h.host && dPort === port),
([dHost, dPort]) => !(dHost === h.hostname && dPort === port),
)
if (!enabled) {
arr.push([h.host, port])
arr.push([h.hostname, port])
}
current.disabled = arr

View File

@@ -46,7 +46,7 @@ export const mockPatchData: DataModel = {
{
ssl: true,
public: false,
host: 'adjective-noun.local',
hostname: 'adjective-noun.local',
port: 443,
metadata: {
kind: 'mdns',
@@ -56,35 +56,35 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: '10.0.0.1',
hostname: '10.0.0.1',
port: 80,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
ssl: false,
public: false,
host: '10.0.0.2',
hostname: '10.0.0.2',
port: 80,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: false,
public: false,
host: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
hostname: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
port: 80,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
ssl: false,
public: false,
host: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
hostname: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
port: 80,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
{
ssl: true,
public: false,
host: 'my-server.home',
hostname: 'my-server.home',
port: 443,
metadata: {
kind: 'private-domain',
@@ -94,16 +94,16 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
hostname: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
port: 80,
metadata: { kind: 'plugin', package: 'tor' },
metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null },
},
{
ssl: true,
public: false,
host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
hostname: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
port: 443,
metadata: { kind: 'plugin', package: 'tor' },
metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null },
},
],
},
@@ -349,6 +349,7 @@ export const mockPatchData: DataModel = {
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
config: {
active: true,
@@ -532,7 +533,7 @@ export const mockPatchData: DataModel = {
{
ssl: true,
public: false,
host: 'adjective-noun.local',
hostname: 'adjective-noun.local',
port: 42443,
metadata: {
kind: 'mdns',
@@ -542,49 +543,49 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: '10.0.0.1',
hostname: '10.0.0.1',
port: 42080,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
ssl: false,
public: false,
host: 'fe80::cd00:0cde:1257:211e:72cd',
hostname: 'fe80::cd00:0cde:1257:211e:72cd',
port: 42080,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
ssl: true,
public: true,
host: '203.0.113.45',
hostname: '203.0.113.45',
port: 42443,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
ssl: true,
public: true,
host: 'bitcoin.example.com',
hostname: 'bitcoin.example.com',
port: 42443,
metadata: { kind: 'public-domain', gateway: 'eth0' },
},
{
ssl: false,
public: false,
host: '192.168.10.11',
hostname: '192.168.10.11',
port: 42080,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: false,
public: false,
host: 'fe80::cd00:0cde:1257:211e:1234',
hostname: 'fe80::cd00:0cde:1257:211e:1234',
port: 42080,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
{
ssl: true,
public: false,
host: 'my-bitcoin.home',
hostname: 'my-bitcoin.home',
port: 42443,
metadata: {
kind: 'private-domain',
@@ -594,16 +595,16 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
hostname: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
port: 42080,
metadata: { kind: 'plugin', package: 'tor' },
metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null },
},
{
ssl: true,
public: false,
host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
hostname: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
port: 42443,
metadata: { kind: 'plugin', package: 'tor' },
metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null },
},
],
},
@@ -655,7 +656,7 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: 'adjective-noun.local',
hostname: 'adjective-noun.local',
port: 48332,
metadata: {
kind: 'mdns',
@@ -665,7 +666,7 @@ export const mockPatchData: DataModel = {
{
ssl: false,
public: false,
host: '10.0.0.1',
hostname: '10.0.0.1',
port: 48332,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
@@ -711,6 +712,7 @@ export const mockPatchData: DataModel = {
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
// 'bitcoind-config': {
// task: {