touch up URL plugins table

This commit is contained in:
Matt Hill
2026-02-19 11:41:41 -07:00
parent d562466fc4
commit 84149be3c1
6 changed files with 316 additions and 58 deletions

View File

@@ -10,7 +10,6 @@ import { T } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
tuiButtonOptionsProvider,
TuiDataList,
TuiDropdown,
TuiTextfield,
@@ -30,6 +29,9 @@ import {
selector: 'section[pluginGroup]',
template: `
<header>
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
}
{{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
@@ -43,7 +45,7 @@ import {
}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index) {
@for (address of pluginGroup().addresses; track $index; let i = $index) {
<tr>
<td>{{ address.hostnameInfo.ssl ? 'HTTPS' : 'HTTP' }}</td>
<td [style.grid-area]="'2 / 1 / 2 / 2'">
@@ -51,22 +53,6 @@ import {
</td>
<td [style.width.rem]="5">
<div class="desktop">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
@@ -91,7 +77,59 @@ import {
</button>
}
}
<!-- TODO @MattHill: overflow -->
}
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.overflowActions.length) {
<button
tuiIconButton
tuiDropdown
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="overflowOpen() === i ? 'hover' : null"
[tuiDropdownOpen]="overflowOpen() === i"
(tuiDropdownOpenChange)="
overflowOpen.set($event ? i : null)
"
>
{{ 'More' | i18n }}
<tui-data-list
*tuiTextfieldDropdown
(click)="overflowOpen.set(null)"
>
@for (
actionId of address.hostnameInfo.metadata
.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
</tui-data-list>
</button>
}
}
</div>
<div class="mobile">
@@ -105,22 +143,6 @@ import {
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
@@ -145,7 +167,38 @@ import {
</button>
}
}
<!-- TODO @MattHill: overflow -->
}
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</tui-data-list>
</button>
@@ -164,6 +217,12 @@ import {
</table>
`,
styles: `
.plugin-icon {
height: 1.25rem;
margin-right: 0.25rem;
border-radius: 100%;
}
:host ::ng-deep {
th:first-child {
width: 5rem;
@@ -217,7 +276,6 @@ import {
PlaceholderComponent,
i18nPipe,
],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PluginAddressesComponent {
@@ -226,6 +284,7 @@ export class PluginAddressesComponent {
private readonly actionService = inject(ActionService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
readonly overflowOpen = signal<number | null>(null)
readonly pluginGroup = input.required<PluginAddressGroup>()
readonly packageId = input('')

View File

@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiNotification } from '@taiga-ui/core'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PackageActionData } from './action-input.component'
@Component({
template: `
<div class="service-title">
<img [src]="pkgInfo.icon" alt="" />
<h4>{{ pkgInfo.title }}</h4>
</div>
<tui-notification appearance="warning">
<div [innerHTML]="warning"></div>
</tui-notification>
<footer class="g-buttons">
<button tuiButton appearance="flat" (click)="context.completeWith(false)">
{{ 'Cancel' | i18n }}
</button>
<button tuiButton (click)="context.completeWith(true)">
{{ 'Run' | i18n }}
</button>
</footer>
`,
styles: `
.service-title {
display: inline-flex;
align-items: center;
margin-bottom: 1.5rem;
img {
height: 1.25rem;
margin-right: 0.25rem;
border-radius: 100%;
}
h4 {
margin: 0;
}
}
footer {
margin-top: 1.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiNotification, i18nPipe],
})
export class ActionConfirmModal {
readonly context =
injectContext<TuiDialogContext<boolean, PackageActionData>>()
readonly pkgInfo = this.context.data.pkgInfo
readonly warning = this.context.data.actionInfo.metadata.warning
}
export const ACTION_CONFIRM_MODAL = new PolymorpheusComponent(
ActionConfirmModal,
)

View File

