mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
refactor: refactor backups page to get rid of ionic (#2446)
This commit is contained in:
120
frontend/package-lock.json
generated
120
frontend/package-lock.json
generated
@@ -23,13 +23,13 @@
|
|||||||
"@start9labs/argon2": "^0.1.0",
|
"@start9labs/argon2": "^0.1.0",
|
||||||
"@start9labs/emver": "^0.1.5",
|
"@start9labs/emver": "^0.1.5",
|
||||||
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
|
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
|
||||||
"@taiga-ui/addon-charts": "3.45.0",
|
"@taiga-ui/addon-charts": "3.47.0",
|
||||||
"@taiga-ui/cdk": "3.45.0",
|
"@taiga-ui/cdk": "3.47.0",
|
||||||
"@taiga-ui/core": "3.45.0",
|
"@taiga-ui/core": "3.47.0",
|
||||||
"@taiga-ui/experimental": "3.45.0",
|
"@taiga-ui/experimental": "3.47.0",
|
||||||
"@taiga-ui/icons": "3.45.0",
|
"@taiga-ui/icons": "3.47.0",
|
||||||
"@taiga-ui/kit": "3.45.0",
|
"@taiga-ui/kit": "3.47.0",
|
||||||
"@taiga-ui/styles": "3.45.0",
|
"@taiga-ui/styles": "3.47.0",
|
||||||
"@tinkoff/ng-dompurify": "4.0.0",
|
"@tinkoff/ng-dompurify": "4.0.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
@@ -3632,9 +3632,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ng-web-apis/common": {
|
"node_modules/@ng-web-apis/common": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-3.0.3.tgz",
|
||||||
"integrity": "sha512-PWMegIsuxfmya8AgSx4fQR5mt4ozaSflJARN6I4W6kGKxX/MnHGt86+djN3P6KVoWjI+bcQt2UlF1jlW9DgWiQ==",
|
"integrity": "sha512-CJm/NYQ4JrN0qNVbPcKeRnZ5nL0zL6RrJrNwBW/LnZEGp9t0mxgLYKw52fM4xRm0OVXOXoRwCbjr8gSUD6vstQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.2.0"
|
"tslib": "^2.2.0"
|
||||||
},
|
},
|
||||||
@@ -3645,9 +3645,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ng-web-apis/intersection-observer": {
|
"node_modules/@ng-web-apis/intersection-observer": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-3.1.3.tgz",
|
||||||
"integrity": "sha512-AojVoHWCS62lJ6LE4BHzyY9E0CXIX8OLmdBw4q6PBJOSZan4vlpup/f9Pl2FPMvw2tVu986IvORFShu1d98y0g==",
|
"integrity": "sha512-mGxUcPOJ/y8oXY85c9k2UnZpGElu1wgAwN66brfFNKswwCYM8GLbrIOm0Zsdb6vyJiNFgaoZ+tG+dEZPobCzGQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.2.0"
|
"tslib": "^2.2.0"
|
||||||
},
|
},
|
||||||
@@ -3657,9 +3657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ng-web-apis/mutation-observer": {
|
"node_modules/@ng-web-apis/mutation-observer": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-3.0.3.tgz",
|
||||||
"integrity": "sha512-x1cq/Vznmz4aJ7STbZmA+4HCE+jxDiw2J359+iyiB+xyCVfZTECrJYP9g/hhzIRxyVPFznrPp61TDCRnLVyNWw==",
|
"integrity": "sha512-gl2OGn7+N8w0VuBLzGP5Ypw2nMqbnV3TgNdnQSyCC5I7+3Rz/Q3OzQqciTNUPAqd5HWWwW/IKFPvgI6ePYWXog==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.2.0"
|
"tslib": "^2.2.0"
|
||||||
},
|
},
|
||||||
@@ -3669,9 +3669,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ng-web-apis/resize-observer": {
|
"node_modules/@ng-web-apis/resize-observer": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-3.0.3.tgz",
|
||||||
"integrity": "sha512-4aTZNHztwyJe4nJY/++0diUcd8jL7kQS+doPCREE6U4niM8Xvc98uK4qD340Faw9pmybkgsKD7EinyyPE5DIFQ==",
|
"integrity": "sha512-2EVqcl/HTzObQmIgtXEs2KHrPUXC8r6ePPfbAAUbuVdlDAZm6vKsXYHvH+Zkm/JKNp1MZJb/3kb6UkkZtf8ewA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.2.0"
|
"tslib": "^2.2.0"
|
||||||
},
|
},
|
||||||
@@ -4041,9 +4041,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/addon-charts": {
|
"node_modules/@taiga-ui/addon-charts": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.47.0.tgz",
|
||||||
"integrity": "sha512-KR9d95Hix/+5oiXiyfnqnzpyxw7am689tZ69y+2Unv2kYENQqc0LXf2SG981rIHLJVr4mVDuBfmJ7I32M+zvVw==",
|
"integrity": "sha512-winbbnpo1hJv6vq/6ov2TEF1OnGCNeLgZAfokJ4/dYTysT9xUPgEiQAff55sqo6zlY2sYLBgHtxn3djeW5bj+Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
@@ -4051,19 +4051,19 @@
|
|||||||
"@angular/common": ">=12.0.0",
|
"@angular/common": ">=12.0.0",
|
||||||
"@angular/core": ">=12.0.0",
|
"@angular/core": ">=12.0.0",
|
||||||
"@ng-web-apis/common": ">=3.0.0",
|
"@ng-web-apis/common": ">=3.0.0",
|
||||||
"@taiga-ui/cdk": ">=3.45.0",
|
"@taiga-ui/cdk": ">=3.47.0",
|
||||||
"@taiga-ui/core": ">=3.45.0",
|
"@taiga-ui/core": ">=3.47.0",
|
||||||
"@tinkoff/ng-polymorpheus": ">=4.0.0"
|
"@tinkoff/ng-polymorpheus": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/cdk": {
|
"node_modules/@taiga-ui/cdk": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.47.0.tgz",
|
||||||
"integrity": "sha512-pFdy5yxkzPGYrtyA1e92SYYXel3uHb+3b2NFjNqbFzLnW3p0NYILZfQPb4I4U9X5cZBhVaFlPGsHXwswCZKqGw==",
|
"integrity": "sha512-SCEnKZq5Psac4NexCPDKmb+38YAcowaWWZNBuOgCJZp63aesHkRkH0KjMCoTBWe19F4EPsE48ARndJa/1wPQkA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ng-web-apis/common": "3.0.2",
|
"@ng-web-apis/common": "3.0.3",
|
||||||
"@ng-web-apis/mutation-observer": "3.0.2",
|
"@ng-web-apis/mutation-observer": "3.0.3",
|
||||||
"@ng-web-apis/resize-observer": "3.0.2",
|
"@ng-web-apis/resize-observer": "3.0.3",
|
||||||
"@tinkoff/ng-event-plugins": "3.1.0",
|
"@tinkoff/ng-event-plugins": "3.1.0",
|
||||||
"@tinkoff/ng-polymorpheus": "4.1.0",
|
"@tinkoff/ng-polymorpheus": "4.1.0",
|
||||||
"tslib": "2.6.2"
|
"tslib": "2.6.2"
|
||||||
@@ -4081,11 +4081,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/core": {
|
"node_modules/@taiga-ui/core": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.47.0.tgz",
|
||||||
"integrity": "sha512-16mCoBlorIx9PHZUGRWfX2K6LTMNo62h4bKOkZEz/l5nxzP+Wsa/vHgmditwE4eKg7v7nHGSPrdmNxlgzcs2dQ==",
|
"integrity": "sha512-+NKA/yvvOr/XZvv0DRDV2kTqdZ8W3weQX4t80lS8SvCM++yl4Ep8p7/tSv16LZYSpaFPxzWx7o4bPJkgD5CwzQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@taiga-ui/i18n": "^3.45.0",
|
"@taiga-ui/i18n": "^3.47.0",
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4097,34 +4097,34 @@
|
|||||||
"@angular/router": ">=12.0.0",
|
"@angular/router": ">=12.0.0",
|
||||||
"@ng-web-apis/common": ">=3.0.0",
|
"@ng-web-apis/common": ">=3.0.0",
|
||||||
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
||||||
"@taiga-ui/cdk": ">=3.45.0",
|
"@taiga-ui/cdk": ">=3.47.0",
|
||||||
"@taiga-ui/i18n": ">=3.45.0",
|
"@taiga-ui/i18n": ">=3.47.0",
|
||||||
"@tinkoff/ng-event-plugins": ">=3.1.0",
|
"@tinkoff/ng-event-plugins": ">=3.1.0",
|
||||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||||
"rxjs": ">=6.0.0"
|
"rxjs": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/experimental": {
|
"node_modules/@taiga-ui/experimental": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.47.0.tgz",
|
||||||
"integrity": "sha512-XsYKHl+CSGd/Te4UtQb/nHOOo9jI6UjAcqna2D5aPi/sntgP1m2hY7Wu0mxtk9ExajuqZjxocJSx1mSorwDC/Q==",
|
"integrity": "sha512-cmgVXL5aXPys2qL92Xk/fLcLpU/8EPGkayYN7UtT7MNnz3bFM+vbfiY7qbbXrNUvbPjjt5El81hG4NLu9/XJag==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": ">=12.0.0",
|
"@angular/common": ">=12.0.0",
|
||||||
"@angular/core": ">=12.0.0",
|
"@angular/core": ">=12.0.0",
|
||||||
"@taiga-ui/cdk": ">=3.45.0",
|
"@taiga-ui/cdk": ">=3.47.0",
|
||||||
"@taiga-ui/core": ">=3.45.0",
|
"@taiga-ui/core": ">=3.47.0",
|
||||||
"@taiga-ui/kit": ">=3.45.0",
|
"@taiga-ui/kit": ">=3.47.0",
|
||||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||||
"rxjs": ">=6.0.0"
|
"rxjs": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/i18n": {
|
"node_modules/@taiga-ui/i18n": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.47.0.tgz",
|
||||||
"integrity": "sha512-Dx8QvGaEu/i7M/F0QXa4fRygk5pL8ZXCnIyvRVWcGoJG9Bzfueb+1gsyBp/b7ogHK3FSgj88QsN1EBW1L0IiXQ==",
|
"integrity": "sha512-41C+ZBm8+rSR5/8ODmBn14dr5Z5MLYTpadXeV7u+AnrfsGQ3hA4D/AeiFHMNhp8zdXnsndDh0m/smB7Vkf2k5w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
@@ -4135,25 +4135,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/icons": {
|
"node_modules/@taiga-ui/icons": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.47.0.tgz",
|
||||||
"integrity": "sha512-Dn3ImJx2o3vEGtUn05IeEj0JPEYU3wEUtyXcOpK1mqN2AxYnLmAFU/5AtYMeJeQoGEuFsRlGbZfcihieR1CPsQ==",
|
"integrity": "sha512-natMScHcxN4o9p7cG1dZncGkqDD7PsCqtx+s9x7hivUz5V/CPxJC8p0qBZRxgwcVZfSvvBbl9iP6Pubazkztxw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@taiga-ui/cdk": ">=3.45.0"
|
"@taiga-ui/cdk": ">=3.47.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/kit": {
|
"node_modules/@taiga-ui/kit": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.47.0.tgz",
|
||||||
"integrity": "sha512-BlQeWh6x041YOsxHU+e0BZaZofo8QZ04gAtKewvWXXUret0keBEbnXhFpyiQLQR0GjM1/0ls3rPHORW4rYYvUw==",
|
"integrity": "sha512-un5nqmhxp9Q8Oa1aM5OjHxRTJb920raVImiXmaqJGyFIapgm8evqU6Ru9eaBH82giwMRUrVsjAa/zrzclvLsLg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@maskito/angular": "1.7.0",
|
"@maskito/angular": "1.7.0",
|
||||||
"@maskito/core": "1.7.0",
|
"@maskito/core": "1.7.0",
|
||||||
"@maskito/kit": "1.7.0",
|
"@maskito/kit": "1.7.0",
|
||||||
"@ng-web-apis/intersection-observer": "3.1.2",
|
"@ng-web-apis/intersection-observer": "3.1.3",
|
||||||
"text-mask-core": "5.1.2",
|
"text-mask-core": "5.1.2",
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
},
|
},
|
||||||
@@ -4165,19 +4165,19 @@
|
|||||||
"@ng-web-apis/common": ">=3.0.0",
|
"@ng-web-apis/common": ">=3.0.0",
|
||||||
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
||||||
"@ng-web-apis/resize-observer": ">=3.0.0",
|
"@ng-web-apis/resize-observer": ">=3.0.0",
|
||||||
"@taiga-ui/cdk": ">=3.45.0",
|
"@taiga-ui/cdk": ">=3.47.0",
|
||||||
"@taiga-ui/core": ">=3.45.0",
|
"@taiga-ui/core": ">=3.47.0",
|
||||||
"@taiga-ui/i18n": ">=3.45.0",
|
"@taiga-ui/i18n": ">=3.47.0",
|
||||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||||
"rxjs": ">=6.0.0"
|
"rxjs": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@taiga-ui/styles": {
|
"node_modules/@taiga-ui/styles": {
|
||||||
"version": "3.45.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-3.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-3.47.0.tgz",
|
||||||
"integrity": "sha512-WxJh5/U0JUsiJmPebw5x6PbCcfad7rV+soUhM3pdUb86uQs+grpqinIEXhhyeuIZrylofkyo70lgV13gJHRr/w==",
|
"integrity": "sha512-zgW9IhVhUpax7VuH3K4KL53OkZxy5JIxQ5JuSmLE+XIHsQ3sEDkoqnGSmvkDVbex5fo6Kbs/7iS5G1yKrSWw+Q==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@taiga-ui/cdk": ">=3.45.0",
|
"@taiga-ui/cdk": ">=3.47.0",
|
||||||
"tslib": ">=2.0.0"
|
"tslib": ">=2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,13 +44,13 @@
|
|||||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||||
"@start9labs/argon2": "^0.1.0",
|
"@start9labs/argon2": "^0.1.0",
|
||||||
"@start9labs/emver": "^0.1.5",
|
"@start9labs/emver": "^0.1.5",
|
||||||
"@taiga-ui/addon-charts": "3.45.0",
|
"@taiga-ui/addon-charts": "3.47.0",
|
||||||
"@taiga-ui/cdk": "3.45.0",
|
"@taiga-ui/cdk": "3.47.0",
|
||||||
"@taiga-ui/core": "3.45.0",
|
"@taiga-ui/core": "3.47.0",
|
||||||
"@taiga-ui/experimental": "3.45.0",
|
"@taiga-ui/experimental": "3.47.0",
|
||||||
"@taiga-ui/icons": "3.45.0",
|
"@taiga-ui/icons": "3.47.0",
|
||||||
"@taiga-ui/kit": "3.45.0",
|
"@taiga-ui/kit": "3.47.0",
|
||||||
"@taiga-ui/styles": "3.45.0",
|
"@taiga-ui/styles": "3.47.0",
|
||||||
"@tinkoff/ng-dompurify": "4.0.0",
|
"@tinkoff/ng-dompurify": "4.0.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<span class="link">
|
<span class="link">
|
||||||
<img alt="" class="icon" [src]="icon" />
|
<tui-svg
|
||||||
|
*ngIf="icon.startsWith('tuiIcon'); else url"
|
||||||
|
class="icon"
|
||||||
|
[src]="icon"
|
||||||
|
></tui-svg>
|
||||||
|
<ng-template #url>
|
||||||
|
<img alt="" class="icon" [src]="icon" />
|
||||||
|
</ng-template>
|
||||||
<label ticker class="title">{{ title }}</label>
|
<label ticker class="title">{{ title }}</label>
|
||||||
</span>
|
</span>
|
||||||
<span *ngIf="isService" class="side">
|
<span *ngIf="isService" class="side">
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tui-svg.icon {
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
TuiHostedDropdownModule,
|
TuiHostedDropdownModule,
|
||||||
TuiSvgModule,
|
TuiSvgModule,
|
||||||
} from '@taiga-ui/core'
|
} from '@taiga-ui/core'
|
||||||
import { NavigationService } from '../navigation/navigation.service'
|
import { NavigationService } from '../../services/navigation.service'
|
||||||
import { Action, ActionsComponent } from '../actions/actions.component'
|
import { Action, ActionsComponent } from '../actions/actions.component'
|
||||||
import { toRouterLink } from '../../utils/to-router-link'
|
import { toRouterLink } from '../../utils/to-router-link'
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
import { TuiInputModule } from '@taiga-ui/kit'
|
import { TuiInputModule } from '@taiga-ui/kit'
|
||||||
import { CardComponent } from '../card/card.component'
|
import { CardComponent } from '../card/card.component'
|
||||||
import { ServicesService } from '../../services/services.service'
|
import { ServicesService } from '../../services/services.service'
|
||||||
import { SYSTEM_UTILITIES } from './drawer.const'
|
|
||||||
import { toRouterLink } from '../../utils/to-router-link'
|
import { toRouterLink } from '../../utils/to-router-link'
|
||||||
import { DrawerItemDirective } from './drawer-item.directive'
|
import { DrawerItemDirective } from './drawer-item.directive'
|
||||||
|
import { SYSTEM_UTILITIES } from '../../constants/system-utilities'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-drawer',
|
selector: 'app-drawer',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
routerLinkActive="tab_active"
|
routerLinkActive="tab_active"
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
>
|
>
|
||||||
<tui-svg src="tuiIconHomeLarge" class="home"></tui-svg>
|
<tui-svg src="tuiIconHomeLarge" class="icon"></tui-svg>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
*ngFor="let tab of tabs$ | async"
|
*ngFor="let tab of tabs$ | async"
|
||||||
@@ -14,7 +14,14 @@
|
|||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
[routerLink]="tab.routerLink"
|
[routerLink]="tab.routerLink"
|
||||||
>
|
>
|
||||||
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
|
<tui-svg
|
||||||
|
*ngIf="tab.icon.startsWith('tuiIcon'); else url"
|
||||||
|
class="icon"
|
||||||
|
[src]="tab.icon"
|
||||||
|
></tui-svg>
|
||||||
|
<ng-template #url>
|
||||||
|
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
|
||||||
|
</ng-template>
|
||||||
<button
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
|
color: var(--tui-base-08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -29,7 +30,3 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home {
|
|
||||||
color: var(--tui-base-08);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { CommonModule, Location } from '@angular/common'
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
|
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
|
||||||
import { NavigationItem, NavigationService } from './navigation.service'
|
import { NavigationService } from '../../services/navigation.service'
|
||||||
|
import { NavigationItem } from '../../types/navigation-item'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'nav[appNavigation]',
|
selector: 'nav[appNavigation]',
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||||
{
|
{
|
||||||
|
'/portal/system/backups': {
|
||||||
|
icon: 'tuiIconSaveLarge',
|
||||||
|
title: 'Backups',
|
||||||
|
},
|
||||||
'/portal/system/devices': {
|
'/portal/system/devices': {
|
||||||
icon: 'assets/img/icon_transparent.png',
|
icon: 'assets/img/icon_transparent.png',
|
||||||
title: 'Devices',
|
title: 'Devices',
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||||
import { SYSTEM_UTILITIES } from '../components/drawer/drawer.const'
|
import { NavigationItem } from '../types/navigation-item'
|
||||||
import { NavigationItem } from '../components/navigation/navigation.service'
|
import { toDesktopItem } from '../utils/to-desktop-item'
|
||||||
import { toRouterLink } from '../utils/to-router-link'
|
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'toDesktopItem',
|
name: 'toDesktopItem',
|
||||||
@@ -13,23 +12,6 @@ export class ToDesktopItemPipe implements PipeTransform {
|
|||||||
packages: Record<string, PackageDataEntry>,
|
packages: Record<string, PackageDataEntry>,
|
||||||
id: string,
|
id: string,
|
||||||
): NavigationItem | null {
|
): NavigationItem | null {
|
||||||
if (!id) return null
|
return id ? toDesktopItem(id, packages) : null
|
||||||
|
|
||||||
const item = SYSTEM_UTILITIES[id]
|
|
||||||
const routerLink = toRouterLink(id)
|
|
||||||
|
|
||||||
if (SYSTEM_UTILITIES[id]) {
|
|
||||||
return {
|
|
||||||
icon: item.icon,
|
|
||||||
title: item.title,
|
|
||||||
routerLink,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
icon: packages[id]?.icon,
|
|
||||||
title: packages[id]?.manifest.title,
|
|
||||||
routerLink,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
|
|||||||
template: `
|
template: `
|
||||||
<ng-container *ngIf="pkg$ | async as pkg">
|
<ng-container *ngIf="pkg$ | async as pkg">
|
||||||
<section>
|
<section>
|
||||||
<h3>Standard Actions</h3>
|
<h3 class="g-title">Standard Actions</h3>
|
||||||
<button
|
<button
|
||||||
class="g-action"
|
class="g-action"
|
||||||
[action]="action"
|
[action]="action"
|
||||||
@@ -57,17 +57,6 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
`,
|
`,
|
||||||
styles: [
|
|
||||||
`
|
|
||||||
h3 {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
color: var(--tui-text-02);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe],
|
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<ng-template #installed>
|
<ng-template #installed>
|
||||||
<ng-container *ngIf="service | toStatus as status">
|
<ng-container *ngIf="service | toStatus as status">
|
||||||
<section>
|
<section>
|
||||||
<h3 class="title">Status</h3>
|
<h3 class="g-title">Status</h3>
|
||||||
<service-status
|
<service-status
|
||||||
class="status"
|
class="status"
|
||||||
[connected]="connected"
|
[connected]="connected"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
|
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
|
||||||
<section>
|
<section>
|
||||||
<h3 class="title">Interfaces</h3>
|
<h3 class="g-title">Interfaces</h3>
|
||||||
<button
|
<button
|
||||||
*ngFor="let info of service | interfaceInfo"
|
*ngFor="let info of service | interfaceInfo"
|
||||||
class="g-action"
|
class="g-action"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="isRunning(status)">
|
<ng-container *ngIf="isRunning(status)">
|
||||||
<section *ngIf="health$ | async as checks">
|
<section *ngIf="health$ | async as checks">
|
||||||
<h3 class="title">Health Checks</h3>
|
<h3 class="g-title">Health Checks</h3>
|
||||||
<service-health-check
|
<service-health-check
|
||||||
*ngFor="let check of checks"
|
*ngFor="let check of checks"
|
||||||
class="g-action"
|
class="g-action"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="service | toDependencies as dependencies">
|
<ng-container *ngIf="service | toDependencies as dependencies">
|
||||||
<section *ngIf="dependencies.length">
|
<section *ngIf="dependencies.length">
|
||||||
<h3 class="title">Dependencies</h3>
|
<h3 class="g-title">Dependencies</h3>
|
||||||
<button
|
<button
|
||||||
*ngFor="let dep of dependencies"
|
*ngFor="let dep of dependencies"
|
||||||
class="g-action"
|
class="g-action"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="title">Menu</h3>
|
<h3 class="g-title">Menu</h3>
|
||||||
<button
|
<button
|
||||||
*ngFor="let menu of service | toMenu"
|
*ngFor="let menu of service | toMenu"
|
||||||
class="g-action"
|
class="g-action"
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="title">Additional Info</h3>
|
<h3 class="g-title">Additional Info</h3>
|
||||||
<ng-container *ngFor="let additional of service | toAdditional">
|
<ng-container *ngFor="let additional of service | toAdditional">
|
||||||
<a
|
<a
|
||||||
*ngIf="additional.description.startsWith('http'); else button"
|
*ngIf="additional.description.startsWith('http'); else button"
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
class="g-action"
|
class="g-action"
|
||||||
[class.g-action_static]="!additional.icon"
|
[class.g-action_static]="!additional.icon"
|
||||||
[additional]="additional"
|
[additional]="additional"
|
||||||
(click)="additional.action && additional.action()"
|
(click)="additional.action?.()"
|
||||||
></button>
|
></button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
padding: 1px 2rem 3rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
// TODO: Theme
|
|
||||||
background: #373a3f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
color: var(--tui-text-02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g-action_static {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
StatusRendering,
|
StatusRendering,
|
||||||
} from 'src/app/services/pkg-status-rendering.service'
|
} from 'src/app/services/pkg-status-rendering.service'
|
||||||
import { ConnectionService } from 'src/app/services/connection.service'
|
import { ConnectionService } from 'src/app/services/connection.service'
|
||||||
import { NavigationService } from '../../components/navigation/navigation.service'
|
import { NavigationService } from '../../services/navigation.service'
|
||||||
import { toRouterLink } from '../../utils/to-router-link'
|
import { toRouterLink } from '../../utils/to-router-link'
|
||||||
|
|
||||||
const STATES = [
|
const STATES = [
|
||||||
@@ -29,6 +29,7 @@ const STATES = [
|
|||||||
@Component({
|
@Component({
|
||||||
templateUrl: 'service.component.html',
|
templateUrl: 'service.component.html',
|
||||||
styleUrls: ['service.component.scss'],
|
styleUrls: ['service.component.scss'],
|
||||||
|
host: { class: 'g-page' },
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ServiceComponent {
|
export class ServiceComponent {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { TuiDialogService, TuiSvgModule } from '@taiga-ui/core'
|
||||||
|
import { BackupsCreateService } from './services/create.service'
|
||||||
|
import { BackupsRestoreService } from './services/restore.service'
|
||||||
|
import { BackupsUpcomingComponent } from './components/upcoming.component'
|
||||||
|
import { TARGETS } from './modals/targets.component'
|
||||||
|
import { HISTORY } from './modals/history.component'
|
||||||
|
import { JOBS } from './modals/jobs.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<section>
|
||||||
|
<h3 class="g-title">Options</h3>
|
||||||
|
<button
|
||||||
|
*ngFor="let option of options"
|
||||||
|
class="g-action"
|
||||||
|
(click)="option.action()"
|
||||||
|
>
|
||||||
|
<tui-svg [src]="option.icon"></tui-svg>
|
||||||
|
<div>
|
||||||
|
<strong>{{ option.name }}</strong>
|
||||||
|
<div>{{ option.description }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 class="g-title">Upcoming Jobs</h3>
|
||||||
|
<table backupsUpcoming class="g-table"></table>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
host: { class: 'g-page' },
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TuiSvgModule, BackupsUpcomingComponent],
|
||||||
|
})
|
||||||
|
export class BackupsComponent {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
|
||||||
|
readonly options = [
|
||||||
|
{
|
||||||
|
name: 'Create a Backup',
|
||||||
|
icon: 'tuiIconPlusLarge',
|
||||||
|
description: 'Create a one-time backup',
|
||||||
|
action: inject(BackupsCreateService).handle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Restore from Backup',
|
||||||
|
icon: 'tuiIconShareLarge',
|
||||||
|
description: 'Restore services from a backup',
|
||||||
|
action: inject(BackupsRestoreService).handle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jobs',
|
||||||
|
icon: 'tuiIconToolLarge',
|
||||||
|
description: 'Manage backup jobs',
|
||||||
|
action: () =>
|
||||||
|
this.dialogs
|
||||||
|
.open(JOBS, { label: 'Backup Jobs', size: 'l' })
|
||||||
|
.subscribe(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Targets',
|
||||||
|
icon: 'tuiIconDatabaseLarge',
|
||||||
|
description: 'Manage backup targets',
|
||||||
|
action: () =>
|
||||||
|
this.dialogs.open(TARGETS, { label: 'Backup Targets' }).subscribe(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'History',
|
||||||
|
icon: 'tuiIconArchiveLarge',
|
||||||
|
description: 'View your entire backup history',
|
||||||
|
action: () =>
|
||||||
|
this.dialogs
|
||||||
|
.open(HISTORY, { label: 'Backup History', size: 'l' })
|
||||||
|
.subscribe(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
|
||||||
|
import { UnknownDisk } from 'src/app/services/api/api.types'
|
||||||
|
import { IonicModule } from '@ionic/angular'
|
||||||
|
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'table[backupsPhysical]',
|
||||||
|
template: `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Make/Model</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Capacity</th>
|
||||||
|
<th>Used</th>
|
||||||
|
<th [style.width.rem]="4.25"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let disk of backupsPhysical; else: loading; empty: blank">
|
||||||
|
<td>
|
||||||
|
{{ disk.vendor || 'unknown make' }},
|
||||||
|
{{ disk.model || 'unknown model' }}
|
||||||
|
</td>
|
||||||
|
<td>{{ disk.label }}</td>
|
||||||
|
<td>{{ disk.capacity | convertBytes }}</td>
|
||||||
|
<td>{{ disk.used ? (disk.used | convertBytes) : 'Unknown' }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="xs"
|
||||||
|
icon="tuiIconPlus"
|
||||||
|
(click)="add.emit(disk)"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<ng-template #loading>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="tui-skeleton">Loading</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #blank>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
To add a new physical backup target, connect the drive and click
|
||||||
|
refresh.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
IonicModule,
|
||||||
|
UnitConversionPipesModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsPhysicalComponent {
|
||||||
|
@Input()
|
||||||
|
backupsPhysical: readonly UnknownDisk[] | null = null
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
readonly add = new EventEmitter<UnknownDisk>()
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { Emver } from '@start9labs/shared'
|
||||||
|
import { TuiSvgModule } from '@taiga-ui/core'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { BackupType } from '../types/backup-type'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'backups-status',
|
||||||
|
template: `
|
||||||
|
<tui-svg [src]="status.icon" [style.color]="status.color"></tui-svg>
|
||||||
|
{{ status.text }}
|
||||||
|
`,
|
||||||
|
styles: [':host { display: flex; gap: 0.5rem; align-items: center }'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [TuiSvgModule],
|
||||||
|
})
|
||||||
|
export class BackupsStatusComponent {
|
||||||
|
private readonly emver = inject(Emver)
|
||||||
|
|
||||||
|
@Input({ required: true }) type!: BackupType
|
||||||
|
@Input({ required: true }) target!: BackupTarget
|
||||||
|
|
||||||
|
get status() {
|
||||||
|
if (!this.target.mountable) {
|
||||||
|
return {
|
||||||
|
icon: 'tuiIconBarChartLarge',
|
||||||
|
color: 'var(--tui-negative)',
|
||||||
|
text: 'Unable to connect',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.type === 'create') {
|
||||||
|
return {
|
||||||
|
icon: 'tuiIconCloudLarge',
|
||||||
|
color: 'var(--tui-positive)',
|
||||||
|
text: this.hasBackup
|
||||||
|
? 'Available, contains existing backup'
|
||||||
|
: 'Available for fresh backup',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasBackup) {
|
||||||
|
return {
|
||||||
|
icon: 'tuiIconCloudLarge',
|
||||||
|
color: 'var(--tui-positive)',
|
||||||
|
text: 'Embassy backup detected',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: 'tuiIconCloudOffLarge',
|
||||||
|
color: 'var(--tui-negative)',
|
||||||
|
text: 'No Embassy backup',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get hasBackup(): boolean {
|
||||||
|
return (
|
||||||
|
!!this.target['embassy-os'] &&
|
||||||
|
this.emver.compare(this.target['embassy-os'].version, '0.3.0') !== -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogOptions,
|
||||||
|
TuiDialogService,
|
||||||
|
TuiSvgModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||||
|
import { filter, map, Subject, switchMap } from 'rxjs'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'table[backupsTargets]',
|
||||||
|
template: `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Available</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th [style.width.rem]="3.5"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let target of backupsTargets; else: loading; empty: blank">
|
||||||
|
<td>{{ target.name }}</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg [src]="target.type | getBackupIcon"></tui-svg>
|
||||||
|
{{ target.type | titlecase }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg
|
||||||
|
[src]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'"
|
||||||
|
[style.color]="
|
||||||
|
target.mountable ? 'var(--tui-positive)' : 'var(--tui-negative)'
|
||||||
|
"
|
||||||
|
></tui-svg>
|
||||||
|
</td>
|
||||||
|
<td>{{ target.path }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
appearance="icon"
|
||||||
|
icon="tuiIconEdit2"
|
||||||
|
(click)="update.emit(target)"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
appearance="icon"
|
||||||
|
icon="tuiIconTrash2"
|
||||||
|
(click)="delete$.next(target.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<ng-template #loading>
|
||||||
|
<tr *ngFor="let i of ['', '']">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="tui-skeleton">Loading</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #blank>
|
||||||
|
<tr><td colspan="5">No saved backup targets.</td></tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
GetBackupIconPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsTargetsComponent {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
|
||||||
|
readonly delete$ = new Subject<string>()
|
||||||
|
readonly update$ = new Subject<BackupTarget>()
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
backupsTargets: readonly BackupTarget[] | null = null
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
readonly update = new EventEmitter<BackupTarget>()
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
readonly delete = this.delete$.pipe(
|
||||||
|
switchMap(id =>
|
||||||
|
this.dialogs.open(TUI_PROMPT, OPTIONS).pipe(
|
||||||
|
filter(Boolean),
|
||||||
|
map(() => id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
|
||||||
|
label: 'Confirm',
|
||||||
|
size: 's',
|
||||||
|
data: {
|
||||||
|
content: 'Forget backup target? This actions cannot be undone.',
|
||||||
|
no: 'Cancel',
|
||||||
|
yes: 'Delete',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import { TuiSvgModule } from '@taiga-ui/core'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { from, map, Observable } from 'rxjs'
|
||||||
|
import { CronJob } from 'cron'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupJob } from 'src/app/services/api/api.types'
|
||||||
|
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'table[backupsUpcoming]',
|
||||||
|
template: `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Scheduled</th>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Packages</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody *ngIf="current$ | async as current">
|
||||||
|
<tr *ngFor="let job of upcoming$ | async; else: loading; empty: blank">
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
*ngIf="current.id === job.id; else notRunning"
|
||||||
|
[style.color]="'var(--tui-positive)'"
|
||||||
|
>
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
<ng-template #notRunning>
|
||||||
|
{{ job.next | date : 'MMM d, y, h:mm a' }}
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
<td>{{ job.name }}</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
|
||||||
|
{{ job.target.name }}
|
||||||
|
</td>
|
||||||
|
<td>Packages: {{ job['package-ids'].length }}</td>
|
||||||
|
</tr>
|
||||||
|
<ng-template #blank>
|
||||||
|
<tr><td colspan="5">You have no active or upcoming backup jobs</td></tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #loading>
|
||||||
|
<tr *ngFor="let row of ['', '']">
|
||||||
|
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TuiForModule, TuiSvgModule, GetBackupIconPipe],
|
||||||
|
})
|
||||||
|
export class BackupsUpcomingComponent {
|
||||||
|
readonly current$: Observable<BackupJob> = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
.watch$('server-info', 'status-info', 'current-backup', 'job')
|
||||||
|
.pipe(map(job => job || {}))
|
||||||
|
|
||||||
|
readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe(
|
||||||
|
map(jobs =>
|
||||||
|
jobs
|
||||||
|
.map(job => {
|
||||||
|
const nextDate = new CronJob(job.cron, () => {}).nextDate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
next: nextDate.toISO(),
|
||||||
|
diff: nextDate.diffNow().milliseconds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.diff - b.diff),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogContext,
|
||||||
|
TuiDialogOptions,
|
||||||
|
TuiGroupModule,
|
||||||
|
TuiLoaderModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||||
|
import {
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
PolymorpheusComponent,
|
||||||
|
} from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { firstValueFrom, map } from 'rxjs'
|
||||||
|
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
disabled: boolean
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<div tuiGroup orientation="vertical">
|
||||||
|
<tui-checkbox-block
|
||||||
|
*ngFor="let pkg of pkgs; else: loading; empty: blank"
|
||||||
|
[disabled]="pkg.disabled"
|
||||||
|
[(ngModel)]="pkg.checked"
|
||||||
|
(ngModelChange)="handleChange()"
|
||||||
|
>
|
||||||
|
<div class="g-action">
|
||||||
|
<img class="icon" alt="" [src]="pkg.icon" />
|
||||||
|
{{ pkg.title }}
|
||||||
|
</div>
|
||||||
|
</tui-checkbox-block>
|
||||||
|
<ng-template #loading><tui-loader></tui-loader></ng-template>
|
||||||
|
<ng-template #blank>No services installed!</ng-template>
|
||||||
|
</div>
|
||||||
|
<footer class="g-buttons">
|
||||||
|
<button tuiButton appearance="flat" (click)="toggleSelectAll()">
|
||||||
|
Toggle all
|
||||||
|
</button>
|
||||||
|
<button tuiButton [disabled]="!hasSelection" (click)="done()">
|
||||||
|
{{ context.data.btnText || 'Done' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiGroupModule,
|
||||||
|
TuiCheckboxBlockModule,
|
||||||
|
TuiLoaderModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsBackupModal {
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
readonly context =
|
||||||
|
inject<TuiDialogContext<string[], { btnText: string }>>(
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
hasSelection = false
|
||||||
|
|
||||||
|
pkgs: readonly Package[] | null = null
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
this.pkgs = await firstValueFrom(
|
||||||
|
this.patch.watch$('package-data').pipe(
|
||||||
|
map(pkgs =>
|
||||||
|
Object.values(pkgs)
|
||||||
|
.map(({ manifest: { id, title }, icon, state }) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
disabled: state !== PackageState.Installed,
|
||||||
|
checked: false,
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
done() {
|
||||||
|
this.context.completeWith(
|
||||||
|
this.pkgs?.filter(p => p.checked).map(p => p.id) || [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange() {
|
||||||
|
this.hasSelection = !!this.pkgs?.some(p => p.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll() {
|
||||||
|
this.pkgs?.forEach(p => (p.checked = !this.hasSelection && !p.disabled))
|
||||||
|
this.hasSelection = !this.hasSelection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKUP = new PolymorpheusComponent(BackupsBackupModal)
|
||||||
|
|
||||||
|
export const BACKUP_OPTIONS: Partial<TuiDialogOptions<unknown>> = {
|
||||||
|
label: 'Select Services to Back Up',
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TuiDialogContext,
|
||||||
|
TuiDialogService,
|
||||||
|
TuiWrapperModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
|
||||||
|
import {
|
||||||
|
TuiInputModule,
|
||||||
|
TuiInputNumberModule,
|
||||||
|
TuiToggleModule,
|
||||||
|
} from '@taiga-ui/kit'
|
||||||
|
import {
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
PolymorpheusComponent,
|
||||||
|
} from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupJob, BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { TARGET, TARGET_CREATE } from './target.component'
|
||||||
|
import { BACKUP, BACKUP_OPTIONS } from './backup.component'
|
||||||
|
import { BackupJobBuilder } from '../utils/job-builder'
|
||||||
|
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<form class="form">
|
||||||
|
<tui-input name="name" [(ngModel)]="job.name">
|
||||||
|
Job Name
|
||||||
|
<input tuiTextfield placeholder="My Backup Job" />
|
||||||
|
</tui-input>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
appearance="secondary"
|
||||||
|
type="button"
|
||||||
|
class="button"
|
||||||
|
(click)="selectTarget()"
|
||||||
|
>
|
||||||
|
Target
|
||||||
|
<tui-badge [appearance]="job.target.type ? 'success' : 'warning'">
|
||||||
|
{{ job.target.type || 'Select target' }}
|
||||||
|
</tui-badge>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
appearance="secondary"
|
||||||
|
type="button"
|
||||||
|
class="button"
|
||||||
|
(click)="selectPackages()"
|
||||||
|
>
|
||||||
|
Packages
|
||||||
|
<tui-badge
|
||||||
|
[appearance]="job['package-ids'].length ? 'success' : 'warning'"
|
||||||
|
>
|
||||||
|
{{ job['package-ids'].length + ' selected' }}
|
||||||
|
</tui-badge>
|
||||||
|
</button>
|
||||||
|
<tui-input name="cron" [(ngModel)]="job.cron">
|
||||||
|
Schedule
|
||||||
|
<input tuiTextfield placeholder="* * * * *" />
|
||||||
|
</tui-input>
|
||||||
|
<div *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
|
||||||
|
{{ human.message }}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!job.job.id" class="g-toggle">
|
||||||
|
Also Execute Now
|
||||||
|
<tui-toggle size="l" name="now" [(ngModel)]="job.now"></tui-toggle>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="m"
|
||||||
|
class="submit"
|
||||||
|
[style.margin-left]="'auto'"
|
||||||
|
(click)="save()"
|
||||||
|
>
|
||||||
|
Save Job
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[data-size] {
|
||||||
|
width: unset;
|
||||||
|
padding: 1rem;
|
||||||
|
text-indent: 0;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
TuiInputModule,
|
||||||
|
TuiInputNumberModule,
|
||||||
|
TuiToggleModule,
|
||||||
|
TuiWrapperModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiBadgeModule,
|
||||||
|
ToHumanCronPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsEditModal {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly context =
|
||||||
|
inject<TuiDialogContext<BackupJob, BackupJobBuilder>>(POLYMORPHEUS_CONTEXT)
|
||||||
|
|
||||||
|
get job() {
|
||||||
|
return this.context.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const loader = this.loader.open('Saving Job').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = this.job.job.id
|
||||||
|
? await this.api.updateBackupJob(this.job.buildUpdate(this.job.job.id))
|
||||||
|
: await this.api.createBackupJob(this.job.buildCreate())
|
||||||
|
|
||||||
|
this.context.completeWith(job)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTarget() {
|
||||||
|
this.dialogs.open<BackupTarget>(TARGET, TARGET_CREATE).subscribe(target => {
|
||||||
|
this.job.target = target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPackages() {
|
||||||
|
this.dialogs.open<string[]>(BACKUP, BACKUP_OPTIONS).subscribe(id => {
|
||||||
|
this.job['package-ids'] = id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EDIT = new PolymorpheusComponent(BackupsEditModal)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||||
|
import {
|
||||||
|
ALWAYS_FALSE_HANDLER,
|
||||||
|
ALWAYS_TRUE_HANDLER,
|
||||||
|
TuiForModule,
|
||||||
|
} from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogService,
|
||||||
|
TuiLinkModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiCheckboxModule } from '@taiga-ui/kit'
|
||||||
|
import { BehaviorSubject } from 'rxjs'
|
||||||
|
import { BackupRun } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { DurationPipe } from '../pipes/duration.pipe'
|
||||||
|
import { HasErrorPipe } from '../pipes/has-error.pipe'
|
||||||
|
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||||
|
import { REPORT } from './report.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *ngIf="loading$ | async"></ng-container>
|
||||||
|
<h3 class="g-title">
|
||||||
|
Past Events
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="m"
|
||||||
|
appearance="secondary-destructive"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(click)="delete()"
|
||||||
|
>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<table class="g-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<tui-checkbox
|
||||||
|
[disabled]="!selected.length"
|
||||||
|
[ngModel]="all"
|
||||||
|
(ngModelChange)="toggle()"
|
||||||
|
></tui-checkbox>
|
||||||
|
</th>
|
||||||
|
<th>Started At</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Target</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
*ngFor="
|
||||||
|
let run of runs;
|
||||||
|
let index = index;
|
||||||
|
else: loading;
|
||||||
|
empty: blank
|
||||||
|
"
|
||||||
|
[style.background]="selected[index] ? 'var(--tui-clear)' : ''"
|
||||||
|
>
|
||||||
|
<td><tui-checkbox [(ngModel)]="selected[index]"></tui-checkbox></td>
|
||||||
|
<td>{{ run['started-at'] | date : 'medium' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ run['started-at'] | duration : run['completed-at'] }} Minutes
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg
|
||||||
|
*ngIf="run.report | hasError; else noError"
|
||||||
|
src="tuiIconClose"
|
||||||
|
[style.color]="'var(--tui-negative)'"
|
||||||
|
></tui-svg>
|
||||||
|
<ng-template #noError>
|
||||||
|
<tui-svg
|
||||||
|
src="tuiIconCheck"
|
||||||
|
[style.color]="'var(--tui-positive)'"
|
||||||
|
></tui-svg>
|
||||||
|
</ng-template>
|
||||||
|
<button tuiLink (click)="showReport(run)">Report</button>
|
||||||
|
</td>
|
||||||
|
<td>{{ run.job.name || 'No job' }}</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg [src]="run.job.target.type | getBackupIcon"></tui-svg>
|
||||||
|
{{ run.job.target.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<ng-template #loading>
|
||||||
|
<tr *ngFor="let row of ['', '', '']">
|
||||||
|
<td colspan="6"><div class="tui-skeleton">Loading</div></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #blank>
|
||||||
|
<tr><td colspan="6">No backups have been run yet.</td></tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiCheckboxModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
TuiLinkModule,
|
||||||
|
DurationPipe,
|
||||||
|
HasErrorPipe,
|
||||||
|
GetBackupIconPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsHistoryModal {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
|
||||||
|
readonly loading$ = new BehaviorSubject(true)
|
||||||
|
|
||||||
|
runs: BackupRun[] | null = null
|
||||||
|
selected: boolean[] = []
|
||||||
|
|
||||||
|
get all(): boolean | null {
|
||||||
|
if (this.selected.length === 0) return false
|
||||||
|
|
||||||
|
const response = this.selected[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < this.selected.length; i++) {
|
||||||
|
if (this.selected[i] !== response) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
get disabled() {
|
||||||
|
return !this.selected.length || !this.selected.some(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
this.runs = await this.api.getBackupRuns({})
|
||||||
|
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.runs = []
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
this.loading$.next(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
const loader = this.loader.open('Deleting...').subscribe()
|
||||||
|
const ids = this.selected
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((_, i) => this.runs?.[i].id || '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.deleteBackupRuns({ ids })
|
||||||
|
this.runs = this.runs?.filter(r => !ids.includes(r.id)) || []
|
||||||
|
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showReport(run: BackupRun) {
|
||||||
|
this.dialogs
|
||||||
|
.open(REPORT, {
|
||||||
|
label: 'Backup Report',
|
||||||
|
data: {
|
||||||
|
report: run.report,
|
||||||
|
timestamp: run['completed-at'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.all) {
|
||||||
|
this.selected = this.selected.map(ALWAYS_FALSE_HANDLER)
|
||||||
|
} else {
|
||||||
|
this.selected = this.selected.map(ALWAYS_TRUE_HANDLER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORY = new PolymorpheusComponent(BackupsHistoryModal)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject, OnInit } from '@angular/core'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogOptions,
|
||||||
|
TuiDialogService,
|
||||||
|
TuiNotificationModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||||
|
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { BehaviorSubject, filter } from 'rxjs'
|
||||||
|
import { BackupJob } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupJobBuilder } from '../utils/job-builder'
|
||||||
|
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
|
||||||
|
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||||
|
import { EDIT } from './edit.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<tui-notification>
|
||||||
|
Scheduling automatic backups is an excellent way to ensure your Embassy
|
||||||
|
data is safely backed up. Your Embassy will issue a notification whenever
|
||||||
|
one of your scheduled backups succeeds or fails.
|
||||||
|
<a
|
||||||
|
href="https://docs.start9.com/latest/user-manual/backups/backup-jobs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
View instructions
|
||||||
|
</a>
|
||||||
|
</tui-notification>
|
||||||
|
<h3 class="g-title">
|
||||||
|
Saved Jobs
|
||||||
|
<button tuiButton size="s" icon="tuiIconPlus" (click)="create()">
|
||||||
|
Create New Job
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<table class="g-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Packages</th>
|
||||||
|
<th>Schedule</th>
|
||||||
|
<th [style.width.rem]="3.5"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let job of jobs || null; else: loading; empty: blank">
|
||||||
|
<td>{{ job.name }}</td>
|
||||||
|
<td>
|
||||||
|
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
|
||||||
|
{{ job.target.name }}
|
||||||
|
</td>
|
||||||
|
<td>Packages: {{ job['package-ids'].length }}</td>
|
||||||
|
<td>{{ (job.cron | toHumanCron).message }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
appearance="icon"
|
||||||
|
size="xs"
|
||||||
|
icon="tuiIconEdit2"
|
||||||
|
(click)="update(job)"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
appearance="icon"
|
||||||
|
size="xs"
|
||||||
|
icon="tuiIconTrash2"
|
||||||
|
(click)="delete(job.id)"
|
||||||
|
></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<ng-template #loading>
|
||||||
|
<tr *ngFor="let i of ['', '']">
|
||||||
|
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #blank>
|
||||||
|
<tr><td colspan="5">No jobs found.</td></tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiNotificationModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
ToHumanCronPipe,
|
||||||
|
GetBackupIconPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsJobsModal implements OnInit {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
|
readonly loading$ = new BehaviorSubject(true)
|
||||||
|
|
||||||
|
jobs?: BackupJob[]
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
this.jobs = await this.api.getBackupJobs({})
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
this.loading$.next(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.dialogs
|
||||||
|
.open<BackupJob>(EDIT, {
|
||||||
|
label: 'Create New Job',
|
||||||
|
data: new BackupJobBuilder({
|
||||||
|
name: `Backup Job ${(this.jobs?.length || 0) + 1}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.subscribe(job => {
|
||||||
|
this.jobs = this.jobs?.concat(job)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: BackupJob) {
|
||||||
|
this.dialogs
|
||||||
|
.open<BackupJob>(EDIT, {
|
||||||
|
label: 'Edit Job',
|
||||||
|
data: new BackupJobBuilder(data),
|
||||||
|
})
|
||||||
|
.subscribe(job => {
|
||||||
|
data.name = job.name
|
||||||
|
data.target = job.target
|
||||||
|
data.cron = job.cron
|
||||||
|
data['package-ids'] = job['package-ids']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string) {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_PROMPT, PROMPT_OPTIONS)
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const loader = this.loader.open('Deleting...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.removeBackupTarget({ id })
|
||||||
|
this.jobs = this.jobs?.filter(a => a.id !== id)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
|
||||||
|
label: 'Confirm',
|
||||||
|
size: 's',
|
||||||
|
data: {
|
||||||
|
content: 'Delete backup job? This action cannot be undone.',
|
||||||
|
yes: 'Delete',
|
||||||
|
no: 'Cancel',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JOBS = new PolymorpheusComponent(BackupsJobsModal)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogContext,
|
||||||
|
TuiGroupModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||||
|
import {
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
PolymorpheusComponent,
|
||||||
|
} from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { take } from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||||
|
import { ToOptionsPipe } from '../pipes/to-options.pipe'
|
||||||
|
import { RecoverOption } from '../types/recover-option'
|
||||||
|
import { RecoverData } from '../types/recover-data'
|
||||||
|
import { TuiMapperPipeModule } from '@taiga-ui/cdk'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *ngIf="packageData$ | toOptions : backups | async as options">
|
||||||
|
<div tuiGroup orientation="vertical" [style.width.%]="100">
|
||||||
|
<tui-checkbox-block
|
||||||
|
*ngFor="let option of options"
|
||||||
|
[disabled]="option.installed || option['newer-eos']"
|
||||||
|
[(ngModel)]="option.checked"
|
||||||
|
>
|
||||||
|
<div [style.margin]="'0.75rem 0'">
|
||||||
|
<strong>{{ option.title }}</strong>
|
||||||
|
<div>Version {{ option.version }}</div>
|
||||||
|
<div>Backup made: {{ option.timestamp | date : 'medium' }}</div>
|
||||||
|
<div
|
||||||
|
*ngIf="option | tuiMapper : toMessage as message"
|
||||||
|
[style.color]="message.color"
|
||||||
|
>
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tui-checkbox-block>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="g-buttons">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
[disabled]="isDisabled(options)"
|
||||||
|
(click)="restore(options)"
|
||||||
|
>
|
||||||
|
Restore Selected
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</ng-container>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
ToOptionsPipe,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiCheckboxBlockModule,
|
||||||
|
TuiGroupModule,
|
||||||
|
TuiMapperPipeModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsRecoverModal {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly context =
|
||||||
|
inject<TuiDialogContext<void, RecoverData>>(POLYMORPHEUS_CONTEXT)
|
||||||
|
|
||||||
|
readonly packageData$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
.watch$('package-data')
|
||||||
|
.pipe(take(1))
|
||||||
|
|
||||||
|
readonly toMessage = (option: RecoverOption) => {
|
||||||
|
if (option['newer-eos']) {
|
||||||
|
return {
|
||||||
|
text: `Unavailable. Backup was made on a newer version of StartOS.`,
|
||||||
|
color: 'var(--tui-error-fill)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.installed) {
|
||||||
|
return {
|
||||||
|
text: `Unavailable. ${option.title} is already installed.`,
|
||||||
|
color: 'var(--tui-warning-fill)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: 'Ready to restore',
|
||||||
|
color: 'var(--tui-success-fill)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get backups(): Record<string, PackageBackupInfo> {
|
||||||
|
return this.context.data.backupInfo['package-backups']
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisabled(options: RecoverOption[]): boolean {
|
||||||
|
return options.every(o => !o.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(options: RecoverOption[]): Promise<void> {
|
||||||
|
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||||
|
const loader = this.loader.open('Initializing...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.restorePackages({
|
||||||
|
ids,
|
||||||
|
'target-id': this.context.data.targetId,
|
||||||
|
password: this.context.data.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.context.$implicit.complete()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOVER = new PolymorpheusComponent(BackupsRecoverModal)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { TuiDialogContext, TuiSvgModule } from '@taiga-ui/core'
|
||||||
|
import {
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
PolymorpheusComponent,
|
||||||
|
} from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { BackupReport } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<h3 class="g-title">Completed: {{ timestamp | date : 'medium' }}</h3>
|
||||||
|
<div class="g-action">
|
||||||
|
<div [style.flex]="1">
|
||||||
|
<strong>System data</strong>
|
||||||
|
<div [style.color]="system.color">{{ system.result }}</div>
|
||||||
|
</div>
|
||||||
|
<tui-svg [src]="system.icon" [style.color]="system.color"></tui-svg>
|
||||||
|
</div>
|
||||||
|
<div *ngFor="let pkg of report?.packages | keyvalue" class="g-action">
|
||||||
|
<div [style.flex]="1">
|
||||||
|
<strong>{{ pkg.key }}</strong>
|
||||||
|
<div [style.color]="getColor(pkg.value.error)">
|
||||||
|
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<tui-svg
|
||||||
|
[src]="getIcon(pkg.value.error)"
|
||||||
|
[style.color]="getColor(pkg.value.error)"
|
||||||
|
></tui-svg>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TuiSvgModule],
|
||||||
|
})
|
||||||
|
export class BackupsReportModal {
|
||||||
|
private readonly context =
|
||||||
|
inject<TuiDialogContext<void, { report: BackupReport; timestamp: string }>>(
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly system = this.getSystem()
|
||||||
|
|
||||||
|
get report(): BackupReport {
|
||||||
|
return this.context.data.report
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamp(): string {
|
||||||
|
return this.context.data.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor(error: unknown) {
|
||||||
|
return error ? 'var(--tui-negative)' : 'var(--tui-positive)'
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(error: unknown) {
|
||||||
|
return error ? 'tuiIconMinusCircleLarge' : 'tuiIconCheckLarge'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSystem() {
|
||||||
|
if (!this.report.server.attempted) {
|
||||||
|
return {
|
||||||
|
result: 'Not Attempted',
|
||||||
|
icon: 'tuiIconMinusLarge',
|
||||||
|
color: 'var(--tui-text-02)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.report.server.error) {
|
||||||
|
return {
|
||||||
|
result: `Failed: ${this.report.server.error}`,
|
||||||
|
icon: 'tuiIconMinusCircleLarge',
|
||||||
|
color: 'var(--tui-negative)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: 'Succeeded',
|
||||||
|
icon: 'tuiIconCheckLarge',
|
||||||
|
color: 'var(--tui-positive)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REPORT = new PolymorpheusComponent(BackupsReportModal)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { ErrorService } from '@start9labs/shared'
|
||||||
|
import { TuiForModule } from '@taiga-ui/cdk'
|
||||||
|
import {
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiDialogContext,
|
||||||
|
TuiDialogOptions,
|
||||||
|
TuiDialogService,
|
||||||
|
TuiLoaderModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import {
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
PolymorpheusComponent,
|
||||||
|
} from '@tinkoff/ng-polymorpheus'
|
||||||
|
import { BehaviorSubject } from 'rxjs'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupType } from '../types/backup-type'
|
||||||
|
import { BackupsStatusComponent } from '../components/status.component'
|
||||||
|
import { GetDisplayInfoPipe } from '../pipes/get-display-info.pipe'
|
||||||
|
import { TARGETS } from './targets.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<tui-loader
|
||||||
|
*ngIf="loading$ | async; else loaded"
|
||||||
|
size="l"
|
||||||
|
[textContent]="loading"
|
||||||
|
></tui-loader>
|
||||||
|
<ng-template #loaded>
|
||||||
|
<h3 class="g-title">Saved Targets</h3>
|
||||||
|
<button
|
||||||
|
*ngFor="let target of targets; empty: blank"
|
||||||
|
class="g-action"
|
||||||
|
[disabled]="isDisabled(target)"
|
||||||
|
(click)="context.completeWith(target)"
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
|
||||||
|
<tui-svg [src]="displayInfo.icon"></tui-svg>
|
||||||
|
<div>
|
||||||
|
<strong>{{ displayInfo.name }}</strong>
|
||||||
|
<backups-status
|
||||||
|
[type]="context.data.type"
|
||||||
|
[target]="target"
|
||||||
|
></backups-status>
|
||||||
|
<div [style.color]="'var(--tui-text-02'">
|
||||||
|
{{ displayInfo.description }}
|
||||||
|
<br />
|
||||||
|
{{ displayInfo.path }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</button>
|
||||||
|
<ng-template #blank>
|
||||||
|
<p>No saved targets</p>
|
||||||
|
<button tuiButton (click)="goToTargets()">Go to Targets</button>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TuiLoaderModule,
|
||||||
|
TuiForModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
TuiSvgModule,
|
||||||
|
BackupsStatusComponent,
|
||||||
|
GetDisplayInfoPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsTargetModal {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
|
readonly context =
|
||||||
|
inject<TuiDialogContext<BackupTarget, { type: BackupType }>>(
|
||||||
|
POLYMORPHEUS_CONTEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly loading$ = new BehaviorSubject(true)
|
||||||
|
readonly loading =
|
||||||
|
this.context.data.type === 'create'
|
||||||
|
? 'Loading Backup Targets'
|
||||||
|
: 'Loading Backup Sources'
|
||||||
|
|
||||||
|
targets: BackupTarget[] = []
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
this.targets = (await this.api.getBackupTargets({})).saved
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
this.loading$.next(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisabled(target: BackupTarget): boolean {
|
||||||
|
return (
|
||||||
|
!target.mountable ||
|
||||||
|
(this.context.data.type === 'restore' && !target['embassy-os'])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
goToTargets() {
|
||||||
|
this.dialogs.open(TARGETS, { label: 'Backup Targets' }).subscribe()
|
||||||
|
this.context.$implicit.complete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGET = new PolymorpheusComponent(BackupsTargetModal)
|
||||||
|
|
||||||
|
export const TARGET_CREATE: Partial<TuiDialogOptions<{ type: BackupType }>> = {
|
||||||
|
label: 'Select Backup Target',
|
||||||
|
data: { type: 'create' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGET_RESTORE: Partial<TuiDialogOptions<{ type: BackupType }>> = {
|
||||||
|
label: 'Select Backup Source',
|
||||||
|
data: { type: 'restore' },
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject, OnInit } from '@angular/core'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
unionSelectKey,
|
||||||
|
unionValueKey,
|
||||||
|
} from '@start9labs/start-sdk/lib/config/configTypes'
|
||||||
|
import { TuiButtonModule, TuiNotificationModule } from '@taiga-ui/core'
|
||||||
|
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
|
from,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
share,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs'
|
||||||
|
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||||
|
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||||
|
import {
|
||||||
|
cifsSpec,
|
||||||
|
diskBackupTargetSpec,
|
||||||
|
dropboxSpec,
|
||||||
|
googleDriveSpec,
|
||||||
|
remoteBackupTargetSpec,
|
||||||
|
} from 'src/app/apps/ui/pages/backups/types/target-types'
|
||||||
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
|
import {
|
||||||
|
BackupTarget,
|
||||||
|
BackupTargetType,
|
||||||
|
RR,
|
||||||
|
UnknownDisk,
|
||||||
|
} from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupConfig } from '../types/backup-config'
|
||||||
|
import { BackupsPhysicalComponent } from '../components/physical.component'
|
||||||
|
import { BackupsTargetsComponent } from '../components/targets.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *ngIf="loading$ | async"></ng-container>
|
||||||
|
<tui-notification>
|
||||||
|
Backup targets are physical or virtual locations for storing encrypted
|
||||||
|
backups. They can be physical drives plugged into your server, shared
|
||||||
|
folders on your Local Area Network (LAN), or third party clouds such as
|
||||||
|
Dropbox or Google Drive.
|
||||||
|
<a
|
||||||
|
href="https://docs.start9.com/latest/user-manual/backups/backup-targets"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
View instructions
|
||||||
|
</a>
|
||||||
|
</tui-notification>
|
||||||
|
<h3 class="g-title">
|
||||||
|
Unknown Physical Drives
|
||||||
|
<button tuiButton size="s" icon="tuiIconRefreshCw" (click)="refresh()">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<table
|
||||||
|
class="g-table"
|
||||||
|
[backupsPhysical]="targets?.['unknown-disks'] || null"
|
||||||
|
(add)="addPhysical($event)"
|
||||||
|
></table>
|
||||||
|
<h3 class="g-title">
|
||||||
|
Saved Targets
|
||||||
|
<button tuiButton size="s" icon="tuiIconPlus" (click)="addRemote()">
|
||||||
|
Add Target
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<table
|
||||||
|
class="g-table"
|
||||||
|
[backupsTargets]="targets?.saved || null"
|
||||||
|
(delete)="onDelete($event)"
|
||||||
|
(update)="onUpdate($event)"
|
||||||
|
></table>
|
||||||
|
`,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TuiNotificationModule,
|
||||||
|
TuiButtonModule,
|
||||||
|
BackupsPhysicalComponent,
|
||||||
|
BackupsTargetsComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsTargetsModal implements OnInit {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly formDialog = inject(FormDialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
|
||||||
|
readonly loading$ = new BehaviorSubject(true)
|
||||||
|
|
||||||
|
targets?: RR.GetBackupTargetsRes
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.loading$.next(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.targets = await this.api.getBackupTargets({})
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
this.targets = { 'unknown-disks': [], saved: [] }
|
||||||
|
} finally {
|
||||||
|
this.loading$.next(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDelete(id: string) {
|
||||||
|
const loader = this.loader.open('Removing...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.removeBackupTarget({ id })
|
||||||
|
this.setTargets(this.targets?.saved.filter(a => a.id !== id))
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onUpdate(value: BackupTarget) {
|
||||||
|
this.formDialog.open(FormPage, {
|
||||||
|
label: 'Update Target',
|
||||||
|
data: {
|
||||||
|
value,
|
||||||
|
spec: await this.getSpec(value),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: (
|
||||||
|
response:
|
||||||
|
| RR.UpdateCifsBackupTargetReq
|
||||||
|
| RR.UpdateCloudBackupTargetReq
|
||||||
|
| RR.UpdateDiskBackupTargetReq,
|
||||||
|
) => this.update(value.type, { ...response, id: value.id }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPhysical(disk: UnknownDisk) {
|
||||||
|
this.formDialog.open(FormPage, {
|
||||||
|
label: 'New Physical Target',
|
||||||
|
data: {
|
||||||
|
spec: await configBuilderToSpec(diskBackupTargetSpec),
|
||||||
|
value: { name: disk.label || disk.logicalname },
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: (value: Omit<RR.AddDiskBackupTargetReq, 'logicalname'>) =>
|
||||||
|
this.add('disk', {
|
||||||
|
logicalname: disk.logicalname,
|
||||||
|
...value,
|
||||||
|
}).then(response => {
|
||||||
|
this.setTargets(
|
||||||
|
this.targets?.saved.concat(response),
|
||||||
|
this.targets?.['unknown-disks'].filter(a => a !== disk),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRemote() {
|
||||||
|
this.formDialog.open(FormPage, {
|
||||||
|
label: 'New Remote Target',
|
||||||
|
data: {
|
||||||
|
spec: await configBuilderToSpec(remoteBackupTargetSpec),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: ({ type }: BackupConfig) =>
|
||||||
|
this.add(
|
||||||
|
type[unionSelectKey] === 'cifs' ? 'cifs' : 'cloud',
|
||||||
|
type[unionValueKey],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async add(
|
||||||
|
type: BackupTargetType,
|
||||||
|
value:
|
||||||
|
| RR.AddCifsBackupTargetReq
|
||||||
|
| RR.AddCloudBackupTargetReq
|
||||||
|
| RR.AddDiskBackupTargetReq,
|
||||||
|
): Promise<BackupTarget> {
|
||||||
|
const loader = this.loader.open('Saving target...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.api.addBackupTarget(type, value)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update(
|
||||||
|
type: BackupTargetType,
|
||||||
|
value:
|
||||||
|
| RR.UpdateCifsBackupTargetReq
|
||||||
|
| RR.UpdateCloudBackupTargetReq
|
||||||
|
| RR.UpdateDiskBackupTargetReq,
|
||||||
|
): Promise<BackupTarget> {
|
||||||
|
const loader = this.loader.open('Saving target...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.api.updateBackupTarget(type, value)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTargets(
|
||||||
|
saved: BackupTarget[] = this.targets?.saved || [],
|
||||||
|
unknown: UnknownDisk[] = this.targets?.['unknown-disks'] || [],
|
||||||
|
) {
|
||||||
|
this.targets = { ['unknown-disks']: unknown, saved }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSpec(target: BackupTarget) {
|
||||||
|
switch (target.type) {
|
||||||
|
case 'cifs':
|
||||||
|
return await configBuilderToSpec(cifsSpec)
|
||||||
|
case 'cloud':
|
||||||
|
return await configBuilderToSpec(
|
||||||
|
target.provider === 'dropbox' ? dropboxSpec : googleDriveSpec,
|
||||||
|
)
|
||||||
|
case 'disk':
|
||||||
|
return await configBuilderToSpec(diskBackupTargetSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGETS = new PolymorpheusComponent(BackupsTargetsModal)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'duration',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class DurationPipe implements PipeTransform {
|
||||||
|
transform(start: string, finish: string): number {
|
||||||
|
return (new Date(finish).valueOf() - new Date(start).valueOf()) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { BackupTargetType } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'getBackupIcon',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class GetBackupIconPipe implements PipeTransform {
|
||||||
|
transform(type: BackupTargetType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'cifs':
|
||||||
|
return 'tuiIconFolder'
|
||||||
|
case 'cloud':
|
||||||
|
return 'tuiIconCloud'
|
||||||
|
case 'disk':
|
||||||
|
return 'tuiIconSave'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { DisplayInfo } from '../types/display-info'
|
||||||
|
import { GetBackupIconPipe } from './get-backup-icon.pipe'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'getDisplayInfo',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class GetDisplayInfoPipe implements PipeTransform {
|
||||||
|
readonly icon = new GetBackupIconPipe()
|
||||||
|
|
||||||
|
transform(target: BackupTarget): DisplayInfo {
|
||||||
|
const result = {
|
||||||
|
name: target.name,
|
||||||
|
path: `Path: ${target.path}`,
|
||||||
|
icon: this.icon.transform(target.type),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (target.type) {
|
||||||
|
case 'cifs':
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
description: `Network Folder: ${target.hostname}`,
|
||||||
|
}
|
||||||
|
case 'cloud':
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
description: `Provider: ${target.provider}`,
|
||||||
|
}
|
||||||
|
case 'disk':
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
description: `Physical Drive: ${target.vendor || 'Unknown Vendor'}, ${
|
||||||
|
target.model || 'Unknown Model'
|
||||||
|
}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { BackupReport } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'hasError',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class HasErrorPipe implements PipeTransform {
|
||||||
|
transform(report: BackupReport): boolean {
|
||||||
|
return (
|
||||||
|
!!report.server.error ||
|
||||||
|
!!Object.values(report.packages).find(({ error }) => error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import cronstrue from 'cronstrue'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'toHumanCron',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class ToHumanCronPipe implements PipeTransform {
|
||||||
|
transform(cron: string): { message: string; color: string } {
|
||||||
|
const toReturn = {
|
||||||
|
message: '',
|
||||||
|
color: 'var(--tui-positive)',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const human = cronstrue.toString(cron, {
|
||||||
|
verbose: true,
|
||||||
|
throwExceptionOnParseError: true,
|
||||||
|
})
|
||||||
|
const zero = Number(cron[0])
|
||||||
|
const one = Number(cron[1])
|
||||||
|
if (Number.isNaN(zero) || Number.isNaN(one)) {
|
||||||
|
throw new Error(
|
||||||
|
`${human}. Cannot run cron jobs more than once per hour`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toReturn.message = human
|
||||||
|
} catch (e) {
|
||||||
|
toReturn.message = e as string
|
||||||
|
toReturn.color = 'var(--tui-negative)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReturn
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { Emver } from '@start9labs/shared'
|
||||||
|
import { map, Observable } from 'rxjs'
|
||||||
|
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { RecoverOption } from '../types/recover-option'
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'toOptions',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class ToOptionsPipe implements PipeTransform {
|
||||||
|
private readonly config = inject(ConfigService)
|
||||||
|
private readonly emver = inject(Emver)
|
||||||
|
|
||||||
|
transform(
|
||||||
|
packageData$: Observable<Record<string, PackageDataEntry>>,
|
||||||
|
packageBackups: Record<string, PackageBackupInfo> = {},
|
||||||
|
): Observable<RecoverOption[]> {
|
||||||
|
return packageData$.pipe(
|
||||||
|
map(packageData =>
|
||||||
|
Object.keys(packageBackups)
|
||||||
|
.map(id => ({
|
||||||
|
...packageBackups[id],
|
||||||
|
id,
|
||||||
|
installed: !!packageData[id],
|
||||||
|
checked: false,
|
||||||
|
'newer-eos': this.compare(packageBackups[id]['os-version']),
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private compare(version: string): boolean {
|
||||||
|
// checks to see if backup was made on a newer version of eOS
|
||||||
|
return this.emver.compare(version, this.config.version) === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core'
|
||||||
|
import { LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||||
|
import { from, switchMap } from 'rxjs'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { TARGET, TARGET_CREATE } from '../modals/target.component'
|
||||||
|
import { BACKUP, BACKUP_OPTIONS } from '../modals/backup.component'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class BackupsCreateService {
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
|
readonly handle = () => {
|
||||||
|
this.dialogs
|
||||||
|
.open<BackupTarget>(TARGET, TARGET_CREATE)
|
||||||
|
.pipe(
|
||||||
|
switchMap(({ id }) =>
|
||||||
|
this.dialogs
|
||||||
|
.open<string[]>(BACKUP, OPTIONS)
|
||||||
|
.pipe(switchMap(ids => from(this.createBackup(id, ids)))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBackup(
|
||||||
|
targetId: string,
|
||||||
|
pkgIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const loader = this.loader.open('Beginning backup...').subscribe()
|
||||||
|
|
||||||
|
await this.api
|
||||||
|
.createBackup({ 'target-id': targetId, 'package-ids': pkgIds })
|
||||||
|
.finally(() => loader.unsubscribe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS: Partial<TuiDialogOptions<{ btnText: string }>> = {
|
||||||
|
...BACKUP_OPTIONS,
|
||||||
|
data: { btnText: 'Create Backup' },
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import * as argon2 from '@start9labs/argon2'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
EMPTY,
|
||||||
|
exhaustMap,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import {
|
||||||
|
PROMPT,
|
||||||
|
PromptOptions,
|
||||||
|
} from 'src/app/apps/ui/modals/prompt/prompt.component'
|
||||||
|
import { TARGET, TARGET_RESTORE } from '../modals/target.component'
|
||||||
|
import { RECOVER } from '../modals/recover.component'
|
||||||
|
import { RecoverData } from '../types/recover-data'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class BackupsRestoreService {
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
|
||||||
|
readonly handle = () => {
|
||||||
|
this.dialogs
|
||||||
|
.open<BackupTarget>(TARGET, TARGET_RESTORE)
|
||||||
|
.pipe(
|
||||||
|
switchMap(target =>
|
||||||
|
this.dialogs.open<string>(PROMPT, PROMPT_OPTIONS).pipe(
|
||||||
|
exhaustMap(password =>
|
||||||
|
this.getRecoverData(
|
||||||
|
target.id,
|
||||||
|
password,
|
||||||
|
target['embassy-os']?.['password-hash'] || '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
take(1),
|
||||||
|
switchMap(data =>
|
||||||
|
this.dialogs.open(RECOVER, {
|
||||||
|
label: 'Select Services to Restore',
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/portal/desktop'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRecoverData(
|
||||||
|
targetId: string,
|
||||||
|
password: string,
|
||||||
|
hash: string,
|
||||||
|
): Observable<RecoverData> {
|
||||||
|
return of(password).pipe(
|
||||||
|
tap(() => argon2.verify(hash, password)),
|
||||||
|
switchMap(() => {
|
||||||
|
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||||
|
|
||||||
|
return this.api
|
||||||
|
.getBackupInfo({ 'target-id': targetId, password })
|
||||||
|
.finally(() => loader.unsubscribe())
|
||||||
|
}),
|
||||||
|
catchError(e => {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
|
||||||
|
return EMPTY
|
||||||
|
}),
|
||||||
|
map(backupInfo => ({ targetId, password, backupInfo })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
|
||||||
|
label: 'Password Required',
|
||||||
|
data: {
|
||||||
|
message: `Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.`,
|
||||||
|
label: 'Master Password',
|
||||||
|
placeholder: 'Enter master password',
|
||||||
|
useMask: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
unionSelectKey,
|
||||||
|
unionValueKey,
|
||||||
|
} from '@start9labs/start-sdk/lib/config/configTypes'
|
||||||
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
export type BackupConfig =
|
||||||
|
| {
|
||||||
|
type: {
|
||||||
|
[unionSelectKey]: 'dropbox' | 'google-drive'
|
||||||
|
[unionValueKey]: RR.AddCloudBackupTargetReq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: {
|
||||||
|
[unionSelectKey]: 'cifs'
|
||||||
|
[unionValueKey]: RR.AddCifsBackupTargetReq
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type BackupType = 'create' | 'restore'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface DisplayInfo {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
export interface RecoverData {
|
||||||
|
targetId: string
|
||||||
|
backupInfo: BackupInfo
|
||||||
|
password: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
export interface RecoverOption extends PackageBackupInfo {
|
||||||
|
id: string
|
||||||
|
checked: boolean
|
||||||
|
installed: boolean
|
||||||
|
'newer-eos': boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
|
export class BackupJobBuilder {
|
||||||
|
name: string
|
||||||
|
target: BackupTarget
|
||||||
|
cron: string
|
||||||
|
'package-ids': string[]
|
||||||
|
now = false
|
||||||
|
|
||||||
|
constructor(readonly job: Partial<BackupJob>) {
|
||||||
|
const { name, target, cron } = job
|
||||||
|
this.name = name || ''
|
||||||
|
this.target = target || ({} as BackupTarget)
|
||||||
|
this.cron = cron || '0 2 * * *'
|
||||||
|
this['package-ids'] = job['package-ids'] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
buildCreate(): RR.CreateBackupJobReq {
|
||||||
|
const { name, target, cron, now } = this
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'target-id': target.id,
|
||||||
|
cron,
|
||||||
|
'package-ids': this['package-ids'],
|
||||||
|
now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUpdate(id: string): RR.UpdateBackupJobReq {
|
||||||
|
const { name, target, cron } = this
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
'target-id': target.id,
|
||||||
|
cron,
|
||||||
|
'package-ids': this['package-ids'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule, Routes } from '@angular/router'
|
import { RouterModule, Routes } from '@angular/router'
|
||||||
|
import { systemTabResolver } from '../../utils/system-tab-resolver'
|
||||||
|
import { toDesktopItem } from '../../utils/to-desktop-item'
|
||||||
|
|
||||||
const ROUTES: Routes = [
|
const ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
|
title: systemTabResolver,
|
||||||
|
path: 'backups',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./backups/backups.component').then(m => m.BackupsComponent),
|
||||||
|
data: toDesktopItem('/portal/system/backups'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: systemTabResolver,
|
||||||
path: 'snek',
|
path: 'snek',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./snek/snek.component').then(m => m.SnekComponent),
|
import('./snek/snek.component').then(m => m.SnekComponent),
|
||||||
|
data: toDesktopItem('/portal/system/snek'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { BehaviorSubject, Observable } from 'rxjs'
|
import { BehaviorSubject, Observable } from 'rxjs'
|
||||||
|
import { NavigationItem } from '../types/navigation-item'
|
||||||
export interface NavigationItem {
|
|
||||||
readonly routerLink: string
|
|
||||||
readonly icon: string
|
|
||||||
readonly title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface NavigationItem {
|
||||||
|
readonly routerLink: string
|
||||||
|
readonly icon: string
|
||||||
|
readonly title: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||||
|
import { inject } from '@angular/core'
|
||||||
|
import { NavigationService } from '../services/navigation.service'
|
||||||
|
import { NavigationItem } from '../types/navigation-item'
|
||||||
|
|
||||||
|
export function systemTabResolver({ data }: ActivatedRouteSnapshot): string {
|
||||||
|
inject(NavigationService).addTab(data as NavigationItem)
|
||||||
|
|
||||||
|
return data['title']
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { SYSTEM_UTILITIES } from '../constants/system-utilities'
|
||||||
|
import { NavigationItem } from '../types/navigation-item'
|
||||||
|
import { toRouterLink } from './to-router-link'
|
||||||
|
|
||||||
|
export function toDesktopItem(
|
||||||
|
id: string,
|
||||||
|
packages: Record<string, PackageDataEntry> = {},
|
||||||
|
): NavigationItem {
|
||||||
|
const item = SYSTEM_UTILITIES[id]
|
||||||
|
const routerLink = toRouterLink(id)
|
||||||
|
|
||||||
|
if (SYSTEM_UTILITIES[id]) {
|
||||||
|
return {
|
||||||
|
icon: item.icon,
|
||||||
|
title: item.title,
|
||||||
|
routerLink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: packages[id]?.icon,
|
||||||
|
title: packages[id]?.manifest.title,
|
||||||
|
routerLink,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
[placeholder]="options.placeholder || ''"
|
[placeholder]="options.placeholder || ''"
|
||||||
/>
|
/>
|
||||||
</tui-input>
|
</tui-input>
|
||||||
<footer class="modal-buttons">
|
<footer class="g-buttons">
|
||||||
<button tuiButton type="button" appearance="secondary" (click)="cancel()">
|
<button tuiButton type="button" appearance="secondary" (click)="cancel()">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ const routes: Routes = [
|
|||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
],
|
],
|
||||||
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
|
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
|
||||||
|
exports: [DurationPipe, HasErrorPipe],
|
||||||
})
|
})
|
||||||
export class BackupHistoryPageModule {}
|
export class BackupHistoryPageModule {}
|
||||||
|
|||||||
@@ -37,5 +37,6 @@ const routes: Routes = [
|
|||||||
TuiWrapperModule,
|
TuiWrapperModule,
|
||||||
],
|
],
|
||||||
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
|
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
|
||||||
|
exports: [ToHumanCronPipe],
|
||||||
})
|
})
|
||||||
export class BackupJobsPageModule {}
|
export class BackupJobsPageModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.button {
|
.button {
|
||||||
|
width: 100%;
|
||||||
height: var(--tui-height-l);
|
height: var(--tui-height-l);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
:host {
|
|
||||||
height: var(--tui-height-l);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 1rem;
|
|
||||||
box-shadow: inset 0 0 0 1px var(--tui-base-03);
|
|
||||||
font: var(--tui-font-text-l);
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: var(--tui-radius-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
tui-toggle {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,6 @@ import { Control } from '../control'
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'form-toggle',
|
selector: 'form-toggle',
|
||||||
templateUrl: './form-toggle.component.html',
|
templateUrl: './form-toggle.component.html',
|
||||||
styleUrls: ['./form-toggle.component.scss'],
|
host: { class: 'g-toggle' },
|
||||||
})
|
})
|
||||||
export class FormToggleComponent extends Control<ValueSpecToggle, boolean> {}
|
export class FormToggleComponent extends Control<ValueSpecToggle, boolean> {}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
--ion-background-color-rgb: var(--ion-color-medium-rgb);
|
--ion-background-color-rgb: var(--ion-color-medium-rgb);
|
||||||
--ion-text-color: var(--ion-color-dark);
|
--ion-text-color: var(--ion-color-dark);
|
||||||
--ion-text-color-rgb: var(--ion-color-dark-rgb);
|
--ion-text-color-rgb: var(--ion-color-dark-rgb);
|
||||||
|
|
||||||
|
--tui-skeleton-radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subheader-height: 48px;
|
$subheader-height: 48px;
|
||||||
@@ -367,13 +369,64 @@ ul {
|
|||||||
margin: 0 12px 6px 0;
|
margin: 0 12px 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g-page {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1px 2rem 3rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
// TODO: Theme
|
||||||
|
background: #373a3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
text-align: left;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
box-shadow: inset 0 -1px var(--tui-clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--tui-clear);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-skeleton {
|
||||||
|
max-height: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
color: var(--tui-text-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.g-action {
|
.g-action {
|
||||||
@include transition(background);
|
@include transition(background);
|
||||||
@include clearbtn();
|
@include clearbtn();
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: stretch;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -382,11 +435,15 @@ ul {
|
|||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
color: var(--tui-text-01);
|
color: var(--tui-text-01);
|
||||||
--tui-skeleton-radius: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.g-action,
|
a.g-action,
|
||||||
button.g-action {
|
button.g-action {
|
||||||
|
&:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--tui-disabled-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--tui-clear);
|
background: var(--tui-clear);
|
||||||
}
|
}
|
||||||
@@ -394,12 +451,19 @@ button.g-action {
|
|||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
box-shadow: 0 0.51rem 0 -0.5rem;
|
box-shadow: 0 0.51rem 0 -0.5rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&_static {
|
.g-toggle {
|
||||||
cursor: default;
|
height: var(--tui-height-l);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--tui-base-03);
|
||||||
|
font: var(--tui-font-text-l);
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: var(--tui-radius-m);
|
||||||
|
|
||||||
&:hover {
|
tui-toggle {
|
||||||
background: transparent;
|
margin-left: auto;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user