diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index a1a9cb659..76cd73353 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -70,6 +70,11 @@ file tracks notable changes since the move to the monorepo. any browser pointed at this server. Narrow windows and phones always show the grid. +- **An action can return a multi-line value** — a diagnostic report, a + generated config file, an exported key block. It appears as a read-only + monospace box that keeps its line breaks, and, where the service asks for it, + can be copied, shown as a QR code, or saved to a file. + ### Changed - **Your server's name is now its `.local` address, without the `.local` on the diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts index 3c1f455c8..8f7393233 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts @@ -1,39 +1,48 @@ import { Component, Input } from '@angular/core' import { TuiAccordion, TuiFade } from '@taiga-ui/kit' import { ActionSuccessMemberComponent } from './action-success-member.component' +import { ActionSuccessMultilineComponent } from './action-success-multiline.component' import { GroupResult } from './types' @Component({ selector: 'app-action-success-group', template: ` @for (member of group.value; track $index) { -

- @if (member.type === 'single') { - - } - @if (member.type === 'group') { - - - - - - - } -

+ @if (member.type === 'single') { + + } + @if (member.type === 'multiline') { + + } + @if (member.type === 'group') { + + + + + + + } } `, styles: ` - p:first-child { - margin-top: 0; - } - - p:last-child { - margin-bottom: 0; + :host { + display: flex; + flex-direction: column; + gap: 1rem; } `, - imports: [ActionSuccessMemberComponent, TuiAccordion, TuiFade], + imports: [ + ActionSuccessMemberComponent, + ActionSuccessMultilineComponent, + TuiAccordion, + TuiFade, + ], }) export class ActionSuccessGroupComponent { @Input() diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts new file mode 100644 index 000000000..d1df408ab --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts @@ -0,0 +1,152 @@ +import { + Component, + computed, + inject, + input, + linkedSignal, + TemplateRef, +} from '@angular/core' +import { CopyService, DialogService, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiTitle } from '@taiga-ui/core' +import { TuiTextarea } from '@taiga-ui/kit' +import { QRComponent } from 'src/app/routes/portal/components/qr.component' +import { MultilineResult } from './types' + +@Component({ + selector: 'app-action-success-multiline', + template: ` + @if (name()) { + + {{ name() }} + @if (description()) { + {{ description() }} + } + + } + + + @if (multiline().masked) { + + } + @if (multiline().copyable) { + + } + @if (multiline().qr) { + + } + @if (multiline().filename; as filename) { + + {{ 'Download' | i18n }} + + } + + + + @if (masked()) { + + } + + `, + styles: ` + @use '@taiga-ui/styles/utils' as taiga; + + :host { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + tui-textfield { + font-family: var(--tui-typography-family-code); + } + + .reveal { + @include taiga.center-all(); + } + `, + imports: [TuiButton, TuiTextarea, TuiTitle, QRComponent, i18nPipe], +}) +export class ActionSuccessMultilineComponent { + private readonly dialog = inject(DialogService) + readonly copy = inject(CopyService) + + readonly multiline = input.required() + readonly name = input('') + readonly description = input('') + + protected readonly masked = linkedSignal(() => this.multiline().masked) + protected readonly href = computed(() => + URL.createObjectURL( + new Blob([this.multiline().value], { type: 'application/octet-stream' }), + ), + ) + + protected show(template: TemplateRef) { + const masked = this.masked() + + this.masked.set(this.multiline().masked) + this.dialog + .openComponent(template, { label: 'Scan this QR', size: 's' }) + .subscribe({ complete: () => this.masked.set(masked) }) + } +} diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts index 7b1d7d95e..62851fb27 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts @@ -9,6 +9,7 @@ import { TuiDialogContext } from '@taiga-ui/core' import { NgDompurifyPipe } from '@taiga-ui/dompurify' import { injectContext } from '@taiga-ui/polymorpheus' import { ActionSuccessGroupComponent } from './action-success-group.component' +import { ActionSuccessMultilineComponent } from './action-success-multiline.component' import { ActionSuccessSingleComponent } from './action-success-single.component' import { ActionResponse } from './types' @@ -24,12 +25,23 @@ import { ActionResponse } from './types' @if (single) { } + @if (multiline) { + + } @if (group) { } `, + styles: ` + :host { + display: flex; + flex-direction: column; + gap: 1rem; + } + `, imports: [ ActionSuccessGroupComponent, + ActionSuccessMultilineComponent, ActionSuccessSingleComponent, NgDompurifyPipe, MarkdownPipe, @@ -43,6 +55,8 @@ export class ActionSuccessPage { readonly message = this.data.message as i18nKey | null readonly single = this.data.result?.type === 'single' ? this.data.result : null + readonly multiline = + this.data.result?.type === 'multiline' ? this.data.result : null readonly group = this.data.result?.type === 'group' ? this.data.result : null readonly options = { breaks: true } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts index 91835b7e7..80b6881b5 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts @@ -3,4 +3,5 @@ import { ActionRes } from 'src/app/services/api/api.types' export type ActionResponse = NonNullable type ActionResult = NonNullable export type SingleResult = ActionResult & { type: 'single' } +export type MultilineResult = ActionResult & { type: 'multiline' } export type GroupResult = ActionResult & { type: 'group' } diff --git a/projects/start-os/web/ui/src/app/services/action.service.ts b/projects/start-os/web/ui/src/app/services/action.service.ts index fceb0cdb0..d74644d7b 100644 --- a/projects/start-os/web/ui/src/app/services/action.service.ts +++ b/projects/start-os/web/ui/src/app/services/action.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core' import { DialogService, getErrorMessage, i18nKey } from '@start9labs/shared' +import { T } from '@start9labs/start-core' import { TuiNotificationMiddleService } from '@taiga-ui/kit' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { filter } from 'rxjs' @@ -68,6 +69,7 @@ export class ActionService { .openComponent(new PolymorpheusComponent(ActionSuccessPage), { label: res.title as i18nKey, data: res, + size: res.result && hasMultiline(res.result) ? 'l' : 'm', }) .subscribe() } @@ -80,3 +82,9 @@ export class ActionService { } } } + +function hasMultiline(value: T.ActionResultValue): boolean { + return value.type === 'group' + ? value.value.some(hasMultiline) + : value.type === 'multiline' +} diff --git a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts index 2bc56204f..e81e76757 100644 --- a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts +++ b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts @@ -1648,6 +1648,114 @@ Full changelog: https://github.com/Kixunil/btc-rpc-proxy/blob/master/CHANGELOG.m }, } + const DOCTOR_REPORT = `Vikunja Doctor +============== + +Checked against https://vikunja.embassy at 2026-08-28T14:02:11Z + +Component Status Detail +----------------- -------- --------------------------------------- +Database ok postgres 16.3, 41 tables, 0 pending +Typesense index degraded 4812 of 5104 tasks indexed +Attachment store ok 1.2 GiB across 318 files +Mailer skipped no SMTP credentials configured +Public URL ok resolves to 10.0.1.24:3456 + +2 warnings + - The search index is behind. Run "Reindex" to rebuild it. + - The mailer is unconfigured, so reminders and invitations are + silently dropped.` + + const DEVICE_CONFIG = `[Interface] +PrivateKey = qNSHDgIkG9Bo0dnjBRAmvIBaU0MI/ADoWfDaCu9uWFo= +Address = 10.13.13.4/32 +DNS = 10.13.13.1 + +[Peer] +PublicKey = HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw= +PresharedKey = uUYtV+HDNU9kZ0eDDBTBQfLuIJfHIPRSRfBWMWFBAgk= +Endpoint = tunnel.start9.com:51820 +AllowedIPs = 0.0.0.0/0, ::/0 +PersistentKeepalive = 25` + + export const ActionResMultiline: ActionRes = { + version: '1', + title: 'Diagnostics', + message: 'Send this report along if you open a support ticket.', + result: { + type: 'multiline', + copyable: true, + qr: false, + masked: false, + filename: 'vikunja-doctor.txt', + value: DOCTOR_REPORT, + }, + } + + export const ActionResMultilineSecret: ActionRes = { + version: '1', + title: 'Device Added', + message: 'Scan this from the WireGuard app, or save it as a file.', + result: { + type: 'multiline', + copyable: true, + qr: true, + masked: true, + filename: 'start-tunnel.conf', + value: DEVICE_CONFIG, + }, + } + + export const ActionResMultilineGroup: ActionRes = { + version: '1', + title: 'Service Information', + message: 'Everything StartOS could collect about this service.', + result: { + type: 'group', + value: [ + { + type: 'single', + name: 'Version', + description: null, + copyable: false, + qr: false, + masked: false, + value: '0.24.6', + }, + { + type: 'multiline', + name: 'Doctor Report', + description: 'The full output of `vikunja doctor`.', + copyable: true, + qr: false, + masked: false, + filename: 'vikunja-doctor.txt', + value: DOCTOR_REPORT, + }, + { + type: 'multiline', + name: 'Device Config', + description: 'The WireGuard config for the device you just added.', + copyable: true, + qr: true, + masked: true, + filename: 'start-tunnel.conf', + value: DEVICE_CONFIG, + }, + { + type: 'multiline', + name: 'Recovery Phrase', + description: 'Write this down. It is shown only once.', + copyable: true, + qr: false, + masked: true, + value: + 'shrug cinnamon plunge oyster\nharbor velvet timber acorn\nglisten fossil marble rooster', + }, + ], + }, + } + export const getCreateOnionServiceSpec = async (): Promise => configBuilderToSpec( ISB.InputSpec.of({ diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index 55845e40c..0477a1d99 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1312,6 +1312,11 @@ export class MockApiService extends ApiService { // return Mock.ActionResSingle if (params.actionId === 'big-qr') return Mock.ActionResBigQr if (params.actionId === 'unencodable-qr') return Mock.ActionResUnencodableQr + if (params.actionId === 'multiline') return Mock.ActionResMultiline + if (params.actionId === 'multiline-secret') + return Mock.ActionResMultilineSecret + if (params.actionId === 'multiline-group') + return Mock.ActionResMultilineGroup return Mock.ActionResMessage } diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts index 85a823e00..a9b1241ac 100644 --- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts +++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts @@ -521,6 +521,36 @@ export const mockPatchData: DataModel = { hasInput: false, group: null, }, + multiline: { + name: 'Show Report', + description: + 'Returns a multi-line value with a copy button and a download', + warning: null, + visibility: 'enabled', + allowedStatuses: 'any', + hasInput: false, + group: null, + }, + 'multiline-secret': { + name: 'Show Device Config', + description: + 'Returns a masked multi-line value that can also be shown as a QR code', + warning: null, + visibility: 'enabled', + allowedStatuses: 'any', + hasInput: false, + group: null, + }, + 'multiline-group': { + name: 'Show Mixed Group', + description: + 'Returns a group whose members mix single-line and multi-line values', + warning: null, + visibility: 'enabled', + allowedStatuses: 'any', + hasInput: false, + group: null, + }, test: { name: 'Do Another Thing', description: diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md index d38eb1777..d2338cee7 100644 --- a/projects/start-sdk/CHANGELOG.md +++ b/projects/start-sdk/CHANGELOG.md @@ -117,6 +117,12 @@ alongside it and default to `false`. See [Single Value](https://docs.start9.com/packaging/actions.html#single-value) +- **An action result can be a `multiline` value**: a read-only monospace box + that keeps its line breaks, taking the same optional `copyable` / `qr` / + `masked` flags as `single`, plus an optional `filename` that offers it as a + download. A `single` value is one line, and a newline in one is not rendered. + See [Result Types](https://docs.start9.com/packaging/actions.html#result-types) + ### Fixed - **Scaffolded package CI builds a draft PR when it becomes ready and rebuilds diff --git a/projects/start-sdk/docs/src/actions.md b/projects/start-sdk/docs/src/actions.md index 5e8f961d7..81af4ae0f 100644 --- a/projects/start-sdk/docs/src/actions.md +++ b/projects/start-sdk/docs/src/actions.md @@ -94,9 +94,7 @@ export const actions = sdk.Actions.of().addAction(setAdminPassword) Actions return structured results that the StartOS UI renders for the user. -`message` is prose shown under the title. It is rendered as **Markdown** — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Anything with its own line structure goes here. - -`result` holds discrete values the user acts on: each one is a single-line field with optional copy, QR, masking and link-opening, so a newline in a `value` is not rendered. A multi-line report goes in `message`, not in a `value`. +`message` is prose shown under the title. It is rendered as **Markdown** — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Guidance and next steps go here. ```typescript return { @@ -112,6 +110,16 @@ Restart the service once the rebuild finishes.`, } ``` +`result` holds the values the user acts on — copies, scans, or saves. It takes one of three types: + +| `type` | Renders as | Takes | +| ----------- | ------------------------------------------------------------------ | --------------------------------------------------------------- | +| `single` | a one-line field | `value`, plus optional `copyable`, `qr`, `masked`, `launchable` | +| `multiline` | a read-only monospace box that keeps its line breaks | `value`, plus optional `copyable`, `qr`, `masked`, `filename` | +| `group` | an accordion of named members, each of which is any of these three | `value`, the array of members | + +A newline in a `single` value is not rendered — the browser strips it from the field — so anything with its own line structure is a `multiline` value. `filename` is what separates "here is some text" from "here is a file": set it and the value is also offered as a download under that name; omit it for no download button. + ### Single Value ```typescript @@ -140,14 +148,28 @@ result: { The value must be an `http(s)` URL for the button to go anywhere. +### Multi-line Value + +```typescript +result: { + type: 'multiline', + value: report, + copyable: true, + filename: 'vikunja-doctor.txt', +} +``` + ### Group of Values +A member carries a `name`, and an optional `description`, on top of whatever its own type takes: + ```typescript result: { type: 'group', value: [ { type: 'single', name: 'Username', description: null, value: 'admin', masked: false, copyable: true, qr: false }, { type: 'single', name: 'Password', description: null, value: 'secret', masked: true, copyable: true, qr: false }, + { type: 'multiline', name: 'Device Config', description: null, value: config, masked: true, copyable: true, qr: true, filename: 'start-tunnel.conf' }, ], } ``` diff --git a/shared-libs/crates/start-core/src/action.rs b/shared-libs/crates/start-core/src/action.rs index 88d52ed52..33e7c1253 100644 --- a/shared-libs/crates/start-core/src/action.rs +++ b/shared-libs/crates/start-core/src/action.rs @@ -191,7 +191,7 @@ pub struct ActionResultMember { pub enum ActionResultValue { Single { /// The actual string value to display. The UI renders it as a single-line field — - /// multi-line text belongs in the result's `message`. + /// multi-line text belongs in a `multiline` value. value: String, /// (optional) Whether or not to include a copy to clipboard icon to copy the value #[ts(optional)] @@ -206,6 +206,22 @@ pub enum ActionResultValue { #[ts(optional)] launchable: Option, }, + Multiline { + /// The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks + value: String, + /// (optional) Whether or not to include a copy to clipboard icon to copy the value + #[ts(optional)] + copyable: Option, + /// (optional) Whether or not to also display the value as a QR code + #[ts(optional)] + qr: Option, + /// (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information + #[ts(optional)] + masked: Option, + /// (optional) Also offer the value as a download under this file name, such as "diagnostics.txt" + #[ts(optional)] + filename: Option, + }, Group { /// An new group of nested values, experienced by the user as an accordion dropdown value: Vec, @@ -214,11 +230,16 @@ pub enum ActionResultValue { impl ActionResultValue { fn fmt_rec(&self, f: &mut fmt::Formatter<'_>, indent: usize) -> fmt::Result { match self { - Self::Single { value, qr, .. } => { - for _ in 0..indent { - write!(f, " ")?; + Self::Single { value, qr, .. } | Self::Multiline { value, qr, .. } => { + for (i, line) in value.lines().enumerate() { + if i > 0 { + writeln!(f)?; + } + for _ in 0..indent { + write!(f, " ")?; + } + write!(f, "{line}")?; } - write!(f, "{value}")?; if qr.unwrap_or_default() { use qrcode::render::unicode; writeln!(f)?; diff --git a/shared-libs/ts-modules/shared/styles/taiga.scss b/shared-libs/ts-modules/shared/styles/taiga.scss index ec121432c..0d0dfc3ff 100644 --- a/shared-libs/ts-modules/shared/styles/taiga.scss +++ b/shared-libs/ts-modules/shared/styles/taiga.scss @@ -166,6 +166,7 @@ tui-textfield [tuiTooltip] { :root { --tui-typography-family-text: 'Hanken Grotesk', system-ui; --tui-typography-family-display: 'Hanken Grotesk', system-ui; + --tui-typography-family-code: ui-monospace, monospace; } tui-notification-middle { diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts index b8c5ee5c6..6ad14a571 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts @@ -14,7 +14,7 @@ export type ActionResultMember = { type: 'single' /** * The actual string value to display. The UI renders it as a single-line field — - * multi-line text belongs in the result's `message`. + * multi-line text belongs in a `multiline` value. */ value: string /** @@ -34,6 +34,29 @@ export type ActionResultMember = { */ launchable?: boolean } + | { + type: 'multiline' + /** + * The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks + */ + value: string + /** + * (optional) Whether or not to include a copy to clipboard icon to copy the value + */ + copyable?: boolean + /** + * (optional) Whether or not to also display the value as a QR code + */ + qr?: boolean + /** + * (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information + */ + masked?: boolean + /** + * (optional) Also offer the value as a download under this file name, such as "diagnostics.txt" + */ + filename?: string + } | { type: 'group' /** diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts index dc2ce28ea..475afdf0b 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts @@ -6,7 +6,7 @@ export type ActionResultValue = type: 'single' /** * The actual string value to display. The UI renders it as a single-line field — - * multi-line text belongs in the result's `message`. + * multi-line text belongs in a `multiline` value. */ value: string /** @@ -26,6 +26,29 @@ export type ActionResultValue = */ launchable?: boolean } + | { + type: 'multiline' + /** + * The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks + */ + value: string + /** + * (optional) Whether or not to include a copy to clipboard icon to copy the value + */ + copyable?: boolean + /** + * (optional) Whether or not to also display the value as a QR code + */ + qr?: boolean + /** + * (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information + */ + masked?: boolean + /** + * (optional) Also offer the value as a download under this file name, such as "diagnostics.txt" + */ + filename?: string + } | { type: 'group' /**