@@ -7,6 +7,7 @@ import {
} from '@start9labs/shared'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { ACTION_CONFIRM_MODAL } from 'src/app/routes/portal/routes/services/modals/action-confirm.component'
import {
ActionInputModal,
PackageActionData,
@@ -36,17 +37,15 @@ export class ActionService {
} else {
if (actionInfo.metadata.warning) {
this.dialog
.openConfirm({
label: 'Warning',
.openComponent<boolean>(ACTION_CONFIRM_MODAL, {
label: actionInfo.metadata.name as i18nKey,
size: 's',
data: {
no: 'Cancel',
yes: 'Run',
content: actionInfo.metadata.warning as i18nKey,
},
data,
})
.pipe(filter(Boolean))
.subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id, data.prefill))
.subscribe(() =>
this.execute(pkgInfo.id, null, actionInfo.id, data.prefill),
)
} else {
this.execute(pkgInfo.id, null, actionInfo.id, data.prefill)
}

View File

@@ -262,7 +262,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
plugins: [],
}
export const MockManifestLnd: T.Manifest = {
@@ -322,7 +322,54 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
plugins: [],
}
export const MockManifestTor: T.Manifest = {
id: 'tor',
title: 'Tor',
version: '0.4.8:0',
satisfies: [],
canMigrateTo: '!',
canMigrateFrom: '*',
gitHash: 'torhash1',
description: {
short: 'An anonymous overlay network.',
long: 'Tor provides anonymous communication by directing traffic through a free, worldwide overlay network.',
},
releaseNotes: 'Bug fixes and stability improvements.',
license: 'BSD-3-Clause',
packageRepo: 'https://github.com/start9labs/tor-wrapper',
upstreamRepo: 'https://gitlab.torproject.org/tpo/core/tor',
marketingUrl: 'https://www.torproject.org',
donationUrl: null,
docsUrls: ['https://docs.start9.com'],
alerts: {
install: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
osVersion: '0.2.12',
sdkVersion: '0.4.0',
dependencies: {},
images: {
main: {
source: 'packed',
arch: ['x86_64', 'aarch64'],
emulateMissingAs: 'aarch64',
nvidiaContainer: false,
},
},
volumes: ['main'],
hardwareRequirements: {
device: [],
arch: null,
ram: null,
},
hardwareAcceleration: false,
plugins: ['url-v0'],
}
export const MockManifestBitcoinProxy: T.Manifest = {
@@ -375,7 +422,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
plugins: [],
}
export const BitcoinDep: T.DependencyMetadata = {
@@ -435,7 +482,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -477,7 +524,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -529,7 +576,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -571,7 +618,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -628,7 +675,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
},
categories: ['lightning'],
@@ -683,7 +730,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
plugins: [],
},
},
categories: ['lightning'],
@@ -1444,6 +1491,31 @@ export namespace Mock {
},
}
export const getCreateOnionServiceSpec = async (): Promise<IST.InputSpec> =>
configBuilderToSpec(
ISB.InputSpec.of({
ssl: ISB.Value.toggle({
name: 'SSL',
description: 'Enable HTTPS for this onion service',
default: true,
}),
privateKey: ISB.Value.text({
name: 'Private Key',
description:
'Optionally provide an existing ed25519 private key to reuse a .onion address. Leave blank to generate a new one.',
required: false,
default: null,
masked: true,
}),
urlPluginMetadata: ISB.Value.hidden<{
packageId: string
interfaceId: string
hostId: string
internalPort: number
}>(),
}),
)
export const getActionInputSpec = async (): Promise<IST.InputSpec> =>
configBuilderToSpec(
ISB.InputSpec.of({

View File

@@ -1069,6 +1069,18 @@ export class MockApiService extends ApiService {
params: T.GetActionInputParams,
): Promise<GetActionInputRes> {
await pauseFor(2000)
if (
params.packageId === 'tor' &&
params.actionId === 'create-onion-service'
) {
return {
eventId: 'ANZXNWIFRTTBZ6T52KQPZILIQQODDHXQ',
value: null,
spec: await Mock.getCreateOnionServiceSpec(),
}
}
return {
eventId: 'ANZXNWIFRTTBZ6T52KQPZILIQQODDHXQ',
value: Mock.MockConfig,

View File

@@ -101,7 +101,7 @@ export const mockPatchData: DataModel = {
kind: 'plugin',
packageId: 'tor',
removeAction: 'delete-onion-service',
overflowActions: [],
overflowActions: ['regenerate-key'],
info: null,
},
},
@@ -115,7 +115,7 @@ export const mockPatchData: DataModel = {
kind: 'plugin',
packageId: 'tor',
removeAction: 'delete-onion-service',
overflowActions: [],
overflowActions: ['regenerate-key'],
info: null,
},
},
@@ -616,7 +616,7 @@ export const mockPatchData: DataModel = {
kind: 'plugin',
packageId: 'tor',
removeAction: 'delete-onion-service',
overflowActions: [],
overflowActions: ['regenerate-key'],
info: null,
},
},
@@ -630,7 +630,7 @@ export const mockPatchData: DataModel = {
kind: 'plugin',
packageId: 'tor',
removeAction: 'delete-onion-service',
overflowActions: [],
overflowActions: ['regenerate-key'],
info: null,
},
},
@@ -763,5 +763,62 @@ export const mockPatchData: DataModel = {
},
},
},
tor: {
stateInfo: {
state: 'installed',
manifest: {
...Mock.MockManifestTor,
version: '0.4.8:0',
},
},
s9pk: '/media/startos/data/package-data/archive/installed/tor.s9pk',
icon: '/assets/img/service-icons/fallback.png',
lastBackup: null,
statusInfo: {
desired: { main: 'running' },
error: null,
health: {},
started: new Date().toISOString(),
},
actions: {
'create-onion-service': {
name: 'Create Onion Service',
description: 'Register a new .onion address for a service interface',
warning: null,
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: true,
group: null,
},
'delete-onion-service': {
name: 'Delete Onion Service',
description: 'Remove an existing .onion address',
warning: 'This will permanently remove the .onion address.',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
'regenerate-key': {
name: 'Regenerate Key',
description: 'Generate a new key pair and .onion address',
warning:
'This will change the .onion address. Any bookmarks or links to the old address will stop working.',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
},
serviceInterfaces: {},
currentDependencies: {},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: { tableAction: 'create-onion-service' } },
tasks: {},
},
},
}