diff --git a/.github/workflows/build-base-image.yml b/.github/workflows/build-base-image.yml index 8dd822651..5e4c55c06 100644 --- a/.github/workflows/build-base-image.yml +++ b/.github/workflows/build-base-image.yml @@ -9,14 +9,29 @@ on: - 'package-lock.json' - 'yarn.lock' - 'Dockerfile-base' + workflow_dispatch: + inputs: + branch: + description: '构建基础镜像所用的分支' + required: true + type: string + default: 'main' jobs: build-and-push: runs-on: ubuntu-latest steps: + - name: Lock Pull Request + if: github.event_name == 'push' + run: | + curl -X POST -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -d '{"state":"pending", "description":"Action running, merge disabled", "context":"Lock PR"}' \ + "https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}" - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -43,8 +58,8 @@ jobs: with: platforms: linux/amd64,linux/arm64 push: true - tags: jumpserver/${{ env.REPO }}-base:${{ env.IMAGE_TAG }} file: Dockerfile-base + tags: jumpserver/${{ env.REPO }}-base:${{ env.IMAGE_TAG }} - name: Update Dockerfile run: | @@ -59,3 +74,10 @@ jobs: git push env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Unlock Pull Request + if: github.event_name == 'push' + run: | + curl -X POST -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -d '{"state":"success", "description":"Action running, merge disabled", "context":"Lock PR"}' \ + "https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}" diff --git a/.gitignore b/.gitignore index b05de9c7b..399004b23 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ luna.tar.gz luna/ package-lock.json .angular + +# generated at build time by scripts/generate-build-info.mjs +/src/environments/build-info.ts diff --git a/Dockerfile b/Dockerfile index dae72b66c..a1da1e2b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use base image to build the project avoid npm install every time -FROM jumpserver/luna-base:20250609_105214 AS stage-build +FROM jumpserver/luna-base:20260701_071908 AS stage-build ARG VERSION ENV VERSION=$VERSION diff --git a/angular.json b/angular.json index e830b8d87..06b5c3ebc 100644 --- a/angular.json +++ b/angular.json @@ -18,6 +18,7 @@ "aot": false, "sourceMap": true, "namedChunks": true, + "baseHref": "/luna/", "index": "src/index.html", "browser": "src/main.ts", "polyfills": ["src/polyfills.ts"], @@ -101,7 +102,8 @@ "builder": "@angular/build:dev-server", "options": { "buildTarget": "Luna:build", - "proxyConfig": "proxy.conf.json" + "proxyConfig": "proxy.conf.json", + "servePath": "/luna" }, "configurations": { "production": { diff --git a/package.json b/package.json index 104bc095e..9f0156ceb 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "license": "GPLv3", "scripts": { "ng": "ng", - "dev": "ng serve --proxy-config proxy.conf.json --hmr --host 0.0.0.0", - "start": "ng serve --proxy-config proxy.conf.json --host 0.0.0.0", - "build": "ng build --configuration production", + "dev": "node scripts/generate-build-info.mjs && ng serve --proxy-config proxy.conf.json --serve-path /luna --hmr --host 0.0.0.0", + "start": "node scripts/generate-build-info.mjs && ng serve --proxy-config proxy.conf.json --serve-path /luna --host 0.0.0.0", + "build": "node scripts/generate-build-info.mjs && ng build --configuration production", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e", @@ -15,16 +15,16 @@ "private": true, "dependencies": { "@acrodata/code-editor": "^0.5.1", - "@angular/animations": "^19.0.0", + "@angular/animations": "^19.2.20", "@angular/cdk": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/compiler": "^19.0.0", - "@angular/core": "^19.0.0", + "@angular/common": "^19.2.20", + "@angular/compiler": "^19.2.20", + "@angular/core": "^19.2.20", "@angular/flex-layout": "15.0.0-beta.42", - "@angular/forms": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", - "@angular/router": "^19.0.0", + "@angular/forms": "^19.2.20", + "@angular/platform-browser": "^19.2.20", + "@angular/platform-browser-dynamic": "^19.2.20", + "@angular/router": "^19.2.20", "@ant-design/icons-angular": "^19.0.0", "@codemirror/language-data": "^6.5.1", "@ngx-translate/core": "^16.0.4", @@ -45,7 +45,7 @@ "guacamole-common-js": "^1.5.0", "jquery": "^3.7.1", "jsencrypt": "^3.3.2", - "lodash-es": "^4.17.21", + "lodash-es": "^4.18.0", "metismenu": "^3.0.7", "ng-zorro-antd": "^19.3.0", "ngx-clipboard": "^16.0.0", @@ -55,21 +55,22 @@ "popper.js": "^1.16.1", "requirejs": "^2.3.6", "rxjs": "^7.8.1", + "sm-crypto": "^0.4.0", "tslib": "^2.6.2", "xterm-theme": "^1.1.0", "zone.js": "~0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.0.0", + "@angular-devkit/build-angular": "^19.2.20", "@angular-eslint/builder": "^19.5.0", "@angular-eslint/eslint-plugin": "^19.5.0", "@angular-eslint/eslint-plugin-template": "^19.5.0", "@angular-eslint/schematics": "^19.5.0", "@angular-eslint/template-parser": "^19.5.0", - "@angular/build": "^19.0.0", - "@angular/cli": "^19.0.0", - "@angular/compiler-cli": "^19.0.0", - "@angular/language-service": "^19.0.0", + "@angular/build": "^19.2.20", + "@angular/cli": "^19.2.20", + "@angular/compiler-cli": "^19.2.20", + "@angular/language-service": "^19.2.20", "@types/jasmine": "^5.1.4", "@types/jasminewd2": "^2.0.13", "@types/node": "^20.11.19", @@ -88,5 +89,9 @@ "ts-node": "^10.9.2", "typescript": "~5.5.0", "vite": "^5.0.0" + }, + "resolutions": { + "seroval": "1.4.1", + "fast-uri": "3.1.2" } } diff --git a/scripts/generate-build-info.mjs b/scripts/generate-build-info.mjs new file mode 100644 index 000000000..7382d3cbd --- /dev/null +++ b/scripts/generate-build-info.mjs @@ -0,0 +1,21 @@ +// Generates src/environments/build-info.ts with the current build time. +// Runs before `ng build` / `ng serve` (see package.json scripts). Because +// `yarn build` runs inside the Docker build, the value equals the Docker build +// moment in production, and the dev-server start time in development. +// The container defaults to UTC, so the time is formatted as Asia/Shanghai. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const buildTime = new Date().toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false +}); + +const target = 'src/environments/build-info.ts'; +mkdirSync(dirname(target), { recursive: true }); +writeFileSync( + target, + `// AUTO-GENERATED by scripts/generate-build-info.mjs. Do not edit or commit.\nexport const buildTime = '${buildTime}';\n` +); + +console.log(`[build-info] buildTime = ${buildTime}`); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 036383890..2cc00230b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -20,22 +20,19 @@ import { AppComponent } from './pages/app.component'; import { ElementComponents } from './elements/elements.component'; import { PluginModules } from './plugins/plugins'; import { ClipboardService } from 'ngx-clipboard'; -import { environment, version } from '../environments/environment'; +import { version } from '../environments/environment'; import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; import { FileInputAccessorModule } from 'file-input-accessor'; import { catchError, mergeMap } from 'rxjs/operators'; import { PagesComponents } from './pages/pages.component'; +import { getAppBasePath, withAppBase, withSitePrefix } from '@app/utils/path'; export class CustomLoader implements TranslateLoader { constructor(private http: HttpClient) {} public getTranslation(lang: String): Observable { - const remote = '/api/v1/settings/i18n/luna/?lang=' + lang + '&v=' + version; - let local = '/assets/i18n/' + lang + '.json'; - const isProd = environment.production; - if (isProd) { - local = '/luna' + local; - } + const remote = withSitePrefix('/api/v1/settings/i18n/luna/?lang=' + lang + '&v=' + version); + const local = withAppBase('/assets/i18n/' + lang + '.json'); return forkJoin([ this.http.get(remote).pipe(catchError(() => of({}))), @@ -74,7 +71,7 @@ export class CustomLoader implements TranslateLoader { bootstrap: [AppComponent], providers: [ ...AllServices, - { provide: APP_BASE_HREF, useValue: '/luna/' }, + { provide: APP_BASE_HREF, useFactory: getAppBasePath }, CookieService, NGXLogger, ClipboardService, diff --git a/src/app/elements/asset-tree/asset-tree.component.html b/src/app/elements/asset-tree/asset-tree.component.html index 48acf0a05..225e88a14 100644 --- a/src/app/elements/asset-tree/asset-tree.component.html +++ b/src/app/elements/asset-tree/asset-tree.component.html @@ -24,6 +24,7 @@ - +
- {{ menu.name | translate }} + + {{ menu.name | translate }} + +
+ + + + + + +
+ {{ child.name }} + +
+
+ + + + + diff --git a/src/app/elements/asset-tree/asset-tree.component.scss b/src/app/elements/asset-tree/asset-tree.component.scss index 69734af5b..961bc6f7d 100644 --- a/src/app/elements/asset-tree/asset-tree.component.scss +++ b/src/app/elements/asset-tree/asset-tree.component.scss @@ -141,7 +141,7 @@ .tree-type-content { position: relative; - padding: 5px 10px; + padding: 1px; min-height: 40px; height: calc(100% - 31px); background-color: var(--el-asset-tree-bg-color); @@ -286,9 +286,64 @@ tr:hover { } .view-menu__data { + position: relative; padding: 0 6px; } +.view-menu__label { + flex: 1; +} + +.view-menu__submenu-arrow { + margin-left: 12px; + font-size: 12px; + opacity: 0.6; +} + +.view-menu__item--has-submenu { + .view-menu__data { + display: flex; + align-items: center; + padding-right: 10px; + } + + &:hover > .view-menu__data > .view-menu__submenu { + display: block; + } +} + +.view-menu__submenu { + display: none; + position: absolute; + top: -6px; + left: 100%; + margin: 0; + padding: 6px 0; + min-width: 120px; + border-radius: 2px; + background-color: var(--el-dropdown-bg-color); + box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16); + z-index: 1001; + + .view-menu__submenu-data { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 140px; + padding-right: 10px; + } + + .view-menu__submenu-label { + flex: 1; + padding-right: 8px; + } + + .view-menu__submenu-check { + font-size: 12px; + color: #52c41a; + } +} + .open-tree { flex: 1; } diff --git a/src/app/elements/asset-tree/asset-tree.component.ts b/src/app/elements/asset-tree/asset-tree.component.ts index e749f4ee0..c53fd72d6 100644 --- a/src/app/elements/asset-tree/asset-tree.component.ts +++ b/src/app/elements/asset-tree/asset-tree.component.ts @@ -1,5 +1,6 @@ -import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { NzMessageService } from 'ng-zorro-antd/message'; +import { NzModalService } from 'ng-zorro-antd/modal'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; @@ -21,6 +22,7 @@ import { import { ConnectEvt, InitTreeConfig, TreeNode } from '@app/model'; import { CookieService } from 'ngx-cookie-service'; import { HttpHeaders } from '@angular/common/http'; +import { withSitePrefix } from '@app/utils/path'; declare var $: any; @@ -61,7 +63,6 @@ export class ElementAssetTreeComponent implements OnInit { view: { dblClickExpand: false, showLine: true, - // 添加禁用颜色区分 fontCss: (treeId, treeNode) => { if (treeNode.chkDisabled) { return { opacity: '0.4' }; @@ -84,12 +85,22 @@ export class ElementAssetTreeComponent implements OnInit { isLoadTreeAsync: boolean; isOpenNewWindow: boolean; filterAssetCancel$: Subject = new Subject(); - favoriteAssets = []; + // Current user's favorite folders, used to build "Favorite to xxx" menu on a normal asset + favoriteFolders: Array = []; + // Current user's favorite asset records, used to mark folders in "Favorite to" submenu + favoriteAssets: Array = []; + // Checked favorite asset leaf nodes (for batch connect) + favoriteChecked: Array = []; + // Input value of the create-folder dialog + folderNameInput = ''; + @ViewChild('folderModalContent', { static: false }) folderModalContent: TemplateRef; searchValue = ''; currentOrgID = ''; trees: Array = []; assetTreeChecked = []; rMenuList: any[] = []; + // Prevent duplicate favorite requests when submenu click bubbles + private favoritingInFlight = new Set(); constructor( private _appSvc: AppService, @@ -104,7 +115,8 @@ export class ElementAssetTreeComponent implements OnInit { private _cookie: CookieService, private _message: NzMessageService, private _connectTokenSvc: ConnectTokenService, - private _viewSrv: ViewService + private _viewSrv: ViewService, + private _modal: NzModalService ) {} getRMenuList() { @@ -112,6 +124,123 @@ export class ElementAssetTreeComponent implements OnInit { return []; } const cnode = this.rightClickSelectNode; + + // Favorite tree root node: create folder + expand/fold all + batch connect + if (cnode.isFavoriteRoot) { + const favTree = cnode.ztree; + const favChecked = favTree ? favTree.getCheckedNodes(true).filter(n => !n.isParent) : []; + const favCheckEnable = favTree && favTree.setting.check && favTree.setting.check.enable; + return [ + { + id: 'batch-connect', + name: this._i18n.instant('Connect checked') + ` (${favChecked.length})`, + fa: 'fa-check-square-o', + hide: favChecked.length === 0 || !favCheckEnable, + click: this.onFavoriteConnectChecked.bind(this) + }, + { + id: 'create-folder', + name: this._i18n.instant('Create folder'), + fa: 'fa-folder-o', + hide: false, + click: this.onCreateFolder.bind(this, null) + }, + { + id: 'expand-all', + name: this._i18n.instant('Expand all'), + fa: 'fa-expand', + hide: false, + click: () => favTree && favTree.expandAll(true) + }, + { + id: 'fold-all', + name: this._i18n.instant('Fold all'), + fa: 'fa-compress', + hide: false, + click: () => favTree && favTree.expandAll(false) + } + ]; + } + + // Favorite folder node: create subfolder, delete folder, expand/fold + if (cnode.isFavoriteFolder) { + const favTree = cnode.ztree; + return [ + { + id: 'create-subfolder', + name: this._i18n.instant('Create subfolder'), + fa: 'fa-folder-o', + hide: false, + click: this.onCreateFolder.bind(this, cnode.favoriteFolderRealId) + }, + { + id: 'rename-folder', + name: this._i18n.instant('Rename'), + fa: 'fa-pencil', + hide: false, + click: this.onRenameFolder.bind(this) + }, + { + id: 'delete-folder', + name: this._i18n.instant('Delete folder'), + fa: 'fa-trash-o', + hide: false, + click: this.onDeleteFolder.bind(this) + }, + { + id: 'expand', + name: this._i18n.instant('Expand'), + fa: 'fa-angle-double-down', + hide: cnode.open, + click: () => favTree && favTree.expandNode(cnode, true, false, true) + }, + { + id: 'fold', + name: this._i18n.instant('Fold'), + fa: 'fa-angle-double-up', + hide: !cnode.open, + click: () => favTree && favTree.expandNode(cnode, false, false, true) + } + ]; + } + + // Favorite asset leaf (under a folder or directly under root): connect / move / remove + if (cnode.isFavoriteLeaf) { + const isK8sFav = cnode.meta?.data?.platform_type === 'k8s'; + const favViewList = this._viewSrv.viewList; + return [ + { + id: 'connect', + name: this._i18n.instant('Connect'), + fa: 'fa-terminal', + hide: isK8sFav, + click: this.onFavoriteConnect.bind(this) + }, + { + id: 'new-connection', + name: this._i18n.instant('Open in new window'), + fa: 'fa-external-link', + hide: false, + click: this.onFavoriteConnectNewTab.bind(this) + }, + { + id: 'split-connect', + name: this._i18n.instant('Split connect'), + fa: 'fa-columns', + hide: favViewList.length <= 0 || isK8sFav, + click: this.onFavoriteConnect.bind(this, true) + }, + ...this.getMoveToMenus(cnode), + { + id: 'remove-favorite', + name: this._i18n.instant('Remove favorite'), + fa: 'fa-star', + hide: false, + click: this.onRemoveFromFolder.bind(this) + } + ]; + } + const tree = this.rightClickSelectNode.ztree; const checkedNodes = tree.getCheckedNodes(true); const checkedLeafs = checkedNodes.filter(node => !node.isParent); @@ -130,28 +259,28 @@ export class ElementAssetTreeComponent implements OnInit { }, { id: 'connect', - name: 'Connect', + name: this._i18n.instant('Connect'), fa: 'fa-terminal', hide: cnode.isParent || isK8s, click: this.onMenuConnect.bind(this) }, { id: 'new-connection', - name: 'Open in new window', + name: this._i18n.instant('Open in new window'), fa: 'fa-external-link', hide: cnode.isParent, click: this.onMenuConnectNewTab.bind(this) }, { id: 'split-connect', - name: 'Split connect', + name: this._i18n.instant('Split connect'), fa: 'fa-columns', hide: viewList.length <= 0 || cnode.isParent || isK8s, click: this.onMenuConnect.bind(this, true) }, { id: 'expand', - name: 'Expand', + name: this._i18n.instant('Expand'), fa: 'fa-angle-double-down', hide: !cnode.isParent || cnode.open, click: () => { @@ -160,7 +289,7 @@ export class ElementAssetTreeComponent implements OnInit { }, { id: 'fold', - name: 'Fold', + name: this._i18n.instant('Fold'), fa: 'fa-angle-double-up', hide: !cnode.isParent || !cnode.open, click: () => { @@ -169,37 +298,191 @@ export class ElementAssetTreeComponent implements OnInit { }, { id: 'expand-all', - name: 'Expand all', + name: this._i18n.instant('Expand all'), fa: 'fa-expand', hide: !cnode.isParent || cnode.open, click: this.onMenuExpandAllChildren.bind(this) }, { id: 'fold-all', - name: 'Fold all', + name: this._i18n.instant('Fold all'), fa: 'fa-compress', hide: !cnode.isParent || !cnode.open, click: () => { tree.expandNode(cnode, false, true, true); } }, + ...this.getFavoriteToMenus(cnode) + ]; + } + + /** + * Build a "Favorite to" parent menu with folder submenu for a normal asset node. + * Show a disabled parent when there is no folder yet. + * @param cnode current right-clicked node + */ + getFavoriteToMenus(cnode): any[] { + if (cnode.isParent) { + return []; + } + return this.buildFolderTargetMenus({ + id: 'favorite-to', + name: this._i18n.instant('Favorite to'), + fa: 'fa-star-o', + assetId: this.resolveAssetId(cnode), + onSelect: this.onFavoriteTo.bind(this) + }); + } + + /** + * Build a "Move to" parent menu for a favorite-tree asset leaf, + * so it can be relocated into another favorite folder. + */ + getMoveToMenus(cnode): any[] { + return this.buildFolderTargetMenus({ + id: 'move-to', + name: this._i18n.instant('Move to'), + fa: 'fa-folder-o', + assetId: this.resolveAssetId(cnode), + onSelect: this.onMoveTo.bind(this) + }); + } + + /** + * Shared folder submenu builder for "Favorite to" / "Move to". + */ + private buildFolderTargetMenus(options: { + id: string; + name: string; + fa: string; + assetId: string; + onSelect: (folderId: string) => void; + }): any[] { + if (this.favoriteFolders.length === 0) { + return [ + { + id: options.id, + name: options.name, + fa: options.fa, + hide: false, + disabled: true, + click: () => {} + } + ]; + } + const favoritedFolderIds = this.getAssetFavoriteFolderIds(options.assetId); + return [ { - id: 'favorite', - name: 'Favorite', - fa: 'fa-star-o', - hide: this.isAssetFavorite() || cnode.isParent, - click: this.onMenuFavorite.bind(this) - }, - { - id: 'disfavor', - name: 'Disfavor', - fa: 'fa-star', - hide: !this.isAssetFavorite() || cnode.isParent, - click: this.onMenuFavorite.bind(this) + id: options.id, + name: options.name, + fa: options.fa, + hide: false, + children: this.favoriteFolders.map(folder => { + const folderId = this.resolveFolderId(folder); + return { + id: options.id + '-' + folderId, + name: folder.name, + checked: favoritedFolderIds.has(folderId), + click: () => options.onSelect(folderId) + }; + }) } ]; } + /** + * Get folder ids that already contain the given asset. + */ + private getAssetFavoriteFolderIds(assetId: string): Set { + const folderIds = new Set(); + if (!assetId) { + return folderIds; + } + (this.favoriteAssets || []).forEach(fav => { + const folderId = this.resolveFolderId(fav.folder ?? fav.folder_id); + const favAssetId = this.resolveAssetId({ + id: fav.asset_info?.id, + assetId: fav.asset_info?.id, + meta: fav.asset_info?.meta + }); + if (folderId && favAssetId === assetId) { + folderIds.add(folderId); + } + }); + const tree = this.trees.find(t => t.name === 'FavoriteTree'); + if (tree?.ztree) { + tree.ztree + .getNodesByFilter(node => !node.isParent && String(node.assetId) === assetId) + .forEach(node => { + if (node.favoriteFolderId) { + folderIds.add(String(node.favoriteFolderId)); + } + }); + } + return folderIds; + } + + /** + * An asset belongs to only one folder: replace any previous record with the new one. + */ + private setFavoriteAssetRecord(folderId: string, assetId: string, srcNode?: any) { + this.favoriteAssets = (this.favoriteAssets || []).filter(fav => { + const favAssetId = this.resolveAssetId({ + id: fav.asset_info?.id, + assetId: fav.asset_info?.id, + meta: fav.asset_info?.meta + }); + return favAssetId !== assetId; + }); + this.favoriteAssets.push({ + folder: folderId, + asset_info: { + id: assetId, + name: srcNode?.name, + meta: srcNode?.meta + } + }); + } + + private removeFavoriteAssetRecord(folderId: string, assetId: string) { + this.favoriteAssets = this.favoriteAssets.filter(fav => { + const favFolderId = this.resolveFolderId(fav.folder ?? fav.folder_id); + const favAssetId = this.resolveAssetId({ + id: fav.asset_info?.id, + assetId: fav.asset_info?.id, + meta: fav.asset_info?.meta + }); + return !(favFolderId === folderId && favAssetId === assetId); + }); + } + + /** + * Normalize folder id from API/menu values. + */ + private resolveFolderId(folderOrId: any): string { + if (folderOrId == null) { + return ''; + } + if (typeof folderOrId === 'object') { + return String(folderOrId.id ?? folderOrId.pk ?? folderOrId.folder_id ?? ''); + } + return String(folderOrId); + } + + /** + * Resolve the real asset id from tree/favorite nodes. + */ + private resolveAssetId(node: any): string { + return String(node?.assetId ?? node?.meta?.data?.id ?? node?.id ?? ''); + } + + /** + * Build a unique favorite leaf id for zTree (folder + asset). + */ + private buildFavoriteLeafId(folderId: string, assetId: string): string { + return `fav-${this.resolveFolderId(folderId)}-${this.resolveAssetId({ id: assetId, assetId })}`; + } + ngOnInit() { this.currentOrgID = this._cookie.get('X-JMS-LUNA-ORG') || this._cookie.get('X-JMS-ORG'); this._settingSvc.afterInited().then(state => { @@ -211,20 +494,33 @@ export class ElementAssetTreeComponent implements OnInit { this.currentOrgID = DEFAULT_ORG_ID; } this.initTree(); - this.trees.map((tree, index) => (index === 0 ? (tree.open = true) : (tree.open = false))); + // Favorite tree on top, but expand "My assets" by default + this.trees.map(tree => (tree.open = tree.name === 'AssetTree')); } }); document.addEventListener('click', this.hideRMenu.bind(this), false); } initTree() { + this.initFavoriteTree().then(); this.initAssetTree().then(); this.initTypeTree().then(); } - handleMenuClick(menu: any) { + handleMenuClick(menu: any, event?: MouseEvent) { + if (menu.disabled) { + return; + } + if (event) { + event.stopPropagation(); + event.preventDefault(); + } menu.click(); - this.hideRMenu(); + setTimeout(() => this.hideRMenu()); + } + + handleSubmenuClick(event: MouseEvent, menu: any) { + this.handleMenuClick(menu, event); } onNodeClick(event, treeId, treeNode, clickFlag) { @@ -259,11 +555,15 @@ export class ElementAssetTreeComponent implements OnInit { async initAssetTree(refresh = false) { const config = { refresh, - showFavoriteAssets: true, + // dev 方案(当前生效):直接使用原始接口地址 url: '/api/v1/perms/users/self/nodes/all-with-assets/tree/', asyncUrl: '/api/v1/perms/users/self/nodes/children-with-assets/tree/?' + // 多子目录方案(保留备用):site prefix 包裹 + showFavoriteAssets + // showFavoriteAssets: true, + // url: withSitePrefix('/api/v1/perms/users/self/nodes/all-with-assets/tree/'), + // asyncUrl: withSitePrefix('/api/v1/perms/users/self/nodes/children-with-assets/tree/?') }; - const tree = new Tree('AssetTree', 'My assets', false, true, true, true, config); + const tree = new Tree('AssetTree', this._i18n.instant('My assets'), false, true, true, true, config); if (!refresh) { this.trees.push(tree); } @@ -273,8 +573,8 @@ export class ElementAssetTreeComponent implements OnInit { async initTypeTree(refresh = false) { const config = { refresh, - url: '/api/v1/perms/users/self/nodes/children-with-assets/category/tree/?sync=1', - asyncUrl: '/api/v1/perms/users/self/nodes/children-with-assets/category/tree/', + url: withSitePrefix('/api/v1/perms/users/self/nodes/children-with-assets/category/tree/?sync=1'), + asyncUrl: withSitePrefix('/api/v1/perms/users/self/nodes/children-with-assets/category/tree/'), setting: { async: { autoParam: ['type', 'category'] @@ -297,6 +597,201 @@ export class ElementAssetTreeComponent implements OnInit { } } + /** + * Init favorite tree: fetch folders and favorited assets, build root -> folder -> asset. + * All data comes from backend; supports batch checking. + */ + async initFavoriteTree(refresh = false) { + const tree = new Tree('FavoriteTree', this._i18n.instant('Favorites'), false, false, false, true, { refresh }); + if (!refresh) { + this.trees.push(tree); + } + this.buildFavoriteTree(tree); + } + + /** + * Fetch folders and favorited assets, render the favorite ztree + * @param tree favorite tree object + */ + buildFavoriteTree(tree: Tree) { + tree.inited = true; + tree.loading = true; + Promise.all([ + this._http.getFavoriteFolders().toPromise(), + this._http.getFavoriteAssets().toPromise() + ]) + .then(([folders, favorites]) => { + this.favoriteFolders = folders || []; + this.favoriteAssets = favorites || []; + const nodes: any[] = []; + nodes.push({ + id: 'favorite-root', + name: this._i18n.instant('Favorites'), + isParent: true, + open: true, + isFavoriteRoot: true, + nocheck: true, + noR: false + }); + // Folder nodes (nested: parent points to parent folder, top-level under favorite-root) + this.favoriteFolders.forEach(folder => { + const folderId = this.resolveFolderId(folder); + nodes.push({ + id: 'folder-' + folderId, + pId: folder.parent ? 'folder-' + this.resolveFolderId(folder.parent) : 'favorite-root', + name: folder.name, + isParent: true, + open: false, + isFavoriteFolder: true, + favoriteFolderRealId: folderId + }); + }); + // Asset leaf nodes: under folder when folder is set, otherwise under favorite root + (favorites || []).forEach(fav => { + if (!fav.asset_info) { + return; + } + const folderId = this.resolveFolderId(fav.folder ?? fav.folder_id); + const info = fav.asset_info; + const assetId = this.resolveAssetId({ id: info.id, assetId: info.id, meta: info.meta }); + if (!assetId) { + return; + } + nodes.push({ + id: this.buildFavoriteLeafId(folderId || 'root', assetId), + pId: folderId ? 'folder-' + folderId : 'favorite-root', + name: info.name, + isParent: false, + iconSkin: info.iconSkin, + chkDisabled: info.chkDisabled, + assetId, + favoriteFolderId: folderId || null, + isFavoriteLeaf: true, + meta: info.meta + }); + }); + setTimeout(() => { + if (tree.ztree) { + tree.ztree.destroy(); + } + const setting = { + view: this.setting.view, + data: this.setting.data, + // Checking disabled by default, toggled by the top "batch" icon; parent cascades to children + check: { enable: false, chkboxType: { Y: 'ps', N: 'ps' } }, + callback: { + onClick: _.debounce(this.onFavoriteNodeClick, 300, { + leading: true, + trailing: false + }).bind(this), + onCheck: this.onFavoriteTreeCheck.bind(this), + onRightClick: this.onRightClick.bind(this) + } + }; + tree.ztree = $.fn.zTree.init($('#' + tree.name), setting, nodes); + tree.loading = false; + }, 100); + }) + .catch(error => { + tree.loading = false; + this._logger.error('Build favorite tree error: ', error); + }); + } + + /** + * Favorite tree check callback: record currently checked favorite asset leaf nodes + */ + onFavoriteTreeCheck() { + const tree = this.trees.find(t => t.name === 'FavoriteTree'); + if (tree && tree.ztree) { + this.favoriteChecked = tree.ztree.getCheckedNodes(true).filter(n => !n.isParent); + } + } + + /** + * Batch connect checked assets inside favorite folders + */ + onFavoriteConnectChecked() { + const tree = this.rightClickSelectNode.ztree; + if (!tree || !tree.setting.check.enable) { + return; + } + const nodes = tree.getCheckedNodes(true).filter(n => !n.isParent); + const t = setInterval(() => { + if (nodes.length === 0) { + clearInterval(t); + this.favoriteChecked = []; + return; + } + if (this._appSvc.connectDialogShown) { + return; + } + const node = this.buildConnectableNode(nodes.shift()); + this.connectAsset(node).then(); + }, 500); + } + + /** + * Click an asset leaf inside a favorite folder: connect directly (real asset id from assetId) + */ + onFavoriteNodeClick(event, treeId, treeNode) { + if (treeNode.isParent) { + const ztree = this.trees.find(t => t.name === treeId).ztree; + ztree.expandNode(treeNode); + return; + } + const node = this.buildConnectableNode(treeNode); + if (this.isOpenNewWindow) { + connectOnNewPage(node, 'auto'); + } else { + this.connectAsset(node).then(); + } + } + + /** + * Convert a favorite-folder asset node into a standard connectable node + * @param treeNode favorite-folder asset ztree node + */ + buildConnectableNode(treeNode): TreeNode { + return { + id: treeNode.assetId, + name: treeNode.name, + comment: '', + title: treeNode.name, + isParent: false, + pId: treeNode.pId, + open: false, + iconSkin: '', + meta: treeNode.meta + }; + } + + /** + * Favorite-folder asset: connect (current window or split) + * @param splitConnect whether split connect + */ + async onFavoriteConnect(splitConnect = false) { + if (splitConnect && this._viewSrv.currentView.subViews.length >= 4) { + const msg = this._i18n.instant('Split connect number'); + this._message.info(msg); + return; + } + const node = this.buildConnectableNode(this.rightClickSelectNode); + await this._appSvc.getConnectMethods(); + const evt = splitConnect + ? new ConnectEvt(node, 'connect', true) + : new ConnectEvt(node, 'connect'); + connectEvt.next(evt); + } + + /** + * Favorite-folder asset: connect in a new window + */ + onFavoriteConnectNewTab() { + const node = this.buildConnectableNode(this.rightClickSelectNode); + connectOnNewPage(node, 'auto'); + } + getOffsetTreeNodes(body, url, headers: HttpHeaders, tree) { const offset = headers.get('X-JMS-TREE-OFFSET'); const options = { @@ -364,24 +859,21 @@ export class ElementAssetTreeComponent implements OnInit { } const { setting, url } = this.cleanupTreeSetting(config); - if (config.showFavoriteAssets) { - this._http.getFavoriteAssets().subscribe(resp => { - this.favoriteAssets = resp.map(i => i.asset); - }); - } tree.loading = true; const request = this._http.get(url, { observe: 'response' }); request.subscribe( resp => { - // 如果是刷新,需要先销毁原来的树, 重新初始化 if (config.refresh) { tree.ztree.expandAll(false); tree.ztree.destroy(); } - const body = resp.body; + let body = resp.body; + // Remove the built-in flat Favorite node of "My assets" to avoid duplication with the favorite tree + if (tree.name === 'AssetTree' && Array.isArray(body)) { + body = body.filter(node => node.id !== 'favorite' && node.pId !== 'favorite'); + } const headers = resp.headers; setTimeout(() => { - // 新的 api 支持树的分页 const offset = headers.get('X-JMS-TREE-OFFSET'); if (offset && offset !== '0') { const parents = body.filter(node => node.isParent); @@ -457,6 +949,8 @@ export class ElementAssetTreeComponent implements OnInit { this.initAssetTree(true).then(); } else if (tree.name === 'AssetTypeTree') { this.initTypeTree(true).then(); + } else if (tree.name === 'FavoriteTree') { + this.buildFavoriteTree(tree); } } @@ -486,11 +980,6 @@ export class ElementAssetTreeComponent implements OnInit { this.isShowRMenu = false; } - isAssetFavorite() { - const assetId = this.rightClickSelectNode.id; - return this.favoriteAssets.indexOf(assetId) !== -1; - } - reAsyncChildNodes(treeId, treeNode, silent) { if (treeNode && treeNode.isParent && treeNode.children) { for (let i = 0; i < treeNode.children.length; i++) { @@ -513,7 +1002,6 @@ export class ElementAssetTreeComponent implements OnInit { if (expandFlag === treeNode.open) { return; } - // 异步加载时需要加载全部子节点 const self = this; const ztree = $.fn.zTree.getZTreeObj(treeId); const treeIsAsync = ztree.setting.async.enable; @@ -523,7 +1011,6 @@ export class ElementAssetTreeComponent implements OnInit { this.reAsyncChildNodes(treeId, treeNode, false); }); } else { - // 展开时递归展开,防止用户手动展开子级折叠后无法再次展开孙子级 if (expandFlag) { ztree.expandNode(treeNode, expandFlag, false, false, false); if (treeNode.children && treeNode.children.length > 0) { @@ -557,7 +1044,7 @@ export class ElementAssetTreeComponent implements OnInit { } /** - * @param splitConnect 是否分屏连接 + * @param splitConnect whether split connect */ async onMenuConnect(splitConnect = false) { if (splitConnect && this._viewSrv.currentView.subViews.length >= 4) { @@ -577,21 +1064,268 @@ export class ElementAssetTreeComponent implements OnInit { connectOnNewPage(node, 'auto'); } - onMenuFavorite() { - const assetId = this.rightClickSelectNode.id; - if (this.isAssetFavorite()) { - this._http.favoriteAsset(assetId, false).subscribe(() => { - const i = this.favoriteAssets.indexOf(assetId); - this.favoriteAssets.splice(i, 1); - const msg = this._i18n.instant('Disfavor') + ' ' + this._i18n.instant('success'); - this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); - }); - } else { - this._http.favoriteAsset(assetId, true).subscribe(() => { - this.favoriteAssets.push(assetId); - const msg = this._i18n.instant('Favorite') + ' ' + this._i18n.instant('success'); + /** + * Create folder: pop an input, validate empty/duplicate name, then call backend + * @param parentId parent folder id, null means top-level + */ + onCreateFolder(parentId: string = null) { + this.folderNameInput = ''; + this._modal.create({ + nzTitle: this._i18n.instant(parentId ? 'Create subfolder' : 'Create folder'), + nzContent: this.folderModalContent, + nzOnOk: () => { + const name = (this.folderNameInput || '').trim(); + if (!name) { + this._message.warning(this._i18n.instant('Folder name is required')); + return false; + } + const exists = this.favoriteFolders.some( + f => f.name === name && (f.parent || null) === (parentId || null) + ); + if (exists) { + this._message.warning(this._i18n.instant('Folder already exists')); + return false; + } + return this._http + .createFavoriteFolder(name, parentId) + .toPromise() + .then(() => { + const msg = this._i18n.instant('Create folder') + ' ' + this._i18n.instant('success'); + this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); + this.refreshFavoriteTree(); + }) + .catch(() => { + this._message.error(this._i18n.instant('Folder already exists')); + return false; + }); + } + }); + } + + /** + * Rename folder: pop an input, validate, then update backend and local ztree node + */ + onRenameFolder() { + const folderId = this.rightClickSelectNode.favoriteFolderRealId; + const currentName = this.rightClickSelectNode.name; + this.folderNameInput = currentName; + this._modal.create({ + nzTitle: this._i18n.instant('Rename folder'), + nzContent: this.folderModalContent, + nzOnOk: () => { + const name = (this.folderNameInput || '').trim(); + if (!name) { + this._message.warning(this._i18n.instant('Folder name is required')); + return false; + } + if (name === currentName) { + return true; + } + const folder = this.favoriteFolders.find(f => this.resolveFolderId(f) === folderId); + const parentId = folder?.parent || null; + const exists = this.favoriteFolders.some( + f => + f.name === name && + (f.parent || null) === (parentId || null) && + this.resolveFolderId(f) !== folderId + ); + if (exists) { + this._message.warning(this._i18n.instant('Folder already exists')); + return false; + } + return this._http + .updateFavoriteFolder(folderId, name) + .toPromise() + .then(() => { + this.renameFavoriteFolderLocally(folderId, name); + const msg = this._i18n.instant('Rename') + ' ' + this._i18n.instant('success'); + this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); + }) + .catch(() => { + this._message.error(this._i18n.instant('Folder already exists')); + return false; + }); + } + }); + } + + /** + * Update folder name locally without rebuilding the whole favorite tree + */ + renameFavoriteFolderLocally(folderId: string, name: string) { + const folder = this.favoriteFolders.find(f => this.resolveFolderId(f) === folderId); + if (folder) { + folder.name = name; + } + const tree = this.trees.find(t => t.name === 'FavoriteTree'); + if (tree?.ztree) { + const node = tree.ztree.getNodeByParam('id', 'folder-' + folderId); + if (node) { + node.name = name; + tree.ztree.updateNode(node); + } + } + } + + /** + * Delete folder: confirm then call backend (favorite relations cascade-removed) + */ + onDeleteFolder() { + const folderId = this.rightClickSelectNode.favoriteFolderRealId; + const folderName = this.rightClickSelectNode.name; + this._modal.confirm({ + nzTitle: this._i18n.instant('Delete folder') + ` 「${folderName}」?`, + nzContent: this._i18n.instant('Assets in this folder will be removed from favorites'), + nzOkDanger: true, + nzOnOk: () => { + return this._http + .deleteFavoriteFolder(folderId) + .toPromise() + .then(() => { + const msg = this._i18n.instant('Delete folder') + ' ' + this._i18n.instant('success'); + this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); + this.refreshFavoriteTree(); + }); + } + }); + } + + /** + * Favorite the current right-clicked asset into a folder. + * An asset can only belong to one folder: backend moves it, and the local tree + * removes the leaf from any previous folder before adding it under the target. + * @param folderId target folder id + */ + onFavoriteTo(folderId: string) { + this.assignFavoriteToFolder(folderId, 'Favorite'); + } + + /** + * Move a favorite-tree asset leaf into another folder. + * Same backend as favorite-to; only the success toast wording differs. + */ + onMoveTo(folderId: string) { + this.assignFavoriteToFolder(folderId, 'Move'); + } + + /** + * Assign the right-clicked asset to a favorite folder (create or relocate). + * @param folderId target folder id + * @param actionKey i18n key for success toast ("Favorite" / "Move") + */ + private assignFavoriteToFolder(folderId: string, actionKey: string) { + const srcNode = this.rightClickSelectNode; + const normalizedFolderId = this.resolveFolderId(folderId); + const assetId = this.resolveAssetId(srcNode); + if (!normalizedFolderId || !assetId) { + return; + } + const currentFolderId = this.resolveFolderId(srcNode.favoriteFolderId); + if (currentFolderId && currentFolderId === normalizedFolderId) { + this._message.warning(this._i18n.instant('Already in this folder')); + return; + } + const favoritingKey = `${assetId}-${normalizedFolderId}`; + if (this.favoritingInFlight.has(favoritingKey)) { + return; + } + this.favoritingInFlight.add(favoritingKey); + this._http.favoriteAssetToFolder(assetId, normalizedFolderId).subscribe( + () => { + const msg = this._i18n.instant(actionKey) + ' ' + this._i18n.instant('success'); this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); - }); + this.setFavoriteAssetRecord(normalizedFolderId, assetId, srcNode); + this.moveFavoriteLeaf(normalizedFolderId, srcNode, assetId); + this.favoritingInFlight.delete(favoritingKey); + }, + error => { + this.favoritingInFlight.delete(favoritingKey); + if (error && error.status === 400) { + this._message.warning(this._i18n.instant('Already in this folder')); + } + } + ); + } + + /** + * Move an asset leaf into the target folder locally. + * Remove every existing leaf of this asset first, so it never appears under two folders. + * @param folderId target folder id + * @param srcNode the favorited asset source node (from asset/type tree) + */ + moveFavoriteLeaf(folderId: string, srcNode: any, assetId?: string) { + const tree = this.trees.find(t => t.name === 'FavoriteTree'); + if (!tree || !tree.ztree) { + return; + } + const ztree = tree.ztree; + const normalizedFolderId = this.resolveFolderId(folderId); + const normalizedAssetId = assetId || this.resolveAssetId(srcNode); + if (!normalizedFolderId || !normalizedAssetId) { + return; + } + + // One asset -> one folder: drop leaves under any other folder first + const existingLeaves = ztree.getNodesByFilter( + node => !node.isParent && String(node.assetId) === normalizedAssetId + ); + existingLeaves.forEach(node => { + ztree.removeNode(node); + }); + + const parent = ztree.getNodeByParam('id', 'folder-' + normalizedFolderId); + if (!parent) { + return; + } + const leafId = this.buildFavoriteLeafId(normalizedFolderId, normalizedAssetId); + ztree.addNodes(parent, -1, [ + { + id: leafId, + pId: 'folder-' + normalizedFolderId, + name: srcNode.name, + isParent: false, + iconSkin: srcNode.iconSkin, + chkDisabled: srcNode.chkDisabled, + assetId: normalizedAssetId, + favoriteFolderId: normalizedFolderId, + isFavoriteLeaf: true, + meta: srcNode.meta + } + ]); + } + + /** + * Remove an asset from favorites (from a folder, or from root when folder is empty). + * On success, only remove this single leaf node (instantly disappears), + * without rebuilding the whole tree, keeping other folders' expand state. + */ + onRemoveFromFolder() { + const node = this.rightClickSelectNode; + const assetId = node.assetId; + const folderId = this.resolveFolderId(node.favoriteFolderId); + const ztree = node.ztree; + const remove$ = folderId + ? this._http.removeFavoriteFromFolder(assetId, folderId) + : this._http.favoriteAsset(assetId, false); + remove$.subscribe(() => { + this.removeFavoriteAssetRecord(folderId, assetId); + const msg = this._i18n.instant('Remove favorite') + ' ' + this._i18n.instant('success'); + this._toastr.success(msg, '', { nzClass: 'custom-success-notification' }); + if (ztree && typeof ztree.removeNode === 'function') { + ztree.removeNode(node); + } else { + this.refreshFavoriteTree(); + } + }); + } + + /** + * Rebuild the favorite tree (used by create/delete folder, does not touch other trees) + */ + refreshFavoriteTree() { + const tree = this.trees.find(t => t.name === 'FavoriteTree'); + if (tree) { + this.buildFavoriteTree(tree); } } diff --git a/src/app/elements/chat/chat.component.ts b/src/app/elements/chat/chat.component.ts index 8b1676c7b..f3594613a 100644 --- a/src/app/elements/chat/chat.component.ts +++ b/src/app/elements/chat/chat.component.ts @@ -15,6 +15,7 @@ import { HostListener, AfterViewInit } from '@angular/core'; +import { withUIBase } from '@app/utils/path'; @Component({ standalone: false, @@ -157,7 +158,7 @@ export class ElementChatComponent implements OnInit, OnDestroy, AfterViewInit { } private listenChatAI(): void { - this.iframeURL = '/ui/#/chat/chat-ai?from=luna'; + this.iframeURL = withUIBase('#/chat/chat-ai?from=luna'); window.addEventListener('message', event => { // 确认消息的来源是你信任的域 @@ -212,12 +213,18 @@ export class ElementChatComponent implements OnInit, OnDestroy, AfterViewInit { if (data && data.content) { this.postCurrentTerminalContextToChatAI(); } + if (!this.iframeRef?.nativeElement?.contentWindow) { + return; + } this.iframeRef.nativeElement.contentWindow.postMessage('show-chat-panel'); this.chatAIShown = true; this.showBtn = false; } postCurrentTerminalContextToChatAI() { + if (!this.iframeRef?.nativeElement?.contentWindow) { + return; + } const data = this.currentView.terminalContentData; const content = { viewId: this.currentView.id, diff --git a/src/app/elements/connect/connect-dialog/select-account/select-account.component.html b/src/app/elements/connect/connect-dialog/select-account/select-account.component.html index 6d6af31c9..85437a3df 100644 --- a/src/app/elements/connect/connect-dialog/select-account/select-account.component.html +++ b/src/app/elements/connect/connect-dialog/select-account/select-account.component.html @@ -50,29 +50,93 @@ - {{ "Password" | translate }} + {{ "Credential" | translate }} - - + + + + + + + + +
+ + + + + + + +
+
+ +
+ + + +
-
diff --git a/src/app/elements/connect/connect-dialog/select-account/select-account.component.scss b/src/app/elements/connect/connect-dialog/select-account/select-account.component.scss index c239e06f0..8a741e508 100644 --- a/src/app/elements/connect/connect-dialog/select-account/select-account.component.scss +++ b/src/app/elements/connect/connect-dialog/select-account/select-account.component.scss @@ -9,27 +9,81 @@ margin-bottom: 20px; } -.zone-label { -} - .account-info { border: solid #cbc9c9 1px; padding: 10px 10px 0 10px; } -.password { - ::ng-deep .ant-input-affix-wrapper { - width: 70%; +.credential { + .credential-input { + flex: 1; + min-width: 0; + } + + ::ng-deep .credential-input.ant-input-affix-wrapper, + ::ng-deep .credential-row .ant-input-affix-wrapper { + width: 100%; } .remember-auth { - width: 30%; - // display: inline-block; + flex-shrink: 0; + margin: 0; font-size: 12px; - vertical-align: bottom; - // text-align: right; - padding-left: 20px; + white-space: nowrap; + } +} + +.credential-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.credential-row { + display: flex; + align-items: center; + gap: 12px; +} + +.secret-type-radio { + margin-bottom: 0; + + label[nz-radio] i { + margin-right: 4px; + } +} + +.ssh-key-wrap { + position: relative; + + .ssh-key-input { + resize: vertical; + width: 100%; + padding-bottom: 36px; } + + .ssh-key-file-btn { + position: absolute; + right: 6px; + bottom: 6px; + z-index: 1; + margin: 0; + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + line-height: 24px; + + .anticon { + font-size: 12px; + } + } +} + +.ssh-key-file-input { + display: none; } .input-field { diff --git a/src/app/elements/connect/connect-dialog/select-account/select-account.component.ts b/src/app/elements/connect/connect-dialog/select-account/select-account.component.ts index cfb7a6a4c..b7a11ed92 100644 --- a/src/app/elements/connect/connect-dialog/select-account/select-account.component.ts +++ b/src/app/elements/connect/connect-dialog/select-account/select-account.component.ts @@ -34,9 +34,12 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { @Output() manualUsernameChanged: EventEmitter = new EventEmitter(); @ViewChild('username', { static: false }) usernameRef: ElementRef; @ViewChild('password', { static: false }) passwordRef: ElementRef; + @ViewChild('sshKeyFileInput', { static: false }) sshKeyFileInputRef: ElementRef; public hidePassword = true; public rememberAuthDisabled = null; + public passwordSecret = ''; + public sshKeySecret = ''; usernameControl = new FormControl(); localAuthItems: AuthInfo[]; filteredOptions: AuthInfo[]; @@ -57,7 +60,7 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { private _cdRef: ChangeDetectorRef ) { this.usernamePlaceholder = this._i18n.instant('Username'); - this.rememberAuthDisabled = false; + this.rememberAuthDisabled = !this._settingSvc.globalSetting.SECURITY_LUNA_REMEMBER_AUTH; } get noSecretAccounts() { @@ -111,6 +114,14 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { return this.accountSelected.username === '@INPUT' || this.accountSelected.username === '@USER'; } + get secretType(): 'password' | 'ssh_key' { + return this.manualAuthInfo?.['input_secret_type'] || 'password'; + } + + set secretType(value: 'password' | 'ssh_key') { + this.manualAuthInfo['input_secret_type'] = value; + } + public compareFn = (f1: Account, f2: Account) => { if (!f1 || !f2) return false; return f1.alias === f2.alias && f1.id === f2.id; @@ -144,6 +155,7 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { this.accountSelectedChange.emit(this.accountSelected); this.onAccountChanged(); } + this.syncSecretsFromManualAuth(); } ngOnDestroy() { @@ -309,6 +321,10 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { this.manualAuthInfo.username = this.accountSelected.username; } this.manualAuthInfo.secret = ''; + this.passwordSecret = ''; + this.sshKeySecret = ''; + // 切换账号时先回到默认类型,再由本地缓存 merge 覆盖(避免沿用上一账号的 ssh_key) + this.manualAuthInfo['input_secret_type'] = 'password'; this.localAuthItems = this._appSvc.getAccountLocalAuth(this.asset.id); if (this.manualAuthInfo.username) { this.localAuthItems = this.localAuthItems.filter( @@ -318,6 +334,8 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { if (this.localAuthItems && this.localAuthItems.length > 0) { this.manualAuthInfo = Object.assign(this.manualAuthInfo, this.localAuthItems[0]); } + this.syncSecretsFromManualAuth(); + this.applyCurrentSecretToManualAuth(); this.setUsernamePlaceholder(); setTimeout(() => { if (this.manualAuthInfo.username && this.passwordRef) { @@ -330,6 +348,67 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { this._cdRef.detectChanges(); } + onSecretTypeChange(_type: 'password' | 'ssh_key') { + this.applyCurrentSecretToManualAuth(); + } + + onPasswordSecretChange(secret: string) { + this.passwordSecret = secret; + if (this.secretType === 'password') { + this.manualAuthInfo.secret = secret; + } + } + + onSshKeySecretChange(secret: string) { + this.sshKeySecret = secret; + if (this.secretType === 'ssh_key') { + this.manualAuthInfo.secret = secret; + } + } + + selectSshKeyFile() { + this.sshKeyFileInputRef?.nativeElement?.click(); + } + + onSshKeyFileSelected(event: Event) { + const target = event.target as HTMLInputElement; + const file = target?.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = () => { + const content = typeof reader.result === 'string' ? reader.result : ''; + this.sshKeySecret = content; + if (this.secretType === 'ssh_key') { + this.manualAuthInfo.secret = content; + } + this._cdRef.detectChanges(); + }; + reader.onerror = () => { + console.error('Load SSH key file failed'); + }; + reader.readAsText(file); + target.value = ''; + } + + private syncSecretsFromManualAuth() { + const secret = this.manualAuthInfo?.secret || ''; + const secretType = this.manualAuthInfo?.['input_secret_type'] || 'password'; + if (secretType === 'ssh_key') { + this.sshKeySecret = secret; + this.passwordSecret = ''; + } else { + this.passwordSecret = secret; + this.sshKeySecret = ''; + } + } + + private applyCurrentSecretToManualAuth() { + this.manualAuthInfo.secret = + this.secretType === 'ssh_key' ? this.sshKeySecret : this.passwordSecret; + } + onFocus() { if (!this.accountManualAuthInit) { this.usernameControl.setValue(''); @@ -342,6 +421,8 @@ export class ElementSelectAccountComponent implements OnInit, OnDestroy { this.filteredOptions = this.localAuthItems.filter(authInfo => { if (authInfo.username.toLowerCase() === filterValue) { this.manualAuthInfo = Object.assign(this.manualAuthInfo, authInfo); + this.syncSecretsFromManualAuth(); + this.applyCurrentSecretToManualAuth(); } return authInfo.username.toLowerCase().includes(filterValue); }); diff --git a/src/app/elements/connect/connect.component.ts b/src/app/elements/connect/connect.component.ts index 14a6402e1..a668ce9ca 100644 --- a/src/app/elements/connect/connect.component.ts +++ b/src/app/elements/connect/connect.component.ts @@ -1,5 +1,7 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { ElementConnectDialogComponent } from '@app/elements/connect/connect-dialog/connect-dialog.component'; import { connectEvt } from '@app/globals'; +import { Account, Asset, ConnectData, ConnectionToken, View } from '@app/model'; import { AlertService, AppService, @@ -10,12 +12,10 @@ import { SettingService, ViewService } from '@app/services'; -import { Account, Asset, ConnectData, ConnectionToken, View } from '@app/model'; -import { launchLocalApp } from '@app/utils/common'; -import { ElementConnectDialogComponent } from '@app/elements/connect/connect-dialog/connect-dialog.component'; +import { launchLocalApp, LOCAL_CLIENT_REMINDER_STORAGE_KEY } from '@app/utils/common'; import { NzModalService } from 'ng-zorro-antd/modal'; -import { ElementDownloadDialogComponent } from './download-dialog/download-dialog.component'; import { firstValueFrom } from 'rxjs'; +import { ElementDownloadDialogComponent } from './download-dialog/download-dialog.component'; @Component({ standalone: false, @@ -153,17 +153,6 @@ export class ElementConnectComponent implements OnInit, OnDestroy { this._logger.info('Create connection token failed'); return; } - - // 分屏连接 - if (splitConnect) { - return this.currentWebSubView(asset, connectInfo, connToken); - } - - // 特殊处理 - if (connectMethod.value.endsWith('_guide')) { - return this.createWebView(asset, connectInfo, connToken); - } - let appletConnectMethod = connectOption ? connectOption['appletConnectMethod'] : 'web'; if (!this._settingSvc.hasXPack()) { @@ -174,21 +163,30 @@ export class ElementConnectComponent implements OnInit, OnDestroy { virtualappConnectMethod = 'web'; } + const isGuide = connectMethod.value.endsWith('_guide'); + const isClientConnect = + connectMethod.type === 'native' || + (connectMethod.type === 'applet' && appletConnectMethod === 'client') || + (connectMethod.type === 'virtual_app' && virtualappConnectMethod === 'client'); + const openWebView = () => + splitConnect + ? this.currentWebSubView(asset, connectInfo, connToken) + : this.createWebView(asset, connectInfo, connToken); + if (connectInfo.downloadRDP) { return this._http.downloadRDPFile( connToken, this._settingSvc.setting, connectInfo.connectOption ); - } else if (connectMethod.type === 'native') { - this.callLocalClient(connToken).then(); - } else if (connectMethod.type === 'applet' && appletConnectMethod === 'client') { - this.callLocalClient(connToken).then(); - } else if (connectMethod.type === 'virtual_app' && virtualappConnectMethod === 'client') { + } + + if (isClientConnect && !isGuide) { this.callLocalClient(connToken).then(); - } else { - this.createWebView(asset, connectInfo, connToken); + return; } + + return openWebView(); } async callLocalClient(connToken: ConnectionToken) { @@ -204,20 +202,24 @@ export class ElementConnectComponent implements OnInit, OnDestroy { const url = response['url']; - launchLocalApp(url, () => { - const downLoadStatus = localStorage.getItem('hasDownLoadApp'); - - if (downLoadStatus !== '1') { - this._dialog.create({ - nzTitle: this._i18n.instant('DownloadClient'), - nzContent: ElementDownloadDialogComponent, - nzOnOk: cmp => cmp.onConfirm(), - nzOnCancel: cmp => cmp.onCancel(), - nzCancelText: this._i18n.instant('Cancel'), - nzOkText: this._i18n.instant('Confirm') - }); - } - }); + const localClientLaunched = await launchLocalApp(url); + + if (localClientLaunched) { + return; + } + + const ignoreDownloadReminder = localStorage.getItem(LOCAL_CLIENT_REMINDER_STORAGE_KEY); + + if (ignoreDownloadReminder !== '1') { + this._dialog.create({ + nzTitle: this._i18n.instant('DownloadClient'), + nzContent: ElementDownloadDialogComponent, + nzOnOk: cmp => cmp.onConfirm(), + nzOnCancel: cmp => cmp.onCancel(), + nzCancelText: this._i18n.instant('Cancel'), + nzOkText: this._i18n.instant('Confirm') + }); + } } createWebView(asset: Asset, connectInfo: any, connToken: ConnectionToken) { diff --git a/src/app/elements/connect/download-dialog/download-dialog.component.ts b/src/app/elements/connect/download-dialog/download-dialog.component.ts index ef7238c18..3044558d2 100644 --- a/src/app/elements/connect/download-dialog/download-dialog.component.ts +++ b/src/app/elements/connect/download-dialog/download-dialog.component.ts @@ -1,5 +1,7 @@ -import {Component, Inject, OnInit} from '@angular/core'; -import {NZ_MODAL_DATA} from "ng-zorro-antd/modal"; +import { Component, Inject, OnInit } from '@angular/core'; +import { LOCAL_CLIENT_REMINDER_STORAGE_KEY } from '@app/utils/common'; +import { withSitePrefix } from '@app/utils/path'; +import { NZ_MODAL_DATA } from "ng-zorro-antd/modal"; @Component({ standalone: false, @@ -9,6 +11,7 @@ import {NZ_MODAL_DATA} from "ng-zorro-antd/modal"; }) export class ElementDownloadDialogComponent implements OnInit { public ignoreRemind = false; + private readonly legacyReminderStorageKey = 'hasDownLoadApp'; constructor( @Inject(NZ_MODAL_DATA) public data: any, @@ -17,21 +20,34 @@ export class ElementDownloadDialogComponent implements OnInit { } ngOnInit() { - if (localStorage.getItem('hasDownLoadApp') === '1') { + const ignoreDownloadReminder = localStorage.getItem(LOCAL_CLIENT_REMINDER_STORAGE_KEY); + const legacyIgnoreDownloadReminder = localStorage.getItem(this.legacyReminderStorageKey); + + if (ignoreDownloadReminder === '1' || legacyIgnoreDownloadReminder === '1') { this.ignoreRemind = true; } + + if (legacyIgnoreDownloadReminder === '1' && ignoreDownloadReminder !== '1') { + localStorage.setItem(LOCAL_CLIENT_REMINDER_STORAGE_KEY, '1'); + } + localStorage.removeItem(this.legacyReminderStorageKey); } - onCancel(): void { + private persistIgnoreRemind(): void { if (this.ignoreRemind) { - localStorage.setItem('hasDownLoadApp', '1'); + localStorage.setItem(LOCAL_CLIENT_REMINDER_STORAGE_KEY, '1'); + return; } + + localStorage.removeItem(LOCAL_CLIENT_REMINDER_STORAGE_KEY); + } + + onCancel(): void { + this.persistIgnoreRemind(); } onConfirm(): void { - if (this.ignoreRemind) { - localStorage.setItem('hasDownLoadApp', '1'); - } - window.open('/core/download/', '_blank'); + this.persistIgnoreRemind(); + window.open(withSitePrefix('/core/download/'), '_blank'); } } diff --git a/src/app/elements/content/content-footer/send-command-with-variable-dialog/send-command-with-variable-dialog.component.ts b/src/app/elements/content/content-footer/send-command-with-variable-dialog/send-command-with-variable-dialog.component.ts index e078294ad..7f3fcf04c 100644 --- a/src/app/elements/content/content-footer/send-command-with-variable-dialog/send-command-with-variable-dialog.component.ts +++ b/src/app/elements/content/content-footer/send-command-with-variable-dialog/send-command-with-variable-dialog.component.ts @@ -1,6 +1,7 @@ -import {Component, Inject, OnInit} from '@angular/core'; -import {HttpService} from '@app/services'; -import {NZ_MODAL_DATA, NzModalRef} from 'ng-zorro-antd/modal'; +import { Component, Inject, OnInit } from '@angular/core'; +import { HttpService } from '@app/services'; +import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal'; +import { withSitePrefix } from '@app/utils/path'; @Component({ standalone: false, @@ -25,7 +26,7 @@ export class ElementSendCommandWithVariableDialogComponent implements OnInit { async getVariableFormMeta() { const adhoc = this.command.id; - const url = `/api/v1/ops/variables/form-data/?t=${new Date().getTime()}&adhoc=${adhoc}`; + const url = withSitePrefix(`/api/v1/ops/variables/form-data/?t=${new Date().getTime()}&adhoc=${adhoc}`); const res: any = await this._http.options(url).toPromise(); this.formConfig = res.actions.GET; } diff --git a/src/app/elements/content/content-window/default/default.component.ts b/src/app/elements/content/content-window/default/default.component.ts index 767a1bd76..42c9188d9 100644 --- a/src/app/elements/content/content-window/default/default.component.ts +++ b/src/app/elements/content/content-window/default/default.component.ts @@ -1,5 +1,8 @@ import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; import {Asset, Endpoint, View} from '@app/model'; +import {joinEndpointUrl} from '@app/utils/path'; +// 多子目录方案(保留备用):site prefix 相关工具 +// import { getSitePrefix, joinEndpointUrl, withSitePrefix } from '@app/utils/path'; @Component({ standalone: false, @@ -32,6 +35,7 @@ export class ElementConnectorDefaultComponent implements OnInit { this.el.nativeElement.focus(); } + // dev 方案(当前生效) generateIframeURL(asset, protocol) { if (this.iframeURL) { return this.iframeURL; @@ -40,16 +44,49 @@ export class ElementConnectorDefaultComponent implements OnInit { const token = this.view.connectToken.id; switch (this.connector) { case 'chen': - const url = `${endpointUrl}/chen/connect?token=${token}`; + const url = joinEndpointUrl(endpointUrl, `/chen/connect?token=${token}`); const disableautohash = this.view.getConnectOption('disableautohash'); if (disableautohash) { return `${url}&disableautohash=true`; } return url; case 'lion': - return `${endpointUrl}/lion/connect?token=${token}`; + return joinEndpointUrl(endpointUrl, `/lion/connect?token=${token}`); case 'default': - return `${endpointUrl}/koko/connect?token=${token}`; + return joinEndpointUrl(endpointUrl, `/koko/connect?token=${token}`); } } + + // 多子目录方案(保留备用):site prefix + URLSearchParams 重写 + // generateIframeURL(asset, protocol) { + // if (this.iframeURL) { + // return this.iframeURL; + // } + // const endpointUrl = this.endpoint.getUrl(); + // const token = this.view.connectToken.id; + // + // switch (this.connector) { + // case 'chen': { + // const params = new URLSearchParams({ token }); + // const disableautohash = this.view.getConnectOption('disableautohash'); + // const sitePrefix = getSitePrefix(); + // + // if (disableautohash) { + // params.set('disableautohash', 'true'); + // } + // + // if (sitePrefix) { + // params.set('site_prefix', sitePrefix); + // } + // + // return joinEndpointUrl(endpointUrl, withSitePrefix(`/chen/connect?${params.toString()}`)); + // } + // case 'lion': + // return joinEndpointUrl(endpointUrl, withSitePrefix(`/lion/connect?token=${token}`)); + // case 'default': + // return joinEndpointUrl(endpointUrl, withSitePrefix(`/koko/connect?token=${token}`)); + // default: + // return ''; + // } + // } } diff --git a/src/app/elements/content/content-window/koko/koko.component.ts b/src/app/elements/content/content-window/koko/koko.component.ts index 3cda9e69d..6246ea089 100644 --- a/src/app/elements/content/content-window/koko/koko.component.ts +++ b/src/app/elements/content/content-window/koko/koko.component.ts @@ -9,6 +9,9 @@ import { } from '@app/services'; import { User } from '@app/globals'; import { Command, InfoItem } from '../guide/model'; +import { joinEndpointUrl } from '@app/utils/path'; +// 多子目录方案(保留备用):site prefix 相关工具 +// import { getSitePrefix, joinEndpointUrl, withSitePrefix } from '@app/utils/path'; @Component({ standalone: false, @@ -54,7 +57,7 @@ export class ElementConnectorKokoComponent implements OnInit { this.account = account; this.token = connectToken; const url = smartEndpoint.getUrl(); - this.baseUrl = `${url}/koko`; + this.baseUrl = url; this.methodName = this.view.connectMethod.value; if (this.methodName === 'ssh_guide') { this.setInfoItems(); @@ -150,6 +153,7 @@ export class ElementConnectorKokoComponent implements OnInit { } } + // dev 方案(当前生效) generateNodeConnectUrl() { const params = {}; params['disableautohash'] = this.view.getConnectOption('disableautohash'); @@ -165,16 +169,62 @@ export class ElementConnectorKokoComponent implements OnInit { }); if (this.protocol === 'k8s') { - return (this.iframeURL = `${this.baseUrl}/k8s/?` + query); + return (this.iframeURL = joinEndpointUrl(this.baseUrl, `/koko/k8s/?${query}`)); } - this.iframeURL = `${this.baseUrl}/connect/?` + query; + this.iframeURL = joinEndpointUrl(this.baseUrl, `/koko/connect/?${query}`); } generateFileManagerURL() { - this.iframeURL = `${this.baseUrl}/elfinder/sftp/?token=${this.view.connectToken.id}&asset=${this.asset.id}`; + this.iframeURL = joinEndpointUrl( + this.baseUrl, + `/koko/elfinder/sftp/?token=${this.view.connectToken.id}&asset=${this.asset.id}` + ); } + // 多子目录方案(保留备用):site prefix + URLSearchParams 重写 + // generateNodeConnectUrl() { + // const params = new URLSearchParams({ + // token: this.view.connectToken.id, + // _: Date.now().toString() + // }); + // const disableautohash = this.view.getConnectOption('disableautohash'); + // const sitePrefix = getSitePrefix(); + // + // if (disableautohash !== undefined && disableautohash !== null) { + // params.set('disableautohash', String(disableautohash)); + // } + // + // if (sitePrefix) { + // params.set('site_prefix', sitePrefix); + // } + // + // const query = params.toString(); + // const kokoPath = withSitePrefix(`/koko/connect/?${query}`); + // const k8sPath = withSitePrefix(`/koko/k8s/?${query}`); + // + // if (this.protocol === 'k8s') { + // return (this.iframeURL = joinEndpointUrl(this.baseUrl, k8sPath)); + // } + // + // this.iframeURL = joinEndpointUrl(this.baseUrl, kokoPath); + // } + // + // generateFileManagerURL() { + // const params = new URLSearchParams({ + // token: this.view.connectToken.id, + // asset: this.asset.id + // }); + // const sitePrefix = getSitePrefix(); + // + // if (sitePrefix) { + // params.set('site_prefix', sitePrefix); + // } + // + // const path = `/koko/elfinder/sftp/?${params.toString()}`; + // this.iframeURL = joinEndpointUrl(this.baseUrl, withSitePrefix(path)); + // } + async reconnect() { this.loading = true; const oldConnectToken = this.view.connectToken; @@ -182,7 +232,6 @@ export class ElementConnectorKokoComponent implements OnInit { if (!newConnectToken) { return; } - // 更新当前 view 的 connectToken this.view.connectToken = newConnectToken; await this.ngOnInit(); this.loading = false; diff --git a/src/app/elements/content/content.component.ts b/src/app/elements/content/content.component.ts index 318edffd3..e000fc763 100644 --- a/src/app/elements/content/content.component.ts +++ b/src/app/elements/content/content.component.ts @@ -295,7 +295,7 @@ export class ElementContentComponent implements OnInit, OnDestroy { icon: '', callback: () => { for (let i = this.viewList.length - 1; i > this.rIdx; i--) { - this.closeView(this.viewList[i].asset); + this.closeView(this.viewList[i]); } }, disabled: this.rIdx === this.viewList.length - 1 diff --git a/src/app/elements/iframe/iframe.component.ts b/src/app/elements/iframe/iframe.component.ts index b78f6cd30..ec69c4722 100644 --- a/src/app/elements/iframe/iframe.component.ts +++ b/src/app/elements/iframe/iframe.component.ts @@ -25,6 +25,7 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FaceService } from '@app/services/face'; import { DrawerStateService } from '@app/services/drawer'; import { Protocol } from '@app/model'; +import { withSitePrefix } from '@app/utils/path'; @Component({ standalone: false, @@ -74,7 +75,7 @@ export class ElementIframeComponent implements OnInit, AfterViewInit, OnDestroy } this.renewalTrigger.pipe(debounceTime(2000)).subscribe(() => { - this._http.get(`/api/v1/health/`).subscribe(); + this._http.get(withSitePrefix('/api/v1/health/')).subscribe(); }); this.id = 'window-' + Math.random().toString(36).substr(2); diff --git a/src/app/elements/left-bar/left-bar.component.ts b/src/app/elements/left-bar/left-bar.component.ts index 8e295b894..b4101a60b 100644 --- a/src/app/elements/left-bar/left-bar.component.ts +++ b/src/app/elements/left-bar/left-bar.component.ts @@ -25,7 +25,7 @@ export class ElementLeftBarComponent implements OnInit, AfterViewInit, OnDestroy @Input() collapsed: boolean = false; private resizeObserver!: ResizeObserver; showTree = true; - version = version; + version = version.replace(/-build\d+.*/i, ''); menus: any[] = []; hasXPack: boolean = localStorage.getItem('hasXPack') === '1'; diff --git a/src/app/elements/nav/nav.component.html b/src/app/elements/nav/nav.component.html index 56c8539e7..e0326f8cb 100644 --- a/src/app/elements/nav/nav.component.html +++ b/src/app/elements/nav/nav.component.html @@ -2,7 +2,7 @@