()
+
+ protected readonly errors = toSignal(this.depErrors.depErrors$)
+ protected readonly manifest = getManifest
+ protected readonly sorted = computed(() => {
+ const direction = this.asc() ? 1 : -1
+
+ return [...(this.services() || [])].sort((a, b) => byName(a, b) * direction)
+ })
+}
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts
index d922fafb01..36ba96b828 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts
@@ -1,6 +1,5 @@
import { Component, computed, input } from '@angular/core'
import { RouterLink } from '@angular/router'
-import { i18nPipe } from '@start9labs/shared'
import { TuiAvatar } from '@taiga-ui/kit'
import { ServiceUptimeComponent } from 'src/app/routes/portal/routes/services/components/uptime.component'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
@@ -11,7 +10,7 @@ import { StatusComponent } from './status.component'
@Component({
selector: 'tr[appService]',
template: `
- |
+ |
@@ -19,13 +18,12 @@ import { StatusComponent } from './status.component'
|
{{ manifest().title }}
|
-
+ |
|
{{ manifest().version }} |
@if (pkg().statusInfo.started; as started) {
- {{ 'Uptime' | i18n }}:
} @else {
-
@@ -53,10 +51,6 @@ import { StatusComponent } from './status.component'
font-weight: bold;
}
- span {
- display: none;
- }
-
.title {
width: 21rem;
}
@@ -72,72 +66,8 @@ import { StatusComponent } from './status.component'
.version {
width: 21rem;
}
-
- :host-context(tui-root._mobile) {
- position: relative;
- display: grid;
- grid-template: 1.25rem 2rem 1.5rem/4rem 1fr;
- align-items: center;
- padding: 1rem;
-
- &:hover {
- background: none;
- }
-
- [tuiAvatar] {
- height: 2.5rem;
- width: 2.5rem;
- }
-
- td {
- padding: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- color: var(--tui-text-secondary);
-
- &::before {
- display: inline;
- }
-
- &:empty {
- display: none;
- }
- }
-
- .title {
- grid-area: 2 / 2;
- font: var(--tui-typography-heading-h6);
- }
-
- .version {
- grid-area: 1 / 2;
- font: var(--tui-typography-body-s);
- }
-
- .uptime {
- grid-area: 4 / 2;
- display: flex;
- align-items: baseline;
- gap: 0.5rem;
-
- &:not(:has(service-uptime)) {
- display: none;
- }
-
- span {
- display: inline-block;
- }
- }
- }
`,
- imports: [
- RouterLink,
- StatusComponent,
- ServiceUptimeComponent,
- i18nPipe,
- TuiAvatar,
- ],
+ imports: [RouterLink, ServiceUptimeComponent, StatusComponent, TuiAvatar],
})
export class ServiceComponent {
readonly pkg = input.required()
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts
new file mode 100644
index 0000000000..171a56d5ec
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts
@@ -0,0 +1,12 @@
+import { TuiComparator } from '@taiga-ui/addon-table'
+import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
+import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
+import { getManifest } from 'src/app/utils/get-package-data'
+
+export const byName: TuiComparator = (a, b) =>
+ getManifest(b).title.toLowerCase() > getManifest(a).title.toLowerCase()
+ ? -1
+ : 1
+
+export const byStatus: TuiComparator = (a, b) =>
+ getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts
index c61889fefb..da282015e0 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts
@@ -1,106 +1,60 @@
import { Component, inject, input } from '@angular/core'
-import { FormsModule } from '@angular/forms'
-import { TableComponent } from 'src/app/routes/portal/components/table.component'
-import { T } from '@start9labs/start-core'
-import {
- PackageDataEntry,
- StateInfo,
-} from 'src/app/services/patch-db/data-model'
-import { ServiceComponent } from './service.component'
-import { TuiComparator, TuiTable } from '@taiga-ui/addon-table'
-import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
-import { getManifest } from 'src/app/utils/get-package-data'
-import { ToManifestPipe } from '../../../pipes/to-manifest'
import { toSignal } from '@angular/core/rxjs-interop'
-import { DepErrorService } from 'src/app/services/dep-error.service'
+import { FormsModule } from '@angular/forms'
+import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
+import { TuiTable } from '@taiga-ui/addon-table'
import { TuiSkeleton } from '@taiga-ui/kit'
-import { PlaceholderComponent } from '../../../components/placeholder.component'
-import { TuiButton } from '@taiga-ui/core'
-import { RouterLink } from '@angular/router'
+import { TableComponent } from 'src/app/routes/portal/components/table.component'
+import { DepErrorService } from 'src/app/services/dep-error.service'
+import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
+import { ToManifestPipe } from '../../../pipes/to-manifest'
+import { ServiceComponent } from './service.component'
+import { byName, byStatus } from './sorters'
@Component({
selector: '[services]',
template: `
- @if (services()?.length === 0) {
-
-
- {{ 'Welcome to' | i18n }}
- StartOS!
-
-
-
- {{
- 'To get started, visit the Marketplace and download your first service'
- | i18n
- }}
-
-
-
- {{ 'View Marketplace' | i18n }}
-
-
- } @else {
-
- @for (service of services() | tuiTableSort; track $index) {
-
- } @empty {
- @for (_ of ['', '']; track $index) {
-
- |
- {{ 'Loading' | i18n }}
- |
-
- }
+
+ @for (service of services() | tuiTableSort; track $index) {
+
+ } @empty {
+ @for (_ of ['', '']; track $index) {
+
+ |
+ {{ 'Loading' | i18n }}
+ |
+
}
-
- }
+ }
+
`,
imports: [
FormsModule,
- TableComponent,
+ RouterLink,
ServiceComponent,
+ TableComponent,
ToManifestPipe,
- i18nPipe,
TuiSkeleton,
- PlaceholderComponent,
- TuiButton,
- RouterLink,
TuiTable,
+ i18nPipe,
],
})
-export class ServicesTableComponent<
- T extends T.PackageDataEntry & {
- stateInfo: StateInfo
- },
-> {
- readonly errors = toSignal(inject(DepErrorService).depErrors$)
-
- readonly services = input.required()
+export class ServicesTableComponent {
+ private readonly depErrors = inject(DepErrorService)
- readonly name: TuiComparator = byName
-
- readonly status: TuiComparator = (a, b) =>
- getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1
-}
+ readonly services = input.required()
-function byName(a: PackageDataEntry, b: PackageDataEntry) {
- return getManifest(b).title.toLowerCase() > getManifest(a).title.toLowerCase()
- ? -1
- : 1
+ protected readonly errors = toSignal(this.depErrors.depErrors$)
+ protected readonly byName = byName
+ protected readonly byStatus = byStatus
}
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts
new file mode 100644
index 0000000000..32cf6d4de6
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts
@@ -0,0 +1,70 @@
+import { Component, computed, input } from '@angular/core'
+import { TuiCell, TuiTitle } from '@taiga-ui/core'
+import { TuiAvatar } from '@taiga-ui/kit'
+import { TuiCardLarge, tuiCardOptionsProvider } from '@taiga-ui/layout'
+import { ServiceUptimeComponent } from 'src/app/routes/portal/routes/services/components/uptime.component'
+import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
+import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
+import { getManifest } from 'src/app/utils/get-package-data'
+import { StatusComponent } from './status.component'
+
+@Component({
+ selector: 'a[appServiceTile]',
+ template: `
+
+
+
+
+
+ {{ manifest().title }}
+ {{ manifest().version }}
+
+
+
+
+ @if (pkg().statusInfo.started; as started) {
+
+ }
+
+ `,
+ styles: `
+ :host {
+ // "floating" paints elevation-1, which is the g-page ground itself.
+ --tui-background-elevation-1: color-mix(
+ in hsl,
+ var(--start9-base-2) 75%,
+ transparent
+ );
+
+ color: var(--tui-text-primary);
+ }
+
+ .status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--tui-text-secondary);
+ }
+ `,
+ host: { class: 'service-tile' },
+ hostDirectives: [TuiCardLarge],
+ providers: [
+ tuiCardOptionsProvider({ space: 'compact', appearance: 'floating' }),
+ ],
+ imports: [
+ ServiceUptimeComponent,
+ StatusComponent,
+ TuiAvatar,
+ TuiCell,
+ TuiTitle,
+ ],
+})
+export class ServiceTileComponent {
+ readonly pkg = input.required({ alias: 'appServiceTile' })
+ readonly depErrors = input({})
+
+ protected readonly manifest = computed(() => getManifest(this.pkg()))
+ protected readonly hasError = computed(() =>
+ Object.values(this.depErrors()).some(Boolean),
+ )
+}
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 26014ad13f..55845e40c0 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
@@ -1,4 +1,5 @@
import { inject, Injectable } from '@angular/core'
+import { WA_SESSION_STORAGE } from '@ng-web-apis/common'
import { GetPackageRes, GetPackagesRes } from '@start9labs/marketplace'
import {
FullKeyboard,
@@ -9,6 +10,7 @@ import {
import { T } from '@start9labs/start-core'
import {
AddOperation,
+ applyOperation,
Dump,
Operation,
PatchOp,
@@ -24,6 +26,7 @@ import {
InstallingState,
PackageDataEntry,
StateInfo,
+ UIData,
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { toAuthorityUrl } from 'src/app/utils/acme'
@@ -56,6 +59,8 @@ import { mockPatchData } from './mock-patch'
import markdown from './md-sample.md'
+const UI_KEY = '_startos/mock-ui'
+
const PROGRESS: T.FullProgress = {
overall: {
done: 0,
@@ -100,6 +105,7 @@ const INIT_PROGRESS: T.FullProgress = {
@Injectable()
export class MockApiService extends ApiService {
readonly mockWsSource$ = new Subject()
+ private readonly storage = inject(WA_SESSION_STORAGE)
private readonly revertTime = 1800
sequence = 0
@@ -187,6 +193,13 @@ export class MockApiService extends ApiService {
guid: string
}> {
await pauseFor(2000)
+
+ const stored = this.storage?.getItem(UI_KEY)
+
+ if (stored) {
+ mockPatchData.ui = { ...mockPatchData.ui, ...JSON.parse(stored) }
+ }
+
return {
dump: { id: 1, value: mockPatchData },
guid: 'db-guid',
@@ -198,16 +211,14 @@ export class MockApiService extends ApiService {
value: T,
): Promise {
const pointer = pathFromArray(pathArr)
- const params = { pointer, value }
await pauseFor(2000)
- const patch = [
- {
- op: PatchOp.REPLACE,
- path: '/ui' + params.pointer,
- value: params.value,
- },
- ]
- this.mockRevision(patch)
+
+ const ui: Dump = { id: 0, value: mockPatchData.ui }
+
+ applyOperation(ui, { op: PatchOp.REPLACE, path: pointer, value })
+ mockPatchData.ui = ui.value
+ this.storage?.setItem(UI_KEY, JSON.stringify(ui.value))
+ this.mockRevision([{ op: PatchOp.REPLACE, path: '/ui' + pointer, value }])
return null
}
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 e398e5798c..85a823e00e 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
@@ -1,8 +1,45 @@
-import { DataModel } from 'src/app/services/patch-db/data-model'
+import { T } from '@start9labs/start-core'
+import {
+ DataModel,
+ InstalledState,
+ PackageDataEntry,
+} from 'src/app/services/patch-db/data-model'
import { knownAuthorities } from 'src/app/utils/acme'
import { Mock } from './api.fixures'
const version = require('../../../../../../../../package.json').version
+function mockService(
+ id: string,
+ title: string,
+ pkgVersion: string,
+ statusInfo: T.StatusInfo,
+): PackageDataEntry {
+ return {
+ stateInfo: {
+ state: 'installed',
+ manifest: {
+ ...Mock.MockManifestBitcoind,
+ id,
+ title,
+ version: pkgVersion,
+ },
+ },
+ s9pk: `/media/startos/data/package-data/archive/installed/${id}.s9pk`,
+ icon: '/assets/img/service-icons/fallback.png',
+ lastBackup: null,
+ statusInfo,
+ actions: {},
+ currentDependencies: {},
+ hosts: {},
+ storeExposedDependents: [],
+ outboundGateway: null,
+ registry: 'https://registry.start9.com/',
+ developerKey: 'developer-key',
+ plugin: { url: null },
+ tasks: {},
+ }
+}
+
export const mockPatchData: DataModel = {
ui: {
registries: {
@@ -12,6 +49,7 @@ export const mockPatchData: DataModel = {
startosRegistry: 'https://registry.start9.com/',
snakeHighScore: 0,
hiddenUpdates: {},
+ servicesView: { desktopLayout: 'list', asc: true },
},
serverInfo: {
id: 'abcdefgh',
@@ -973,5 +1011,54 @@ export const mockPatchData: DataModel = {
plugin: { url: { tableAction: 'create-onion-service' } },
tasks: {},
},
+ nextcloud: mockService('nextcloud', 'Nextcloud', '30.0.2:0', {
+ desired: { main: 'running' },
+ error: null,
+ health: {
+ web: { name: 'Web Interface', result: 'success', message: null },
+ },
+ started: new Date(Date.now() - 86400000).toISOString(),
+ }),
+ vaultwarden: mockService('vaultwarden', 'Vaultwarden', '1.32.7:0', {
+ desired: { main: 'running' },
+ error: null,
+ health: {
+ web: { name: 'Web Vault', result: 'loading', message: 'Starting up' },
+ },
+ started: new Date(Date.now() - 30000).toISOString(),
+ }),
+ jellyfin: mockService('jellyfin', 'Jellyfin', '10.10.3:0', {
+ desired: { main: 'stopped' },
+ error: null,
+ health: {},
+ started: null,
+ }),
+ electrs: mockService('electrs', 'Electrs', '0.10.5:0', {
+ desired: { main: 'running' },
+ error: null,
+ health: {
+ rpc: {
+ name: 'RPC',
+ result: 'failure',
+ message: 'Cannot reach bitcoind',
+ },
+ },
+ started: new Date(Date.now() - 3600000).toISOString(),
+ }),
+ 'home-assistant': mockService(
+ 'home-assistant',
+ 'Home Assistant',
+ '2024.11.3:0',
+ {
+ desired: { main: 'running' },
+ error: {
+ details: 'Container exited unexpectedly',
+ debug: 'exit code 137',
+ info: null,
+ },
+ health: {},
+ started: null,
+ },
+ ),
},
}
diff --git a/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts b/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts
index c4a32c848d..ecebecb483 100644
--- a/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts
+++ b/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts
@@ -15,6 +15,14 @@ export type UIData = {
snakeHighScore: number
startosRegistry: string
hiddenUpdates: Record
+ // Absent on upgraded servers — the seed only applies at first boot.
+ servicesView?: ServicesView
+}
+
+export type ServicesView = {
+ // Narrow viewports always render the grid, so the layout is desktop-only.
+ desktopLayout: 'list' | 'grid'
+ asc: boolean
}
export type PackageDataEntry =
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
index ce2d3e37bf..6b4c0cdc28 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
@@ -816,4 +816,8 @@ export default {
923: 'Die lokale Adresse jedes installierten Dienstes',
924: 'Der Browser-Tab und der Name der installierten App',
925: 'SSH und Systemprotokolle',
+ 926: 'Listenansicht',
+ 927: 'Rasteransicht',
+ 928: 'Aufsteigend',
+ 929: 'Absteigend',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
index 923b1d95a5..239dbabd8d 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
@@ -817,4 +817,8 @@ export const ENGLISH: Record = {
'The local address of every installed service': 923,
'The browser tab and the name of the installed app': 924,
'SSH and system logs': 925,
+ 'List view': 926,
+ 'Grid view': 927,
+ 'Ascending': 928,
+ 'Descending': 929,
}
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
index fb11530f4e..1a97821fa7 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
@@ -816,4 +816,8 @@ export default {
923: 'La dirección local de cada servicio instalado',
924: 'La pestaña del navegador y el nombre de la aplicación instalada',
925: 'SSH y los registros del sistema',
+ 926: 'Vista de lista',
+ 927: 'Vista de cuadrícula',
+ 928: 'Ascendente',
+ 929: 'Descendente',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
index bb0b85278f..54113ed75b 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
@@ -816,4 +816,8 @@ export default {
923: 'L’adresse locale de chaque service installé',
924: 'L’onglet du navigateur et le nom de l’application installée',
925: 'SSH et les journaux système',
+ 926: 'Vue liste',
+ 927: 'Vue grille',
+ 928: 'Croissant',
+ 929: 'Décroissant',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
index 44f0f38275..c81c67da01 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
@@ -816,4 +816,8 @@ export default {
923: 'Lokalny adres każdej zainstalowanej usługi',
924: 'Karta przeglądarki i nazwa zainstalowanej aplikacji',
925: 'SSH i dzienniki systemowe',
+ 926: 'Widok listy',
+ 927: 'Widok siatki',
+ 928: 'Rosnąco',
+ 929: 'Malejąco',
} satisfies i18n
|