mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat: Version control mvp (#6271)
* implement basic git service * cleanup connected prop * add skeleton of git functions * initial import/export setup * split out export service * refactor and improve export * begin import * more commands and basic import * clean up imports with transactions * work folder import functions * reintroduce versionid * add missing import to pull workfolder * add get-status endpoint * add cleanup to disconnect * add initRepo options * add more checks and cleanup * minor cleanup * refactor prefs * fix server.ts * fix sending deleted files * rename files to ee * add variable override and fix critical cred import bug * fix mkdir race condition * make initRepo default to true * fix front back integration * improve connect flow * add comment to generated ssh key * fix(editor): use useToast composable * fix buttons size * commenting out repo init for now * fix(editor): update UI logic * fix(editor): remove console.log * fix(editor): remove unused ref * adjust endpoints for improved UI * fix(editor): add push and pull buttons * keep or not ssh key * switching file name to id * fix(editor): add success messages, fix save button * fixed faulty diff preventing pull * fix build * fix(editor): adding loader to VC components * removing duplicate exports * improve conflict finding on push pull * manage pull conflict * alternate push pull * fix pull confirmation * fix rm and credential export/import * switch to alternative pull implementation * fix initial commit * fix(editor): subscribing to VC store action to refresh lists * fix(editor): wrap VC store actions with try * feat: add fine-grained file selection for push action * fix: close modal after successful push * fix(editor): VC preferences validation * fix confirm * fix: update endpoint to /get-status * feat: update pull modal override changes message * fix missing wf error * undo * removing connect endpoint * fix(editor): add button titles * fix(editor): cleaning up store action * add version-control/set-read-only protection * fix(editor): adding set branch readonly * fix(editor): remove Push button if branch set to readonly * fix(editor): fix some styles * fix(editor): remove duplicate and delete actions in WF list when branch is readonly * fix: load status before opening selective push modal * fix(editor): extend readonly logic * add cleanup after failed initRepo * fix deleted files crashing get-status * fix n8n-checkbox in staging dialog * fix(editor): fix loading * fix(editor): resize buttons * fix(editor): fix translation * fix(editor): fix copy text size * fix(editor): fix copy text size * fix(editor): add disconnection confirmation * fix(editor): add disconnection confirmation * fix(editor): set large buttons * add public api Pull endpoint * feat: add refresh ssh key * return prefs when new keys are generated * fix(editor): adding readOnly mode to main header * fix(editor): adding readOnly mode to workflow settings * improve credential owner import * add middleware to endpoints * improve public api error/doc * do not create branch if one already exists * update wordings for connect toasts * fix(editor): updating and separating readonly modes * fix(editor): fix readonly mode in WF list * fix(editor): disable elements dragging on canvas in readonly mode (WIP: not working when NodeView page is loaded first) * fix(editor): fix canvas draggables in readonly env * fix(editor): remove unused variables * fix(editor): hide actions in node connections when readonly * fix(editor): hide actions in node connections when readonly * fix(editor): disable Save button when readonly * fix(editor): disable Save settings if no branch is selected * fix(editor): lint fix * fix(editor): update snapshots * fix(editor): replace Loading... text * fix(editor): reset Loading... text * reset branchname on disconnect * fix(editor): adding some translations * fix(editor): fix unit test * fix(editor): fix loading * fix(editor): set settings saved message * fix(editor): update connection flag * fix branchName not returning after connect * temporary (but still breaking) fix for postgres * fix(editor): adding tooltip to Push/Pull buttons when they're collapsed * fix(editor): enabled activator in readonly mode * fix test * fix(editor): disabling new item addition for workflows in readonly mode * fix(editor): modify Pull/Push button tooltips * do not commit empty variables file --------- Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com> Co-authored-by: Romain Minaud <romain.minaud@gmail.com> Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
parent
04cfa548af
commit
1b321416c0
|
@ -78,6 +78,7 @@
|
|||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/localtunnel": "^1.9.0",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.difference": "^4",
|
||||
"@types/lodash.get": "^4.4.6",
|
||||
"@types/lodash.intersection": "^4.4.7",
|
||||
"@types/lodash.iteratee": "^4.7.7",
|
||||
|
@ -91,6 +92,7 @@
|
|||
"@types/lodash.uniq": "^4.5.7",
|
||||
"@types/lodash.uniqby": "^4.7.7",
|
||||
"@types/lodash.unset": "^4.5.7",
|
||||
"@types/lodash.without": "^4.4.7",
|
||||
"@types/parseurl": "^1.3.1",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/psl": "^1.1.0",
|
||||
|
@ -159,6 +161,7 @@
|
|||
"jwks-rsa": "^3.0.1",
|
||||
"ldapts": "^4.2.6",
|
||||
"localtunnel": "^2.0.0",
|
||||
"lodash.difference": "^4",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.intersection": "^4.4.0",
|
||||
"lodash.iteratee": "^4.7.0",
|
||||
|
@ -172,6 +175,7 @@
|
|||
"lodash.uniq": "^4.5.0",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"lodash.unset": "^4.5.2",
|
||||
"lodash.without": "^4.4.0",
|
||||
"luxon": "^3.3.0",
|
||||
"mysql2": "~2.3.3",
|
||||
"n8n-core": "workspace:*",
|
||||
|
@ -197,6 +201,7 @@
|
|||
"samlify": "^2.8.9",
|
||||
"semver": "^7.3.8",
|
||||
"shelljs": "^0.8.5",
|
||||
"simple-git": "^3.17.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sse-channel": "^4.0.0",
|
||||
|
|
10
packages/cli/src/PublicApi/types.d.ts
vendored
10
packages/cli/src/PublicApi/types.d.ts
vendored
|
@ -163,6 +163,16 @@ export interface IJsonSchema {
|
|||
required: string[];
|
||||
}
|
||||
|
||||
export class VersionControlPull {
|
||||
force?: boolean;
|
||||
|
||||
variables?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export declare namespace PublicVersionControlRequest {
|
||||
type Pull = AuthenticatedRequest<{}, {}, VersionControlPull, {}>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /audit
|
||||
// ----------------------------------
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
post:
|
||||
x-eov-operation-id: pull
|
||||
x-eov-operation-handler: v1/handlers/versionControl/versionControl.handler
|
||||
tags:
|
||||
- VersionControl
|
||||
summary: Pull changes from the remote repository
|
||||
description: Requires the Version Control feature to be licensed and connected to a repository.
|
||||
requestBody:
|
||||
description: Pull options
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/pull.yml"
|
||||
responses:
|
||||
"200":
|
||||
description: Import result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/importResult.yml"
|
||||
"400":
|
||||
$ref: "../../../../shared/spec/responses/badRequest.yml"
|
||||
"409":
|
||||
$ref: "../../../../shared/spec/responses/conflict.yml"
|
|
@ -0,0 +1,55 @@
|
|||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
variables:
|
||||
type: object
|
||||
properties:
|
||||
added:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
changed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
credentials:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
workflows:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
tags:
|
||||
type: object
|
||||
properties:
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
mappings:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
workflowId:
|
||||
type: string
|
||||
tagId:
|
||||
type: string
|
|
@ -0,0 +1,8 @@
|
|||
type: object
|
||||
properties:
|
||||
force:
|
||||
type: boolean
|
||||
example: true
|
||||
variables:
|
||||
type: object
|
||||
example: { "foo": "bar" }
|
|
@ -0,0 +1,47 @@
|
|||
import type express from 'express';
|
||||
import type { StatusResult } from 'simple-git';
|
||||
import type { PublicVersionControlRequest } from '../../../types';
|
||||
import { authorize } from '../../shared/middlewares/global.middleware';
|
||||
import type { ImportResult } from '@/environments/versionControl/types/importResult';
|
||||
import Container from 'typedi';
|
||||
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
|
||||
import { VersionControlPreferencesService } from '@/environments/versionControl/versionControlPreferences.service.ee';
|
||||
import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper.ee';
|
||||
|
||||
export = {
|
||||
pull: [
|
||||
authorize(['owner', 'member']),
|
||||
async (
|
||||
req: PublicVersionControlRequest.Pull,
|
||||
res: express.Response,
|
||||
): Promise<ImportResult | StatusResult | Promise<express.Response>> => {
|
||||
const versionControlPreferencesService = Container.get(VersionControlPreferencesService);
|
||||
if (!isVersionControlLicensed()) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ status: 'Error', message: 'Version Control feature is not licensed' });
|
||||
}
|
||||
if (!versionControlPreferencesService.isVersionControlConnected()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ status: 'Error', message: 'Version Control is not connected to a repository' });
|
||||
}
|
||||
try {
|
||||
const versionControlService = Container.get(VersionControlService);
|
||||
const result = await versionControlService.pullWorkfolder({
|
||||
force: req.body.force,
|
||||
variables: req.body.variables,
|
||||
userId: req.user.id,
|
||||
importAfterPull: true,
|
||||
});
|
||||
if ((result as ImportResult)?.workflows) {
|
||||
return res.status(200).send(result as ImportResult);
|
||||
} else {
|
||||
return res.status(409).send(result);
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).send((error as { message: string }).message);
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: n8n Public API
|
||||
title: n8n Public API11
|
||||
description: n8n Public API
|
||||
termsOfService: https://n8n.io/legal/terms
|
||||
contact:
|
||||
|
@ -24,35 +24,39 @@ tags:
|
|||
description: Operations about workflows
|
||||
- name: Credential
|
||||
description: Operations about credentials
|
||||
- name: VersionControl
|
||||
description: Operations about version control
|
||||
|
||||
paths:
|
||||
/audit:
|
||||
$ref: './handlers/audit/spec/paths/audit.yml'
|
||||
$ref: "./handlers/audit/spec/paths/audit.yml"
|
||||
/credentials:
|
||||
$ref: './handlers/credentials/spec/paths/credentials.yml'
|
||||
$ref: "./handlers/credentials/spec/paths/credentials.yml"
|
||||
/credentials/{id}:
|
||||
$ref: './handlers/credentials/spec/paths/credentials.id.yml'
|
||||
$ref: "./handlers/credentials/spec/paths/credentials.id.yml"
|
||||
/credentials/schema/{credentialTypeName}:
|
||||
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
|
||||
$ref: "./handlers/credentials/spec/paths/credentials.schema.id.yml"
|
||||
/executions:
|
||||
$ref: './handlers/executions/spec/paths/executions.yml'
|
||||
$ref: "./handlers/executions/spec/paths/executions.yml"
|
||||
/executions/{id}:
|
||||
$ref: './handlers/executions/spec/paths/executions.id.yml'
|
||||
$ref: "./handlers/executions/spec/paths/executions.id.yml"
|
||||
/workflows:
|
||||
$ref: './handlers/workflows/spec/paths/workflows.yml'
|
||||
$ref: "./handlers/workflows/spec/paths/workflows.yml"
|
||||
/workflows/{id}:
|
||||
$ref: './handlers/workflows/spec/paths/workflows.id.yml'
|
||||
$ref: "./handlers/workflows/spec/paths/workflows.id.yml"
|
||||
/workflows/{id}/activate:
|
||||
$ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
|
||||
$ref: "./handlers/workflows/spec/paths/workflows.id.activate.yml"
|
||||
/workflows/{id}/deactivate:
|
||||
$ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
|
||||
$ref: "./handlers/workflows/spec/paths/workflows.id.deactivate.yml"
|
||||
/version-control/pull:
|
||||
$ref: "./handlers/versionControl/spec/paths/versionControl.yml"
|
||||
components:
|
||||
schemas:
|
||||
$ref: './shared/spec/schemas/_index.yml'
|
||||
$ref: "./shared/spec/schemas/_index.yml"
|
||||
responses:
|
||||
$ref: './shared/spec/responses/_index.yml'
|
||||
$ref: "./shared/spec/responses/_index.yml"
|
||||
parameters:
|
||||
$ref: './shared/spec/parameters/_index.yml'
|
||||
$ref: "./shared/spec/parameters/_index.yml"
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
NotFound:
|
||||
$ref: './notFound.yml'
|
||||
$ref: "./notFound.yml"
|
||||
Unauthorized:
|
||||
$ref: './unauthorized.yml'
|
||||
$ref: "./unauthorized.yml"
|
||||
BadRequest:
|
||||
$ref: './badRequest.yml'
|
||||
$ref: "./badRequest.yml"
|
||||
Conflict:
|
||||
$ref: "./conflict.yml"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
description: Conflict
|
|
@ -1,22 +1,26 @@
|
|||
Error:
|
||||
$ref: './error.yml'
|
||||
$ref: "./error.yml"
|
||||
Execution:
|
||||
$ref: './../../../handlers/executions/spec/schemas/execution.yml'
|
||||
$ref: "./../../../handlers/executions/spec/schemas/execution.yml"
|
||||
Node:
|
||||
$ref: './../../../handlers/workflows/spec/schemas/node.yml'
|
||||
$ref: "./../../../handlers/workflows/spec/schemas/node.yml"
|
||||
Tag:
|
||||
$ref: './../../../handlers/workflows/spec/schemas/tag.yml'
|
||||
$ref: "./../../../handlers/workflows/spec/schemas/tag.yml"
|
||||
Workflow:
|
||||
$ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
|
||||
$ref: "./../../../handlers/workflows/spec/schemas/workflow.yml"
|
||||
WorkflowSettings:
|
||||
$ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
|
||||
$ref: "./../../../handlers/workflows/spec/schemas/workflowSettings.yml"
|
||||
ExecutionList:
|
||||
$ref: './../../../handlers/executions/spec/schemas/executionList.yml'
|
||||
$ref: "./../../../handlers/executions/spec/schemas/executionList.yml"
|
||||
WorkflowList:
|
||||
$ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
|
||||
$ref: "./../../../handlers/workflows/spec/schemas/workflowList.yml"
|
||||
Credential:
|
||||
$ref: './../../../handlers/credentials/spec/schemas/credential.yml'
|
||||
$ref: "./../../../handlers/credentials/spec/schemas/credential.yml"
|
||||
CredentialType:
|
||||
$ref: './../../../handlers/credentials/spec/schemas/credentialType.yml'
|
||||
$ref: "./../../../handlers/credentials/spec/schemas/credentialType.yml"
|
||||
Audit:
|
||||
$ref: './../../../handlers/audit/spec/schemas/audit.yml'
|
||||
$ref: "./../../../handlers/audit/spec/schemas/audit.yml"
|
||||
Pull:
|
||||
$ref: "./../../../handlers/versionControl/spec/schemas/pull.yml"
|
||||
ImportResult:
|
||||
$ref: "./../../../handlers/versionControl/spec/schemas/importResult.yml"
|
||||
|
|
|
@ -167,9 +167,10 @@ import {
|
|||
isLdapCurrentAuthenticationMethod,
|
||||
isSamlCurrentAuthenticationMethod,
|
||||
} from './sso/ssoHelpers';
|
||||
import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper';
|
||||
import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper.ee';
|
||||
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
|
||||
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
|
||||
import { VersionControlPreferencesService } from './environments/versionControl/versionControlPreferences.service.ee';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
|
@ -468,6 +469,7 @@ export class Server extends AbstractServer {
|
|||
const postHog = this.postHog;
|
||||
const samlService = Container.get(SamlService);
|
||||
const versionControlService = Container.get(VersionControlService);
|
||||
const versionControlPreferencesService = Container.get(VersionControlPreferencesService);
|
||||
|
||||
const controllers: object[] = [
|
||||
new EventBusController(),
|
||||
|
@ -496,7 +498,7 @@ export class Server extends AbstractServer {
|
|||
postHog,
|
||||
}),
|
||||
new SamlController(samlService),
|
||||
new VersionControlController(versionControlService),
|
||||
new VersionControlController(versionControlService, versionControlPreferencesService),
|
||||
];
|
||||
|
||||
if (isLdapEnabled()) {
|
||||
|
|
|
@ -1 +1,15 @@
|
|||
export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl';
|
||||
export const VERSION_CONTROL_GIT_FOLDER = 'git';
|
||||
export const VERSION_CONTROL_GIT_KEY_COMMENT = 'n8n deploy key';
|
||||
export const VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows';
|
||||
export const VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credentials';
|
||||
export const VERSION_CONTROL_VARIABLES_EXPORT_FILE = 'variables.json';
|
||||
export const VERSION_CONTROL_TAGS_EXPORT_FILE = 'tags.json';
|
||||
export const VERSION_CONTROL_SSH_FOLDER = 'ssh';
|
||||
export const VERSION_CONTROL_SSH_KEY_NAME = 'key';
|
||||
export const VERSION_CONTROL_DEFAULT_BRANCH = 'main';
|
||||
export const VERSION_CONTROL_ORIGIN = 'origin';
|
||||
export const VERSION_CONTROL_API_ROOT = 'version-control';
|
||||
export const VERSION_CONTROL_README = `
|
||||
# n8n Version Control
|
||||
`;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import type { RequestHandler } from 'express';
|
||||
import {
|
||||
isVersionControlLicensed,
|
||||
isVersionControlLicensedAndEnabled,
|
||||
} from '../versionControlHelper';
|
||||
import { isVersionControlLicensed } from '../versionControlHelper.ee';
|
||||
import Container from 'typedi';
|
||||
import { VersionControlPreferencesService } from '../versionControlPreferences.service.ee';
|
||||
|
||||
export const versionControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => {
|
||||
if (isVersionControlLicensedAndEnabled()) {
|
||||
const versionControlPreferencesService = Container.get(VersionControlPreferencesService);
|
||||
if (versionControlPreferencesService.isVersionControlLicensedAndEnabled()) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
|
@ -0,0 +1,9 @@
|
|||
export interface ExportResult {
|
||||
count: number;
|
||||
folder: string;
|
||||
files: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
removedFiles?: string[];
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
export interface ExportableCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow';
|
||||
|
||||
export interface ExportableWorkflow {
|
||||
active: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: INode[];
|
||||
connections: IConnections;
|
||||
settings?: IWorkflowSettings;
|
||||
triggerCount: number;
|
||||
owner: string;
|
||||
versionId: string;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { TagEntity } from '@/databases/entities/TagEntity';
|
||||
import type { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
|
||||
|
||||
export interface ImportResult {
|
||||
workflows: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
credentials: Array<{ id: string; name: string; type: string }>;
|
||||
variables: { added: string[]; changed: string[] };
|
||||
tags: { tags: TagEntity[]; mappings: WorkflowTagMapping[] };
|
||||
removedFiles?: string[];
|
||||
}
|
|
@ -1,6 +1,22 @@
|
|||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import type { VersionControlPreferences } from './versionControlPreferences';
|
||||
import type { VersionControlSetBranch } from './versionControlSetBranch';
|
||||
import type { VersionControlCommit } from './versionControlCommit';
|
||||
import type { VersionControlStage } from './versionControlStage';
|
||||
import type { VersionControlPush } from './versionControlPush';
|
||||
import type { VersionControlPushWorkFolder } from './versionControlPushWorkFolder';
|
||||
import type { VersionControlPullWorkFolder } from './versionControlPullWorkFolder';
|
||||
import type { VersionControlDisconnect } from './versionControlDisconnect';
|
||||
import type { VersionControlSetReadOnly } from './versionControlSetReadOnly';
|
||||
|
||||
export declare namespace VersionControlRequest {
|
||||
type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial<VersionControlPreferences>, {}>;
|
||||
type SetReadOnly = AuthenticatedRequest<{}, {}, VersionControlSetReadOnly, {}>;
|
||||
type SetBranch = AuthenticatedRequest<{}, {}, VersionControlSetBranch, {}>;
|
||||
type Commit = AuthenticatedRequest<{}, {}, VersionControlCommit, {}>;
|
||||
type Stage = AuthenticatedRequest<{}, {}, VersionControlStage, {}>;
|
||||
type Push = AuthenticatedRequest<{}, {}, VersionControlPush, {}>;
|
||||
type Disconnect = AuthenticatedRequest<{}, {}, VersionControlDisconnect, {}>;
|
||||
type PushWorkFolder = AuthenticatedRequest<{}, {}, VersionControlPushWorkFolder, {}>;
|
||||
type PullWorkFolder = AuthenticatedRequest<{}, {}, VersionControlPullWorkFolder, {}>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlCommit {
|
||||
@IsString()
|
||||
message: string;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class VersionControlDisconnect {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
keepKeyPair?: boolean;
|
||||
}
|
|
@ -18,7 +18,7 @@ export class VersionControlPreferences {
|
|||
authorEmail: string;
|
||||
|
||||
@IsString()
|
||||
branchName: string;
|
||||
branchName = 'main';
|
||||
|
||||
@IsBoolean()
|
||||
branchReadOnly: boolean;
|
||||
|
@ -28,9 +28,28 @@ export class VersionControlPreferences {
|
|||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly privateKey?: string;
|
||||
readonly publicKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly publicKey?: string;
|
||||
@IsBoolean()
|
||||
readonly initRepo?: boolean;
|
||||
|
||||
static fromJSON(json: Partial<VersionControlPreferences>): VersionControlPreferences {
|
||||
return new VersionControlPreferences(json);
|
||||
}
|
||||
|
||||
static merge(
|
||||
preferences: Partial<VersionControlPreferences>,
|
||||
defaultPreferences: Partial<VersionControlPreferences>,
|
||||
): VersionControlPreferences {
|
||||
return new VersionControlPreferences({
|
||||
connected: preferences.connected ?? defaultPreferences.connected,
|
||||
authorEmail: preferences.authorEmail ?? defaultPreferences.authorEmail,
|
||||
authorName: preferences.authorName ?? defaultPreferences.authorName,
|
||||
branchName: preferences.branchName ?? defaultPreferences.branchName,
|
||||
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
||||
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
||||
repositoryUrl: preferences.repositoryUrl ?? defaultPreferences.repositoryUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlPullWorkFolder {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
force?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
importAfterPull?: boolean = true;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
files?: Set<string>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
variables?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export class VersionControllPullOptions {
|
||||
userId: string;
|
||||
|
||||
force?: boolean;
|
||||
|
||||
variables?: { [key: string]: string };
|
||||
|
||||
importAfterPull?: boolean = true;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class VersionControlPush {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
force?: boolean;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlPushWorkFolder {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
force?: boolean;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
fileNames?: Set<string>;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
workflowIds?: Set<string>;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
credentialIds?: Set<string>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
message?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
skipDiff?: boolean;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlSetBranch {
|
||||
@IsString()
|
||||
branch: string;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class VersionControlSetReadOnly {
|
||||
@IsBoolean()
|
||||
branchReadOnly: boolean;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlStage {
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
fileNames?: Set<string>;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
workflowIds?: Set<string>;
|
||||
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
credentialIds?: Set<string>;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
export type VersionControlledFileStatus =
|
||||
| 'new'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'created'
|
||||
| 'renamed'
|
||||
| 'conflicted'
|
||||
| 'unknown';
|
||||
export type VersionControlledFileLocation = 'local' | 'remote';
|
||||
export type VersionControlledFileType = 'credential' | 'workflow' | 'tags' | 'variables' | 'file';
|
||||
export type VersionControlledFile = {
|
||||
file: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: VersionControlledFileType;
|
||||
status: VersionControlledFileStatus;
|
||||
location: VersionControlledFileLocation;
|
||||
conflict: boolean;
|
||||
};
|
|
@ -1,37 +1,320 @@
|
|||
import { Authorized, Get, Post, RestController } from '@/decorators';
|
||||
import { versionControlLicensedMiddleware } from './middleware/versionControlEnabledMiddleware';
|
||||
import {
|
||||
versionControlLicensedMiddleware,
|
||||
versionControlLicensedAndEnabledMiddleware,
|
||||
} from './middleware/versionControlEnabledMiddleware.ee';
|
||||
import { VersionControlService } from './versionControl.service.ee';
|
||||
import { VersionControlRequest } from './types/requests';
|
||||
import type { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import { BadRequestError } from '@/ResponseHelper';
|
||||
import type { PullResult, PushResult, StatusResult } from 'simple-git';
|
||||
import { AuthenticatedRequest } from '../../requests';
|
||||
import express from 'express';
|
||||
import type { ImportResult } from './types/importResult';
|
||||
import type { VersionControlPushWorkFolder } from './types/versionControlPushWorkFolder';
|
||||
import { VersionControlPreferencesService } from './versionControlPreferences.service.ee';
|
||||
import type { VersionControlledFile } from './types/versionControlledFile';
|
||||
import { VERSION_CONTROL_API_ROOT, VERSION_CONTROL_DEFAULT_BRANCH } from './constants';
|
||||
|
||||
@RestController('/versionControl')
|
||||
@RestController(`/${VERSION_CONTROL_API_ROOT}`)
|
||||
export class VersionControlController {
|
||||
constructor(private versionControlService: VersionControlService) {}
|
||||
constructor(
|
||||
private versionControlService: VersionControlService,
|
||||
private versionControlPreferencesService: VersionControlPreferencesService,
|
||||
) {}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/preferences', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async getPreferences(): Promise<VersionControlPreferences> {
|
||||
// returns the settings with the privateKey property redacted
|
||||
return this.versionControlService.versionControlPreferences;
|
||||
return this.versionControlPreferencesService.getPreferences();
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/preferences', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async setPreferences(req: VersionControlRequest.UpdatePreferences) {
|
||||
const sanitizedPreferences: Partial<VersionControlPreferences> = {
|
||||
...req.body,
|
||||
privateKey: undefined,
|
||||
publicKey: undefined,
|
||||
};
|
||||
await this.versionControlService.validateVersionControlPreferences(sanitizedPreferences);
|
||||
return this.versionControlService.setPreferences(sanitizedPreferences);
|
||||
if (
|
||||
req.body.branchReadOnly === undefined &&
|
||||
this.versionControlPreferencesService.isVersionControlConnected()
|
||||
) {
|
||||
throw new BadRequestError(
|
||||
'Cannot change preferences while connected to a version control provider. Please disconnect first.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const sanitizedPreferences: Partial<VersionControlPreferences> = {
|
||||
...req.body,
|
||||
initRepo: req.body.initRepo ?? true, // default to true if not specified
|
||||
connected: undefined,
|
||||
publicKey: undefined,
|
||||
};
|
||||
await this.versionControlPreferencesService.validateVersionControlPreferences(
|
||||
sanitizedPreferences,
|
||||
);
|
||||
const updatedPreferences = await this.versionControlPreferencesService.setPreferences(
|
||||
sanitizedPreferences,
|
||||
);
|
||||
if (sanitizedPreferences.initRepo === true) {
|
||||
try {
|
||||
await this.versionControlService.initializeRepository({
|
||||
...updatedPreferences,
|
||||
branchName:
|
||||
updatedPreferences.branchName === ''
|
||||
? VERSION_CONTROL_DEFAULT_BRANCH
|
||||
: updatedPreferences.branchName,
|
||||
initRepo: true,
|
||||
});
|
||||
if (this.versionControlPreferencesService.getPreferences().branchName !== '') {
|
||||
await this.versionControlPreferencesService.setPreferences({
|
||||
connected: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// if initialization fails, run cleanup to remove any intermediate state and throw the error
|
||||
await this.versionControlService.disconnect({ keepKeyPair: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.versionControlService.init();
|
||||
return this.versionControlPreferencesService.getPreferences();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: temporary function to generate key and save new pair
|
||||
// REMOVE THIS FUNCTION AFTER TESTING
|
||||
@Authorized(['global', 'owner'])
|
||||
@Get('/generateKeyPair', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async generateKeyPair() {
|
||||
return this.versionControlService.generateAndSaveKeyPair();
|
||||
@Post('/set-read-only', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async setReadOnly(req: VersionControlRequest.SetReadOnly) {
|
||||
try {
|
||||
this.versionControlPreferencesService.setBranchReadOnly(req.body.branchReadOnly);
|
||||
return this.versionControlPreferencesService.getPreferences();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/disconnect', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async disconnect(req: VersionControlRequest.Disconnect) {
|
||||
try {
|
||||
return await this.versionControlService.disconnect(req.body);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/get-branches', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async getBranches() {
|
||||
try {
|
||||
return await this.versionControlService.getBranches();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/set-branch', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async setBranch(req: VersionControlRequest.SetBranch) {
|
||||
try {
|
||||
return await this.versionControlService.setBranch(req.body.branch);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/push-workfolder', { middlewares: [versionControlLicensedAndEnabledMiddleware] })
|
||||
async pushWorkfolder(
|
||||
req: VersionControlRequest.PushWorkFolder,
|
||||
res: express.Response,
|
||||
): Promise<PushResult | VersionControlledFile[]> {
|
||||
if (this.versionControlPreferencesService.isBranchReadOnly()) {
|
||||
throw new BadRequestError('Cannot push onto read-only branch.');
|
||||
}
|
||||
try {
|
||||
const result = await this.versionControlService.pushWorkfolder(req.body);
|
||||
if ((result as PushResult).pushed) {
|
||||
res.statusCode = 200;
|
||||
} else {
|
||||
res.statusCode = 409;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/pull-workfolder', { middlewares: [versionControlLicensedAndEnabledMiddleware] })
|
||||
async pullWorkfolder(
|
||||
req: VersionControlRequest.PullWorkFolder,
|
||||
res: express.Response,
|
||||
): Promise<VersionControlledFile[] | ImportResult | PullResult | StatusResult | undefined> {
|
||||
try {
|
||||
const result = await this.versionControlService.pullWorkfolder({
|
||||
force: req.body.force,
|
||||
variables: req.body.variables,
|
||||
userId: req.user.id,
|
||||
importAfterPull: req.body.importAfterPull ?? true,
|
||||
});
|
||||
if ((result as ImportResult)?.workflows) {
|
||||
res.statusCode = 200;
|
||||
} else {
|
||||
res.statusCode = 409;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Get('/reset-workfolder', { middlewares: [versionControlLicensedAndEnabledMiddleware] })
|
||||
async resetWorkfolder(
|
||||
req: VersionControlRequest.PullWorkFolder,
|
||||
): Promise<ImportResult | undefined> {
|
||||
try {
|
||||
return await this.versionControlService.resetWorkfolder({
|
||||
force: req.body.force,
|
||||
variables: req.body.variables,
|
||||
userId: req.user.id,
|
||||
importAfterPull: req.body.importAfterPull ?? true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/get-status', { middlewares: [versionControlLicensedAndEnabledMiddleware] })
|
||||
async getStatus() {
|
||||
try {
|
||||
return await this.versionControlService.getStatus();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/status', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async status(): Promise<StatusResult> {
|
||||
try {
|
||||
return await this.versionControlService.status();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/generate-key-pair', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async generateKeyPair(): Promise<VersionControlPreferences> {
|
||||
try {
|
||||
const result = await this.versionControlPreferencesService.generateAndSaveKeyPair();
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
// #region Version Control Test Functions
|
||||
//TODO: SEPARATE FUNCTIONS FOR DEVELOPMENT ONLY
|
||||
//TODO: REMOVE THESE FUNCTIONS AFTER TESTING
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Get('/export', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async export() {
|
||||
try {
|
||||
return await this.versionControlService.export();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Get('/import', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async import(req: AuthenticatedRequest) {
|
||||
try {
|
||||
return await this.versionControlService.import({
|
||||
userId: req.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/fetch')
|
||||
async fetch() {
|
||||
try {
|
||||
return await this.versionControlService.fetch();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized('any')
|
||||
@Get('/diff')
|
||||
async diff() {
|
||||
try {
|
||||
return await this.versionControlService.diff();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/push')
|
||||
async push(req: VersionControlRequest.Push): Promise<PushResult> {
|
||||
if (this.versionControlPreferencesService.isBranchReadOnly()) {
|
||||
throw new BadRequestError('Cannot push onto read-only branch.');
|
||||
}
|
||||
try {
|
||||
return await this.versionControlService.push(req.body.force);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/commit')
|
||||
async commit(req: VersionControlRequest.Commit) {
|
||||
try {
|
||||
return await this.versionControlService.commit(req.body.message);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/stage')
|
||||
async stage(req: VersionControlRequest.Stage): Promise<{ staged: string[] } | string> {
|
||||
try {
|
||||
return await this.versionControlService.stage(req.body as VersionControlPushWorkFolder);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Post('/unstage')
|
||||
async unstage(): Promise<StatusResult | string> {
|
||||
try {
|
||||
return await this.versionControlService.unstage();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized(['global', 'owner'])
|
||||
@Get('/pull')
|
||||
async pull(): Promise<PullResult> {
|
||||
try {
|
||||
return await this.versionControlService.pull();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
|
|
@ -1,108 +1,491 @@
|
|||
import { Service } from 'typedi';
|
||||
import { generateSshKeyPair } from './versionControlHelper';
|
||||
import { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import { VERSION_CONTROL_PREFERENCES_DB_KEY } from './constants';
|
||||
import path from 'path';
|
||||
import * as Db from '@/Db';
|
||||
import { jsonParse, LoggerProxy } from 'n8n-workflow';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { versionControlFoldersExistCheck } from './versionControlHelper.ee';
|
||||
import type { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import {
|
||||
VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
VERSION_CONTROL_GIT_FOLDER,
|
||||
VERSION_CONTROL_README,
|
||||
VERSION_CONTROL_SSH_FOLDER,
|
||||
VERSION_CONTROL_SSH_KEY_NAME,
|
||||
VERSION_CONTROL_TAGS_EXPORT_FILE,
|
||||
VERSION_CONTROL_VARIABLES_EXPORT_FILE,
|
||||
VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER,
|
||||
} from './constants';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import { VersionControlGitService } from './versionControlGit.service.ee';
|
||||
import { UserSettings } from 'n8n-core';
|
||||
import type {
|
||||
CommitResult,
|
||||
DiffResult,
|
||||
FetchResult,
|
||||
PullResult,
|
||||
PushResult,
|
||||
StatusResult,
|
||||
} from 'simple-git';
|
||||
import type { ExportResult } from './types/exportResult';
|
||||
import { VersionControlExportService } from './versionControlExport.service.ee';
|
||||
import { BadRequestError } from '../../ResponseHelper';
|
||||
import type { ImportResult } from './types/importResult';
|
||||
import type { VersionControlPushWorkFolder } from './types/versionControlPushWorkFolder';
|
||||
import type { VersionControllPullOptions } from './types/versionControlPullWorkFolder';
|
||||
import type {
|
||||
VersionControlledFileLocation,
|
||||
VersionControlledFile,
|
||||
VersionControlledFileStatus,
|
||||
VersionControlledFileType,
|
||||
} from './types/versionControlledFile';
|
||||
import { VersionControlPreferencesService } from './versionControlPreferences.service.ee';
|
||||
import { writeFileSync } from 'fs';
|
||||
@Service()
|
||||
export class VersionControlService {
|
||||
private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences();
|
||||
private sshKeyName: string;
|
||||
|
||||
private sshFolder: string;
|
||||
|
||||
private gitFolder: string;
|
||||
|
||||
constructor(
|
||||
private gitService: VersionControlGitService,
|
||||
private versionControlPreferencesService: VersionControlPreferencesService,
|
||||
private versionControlExportService: VersionControlExportService,
|
||||
) {
|
||||
const userFolder = UserSettings.getUserN8nFolderPath();
|
||||
this.sshFolder = path.join(userFolder, VERSION_CONTROL_SSH_FOLDER);
|
||||
this.gitFolder = path.join(userFolder, VERSION_CONTROL_GIT_FOLDER);
|
||||
this.sshKeyName = path.join(this.sshFolder, VERSION_CONTROL_SSH_KEY_NAME);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.loadFromDbAndApplyVersionControlPreferences();
|
||||
this.gitService.resetService();
|
||||
versionControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
||||
await this.versionControlPreferencesService.loadFromDbAndApplyVersionControlPreferences();
|
||||
await this.gitService.initService({
|
||||
versionControlPreferences: this.versionControlPreferencesService.getPreferences(),
|
||||
gitFolder: this.gitFolder,
|
||||
sshKeyName: this.sshKeyName,
|
||||
sshFolder: this.sshFolder,
|
||||
});
|
||||
}
|
||||
|
||||
public get versionControlPreferences(): VersionControlPreferences {
|
||||
return {
|
||||
...this._versionControlPreferences,
|
||||
privateKey: '(redacted)',
|
||||
};
|
||||
async disconnect(options: { keepKeyPair?: boolean } = {}) {
|
||||
try {
|
||||
await this.versionControlPreferencesService.setPreferences({
|
||||
connected: false,
|
||||
branchName: '',
|
||||
});
|
||||
await this.versionControlExportService.deleteRepositoryFolder();
|
||||
if (!options.keepKeyPair) {
|
||||
await this.versionControlPreferencesService.deleteKeyPairFiles();
|
||||
}
|
||||
this.gitService.resetService();
|
||||
return this.versionControlPreferencesService.versionControlPreferences;
|
||||
} catch (error) {
|
||||
throw Error(`Failed to disconnect from version control: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public set versionControlPreferences(preferences: Partial<VersionControlPreferences>) {
|
||||
this._versionControlPreferences = {
|
||||
connected: preferences.connected ?? this._versionControlPreferences.connected,
|
||||
authorEmail: preferences.authorEmail ?? this._versionControlPreferences.authorEmail,
|
||||
authorName: preferences.authorName ?? this._versionControlPreferences.authorName,
|
||||
branchName: preferences.branchName ?? this._versionControlPreferences.branchName,
|
||||
branchColor: preferences.branchColor ?? this._versionControlPreferences.branchColor,
|
||||
branchReadOnly: preferences.branchReadOnly ?? this._versionControlPreferences.branchReadOnly,
|
||||
privateKey: preferences.privateKey ?? this._versionControlPreferences.privateKey,
|
||||
publicKey: preferences.publicKey ?? this._versionControlPreferences.publicKey,
|
||||
repositoryUrl: preferences.repositoryUrl ?? this._versionControlPreferences.repositoryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async generateAndSaveKeyPair() {
|
||||
const keyPair = generateSshKeyPair('ed25519');
|
||||
if (keyPair.publicKey && keyPair.privateKey) {
|
||||
await this.setPreferences({ ...keyPair });
|
||||
async initializeRepository(preferences: VersionControlPreferences) {
|
||||
if (!this.gitService.git) {
|
||||
await this.init();
|
||||
}
|
||||
LoggerProxy.debug('Initializing repository...');
|
||||
await this.gitService.initRepository(preferences);
|
||||
let getBranchesResult;
|
||||
try {
|
||||
getBranchesResult = await this.getBranches();
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('Warning: Permanently added')) {
|
||||
LoggerProxy.debug('Added repository host to the list of known hosts. Retrying...');
|
||||
getBranchesResult = await this.getBranches();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (getBranchesResult.branches.includes(preferences.branchName)) {
|
||||
await this.gitService.setBranch(preferences.branchName);
|
||||
} else {
|
||||
LoggerProxy.error('Failed to generate key pair');
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
if (getBranchesResult.branches?.length === 0) {
|
||||
try {
|
||||
writeFileSync(path.join(this.gitFolder, '/README.md'), VERSION_CONTROL_README);
|
||||
|
||||
async validateVersionControlPreferences(
|
||||
preferences: Partial<VersionControlPreferences>,
|
||||
): Promise<ValidationError[]> {
|
||||
const preferencesObject = new VersionControlPreferences(preferences);
|
||||
const validationResult = await validate(preferencesObject, {
|
||||
forbidUnknownValues: false,
|
||||
skipMissingProperties: true,
|
||||
stopAtFirstError: false,
|
||||
validationError: { target: false },
|
||||
});
|
||||
if (validationResult.length > 0) {
|
||||
throw new Error(`Invalid version control preferences: ${JSON.stringify(validationResult)}`);
|
||||
}
|
||||
// TODO: if repositoryUrl is changed, check if it is valid
|
||||
// TODO: if branchName is changed, check if it is valid
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
async setPreferences(
|
||||
preferences: Partial<VersionControlPreferences>,
|
||||
saveToDb = true,
|
||||
): Promise<VersionControlPreferences> {
|
||||
this.versionControlPreferences = preferences;
|
||||
if (saveToDb) {
|
||||
const settingsValue = JSON.stringify(this._versionControlPreferences);
|
||||
try {
|
||||
await Db.collections.Settings.save({
|
||||
key: VERSION_CONTROL_PREFERENCES_DB_KEY,
|
||||
value: settingsValue,
|
||||
loadOnStartup: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save version control preferences: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.versionControlPreferences;
|
||||
}
|
||||
|
||||
async loadFromDbAndApplyVersionControlPreferences(): Promise<
|
||||
VersionControlPreferences | undefined
|
||||
> {
|
||||
const loadedPreferences = await Db.collections.Settings.findOne({
|
||||
where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY },
|
||||
});
|
||||
if (loadedPreferences) {
|
||||
try {
|
||||
const preferences = jsonParse<VersionControlPreferences>(loadedPreferences.value);
|
||||
if (preferences) {
|
||||
await this.setPreferences(preferences, false);
|
||||
return preferences;
|
||||
await this.gitService.stage(new Set<string>(['README.md']));
|
||||
await this.gitService.commit('Initial commit');
|
||||
await this.gitService.push({
|
||||
branch: preferences.branchName,
|
||||
force: true,
|
||||
});
|
||||
getBranchesResult = await this.getBranches();
|
||||
} catch (fileError) {
|
||||
LoggerProxy.error(`Failed to create initial commit: ${(fileError as Error).message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
LoggerProxy.warn(
|
||||
`Could not parse Version Control settings from database: ${(error as Error).message}`,
|
||||
);
|
||||
} else {
|
||||
await this.versionControlPreferencesService.setPreferences({
|
||||
branchName: '',
|
||||
connected: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return getBranchesResult;
|
||||
}
|
||||
|
||||
async export() {
|
||||
const result: {
|
||||
tags: ExportResult | undefined;
|
||||
credentials: ExportResult | undefined;
|
||||
variables: ExportResult | undefined;
|
||||
workflows: ExportResult | undefined;
|
||||
} = {
|
||||
credentials: undefined,
|
||||
tags: undefined,
|
||||
variables: undefined,
|
||||
workflows: undefined,
|
||||
};
|
||||
try {
|
||||
// comment next line if needed
|
||||
await this.versionControlExportService.cleanWorkFolder();
|
||||
result.tags = await this.versionControlExportService.exportTagsToWorkFolder();
|
||||
result.variables = await this.versionControlExportService.exportVariablesToWorkFolder();
|
||||
result.workflows = await this.versionControlExportService.exportWorkflowsToWorkFolder();
|
||||
result.credentials = await this.versionControlExportService.exportCredentialsToWorkFolder();
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async import(options: VersionControllPullOptions): Promise<ImportResult | undefined> {
|
||||
try {
|
||||
return await this.versionControlExportService.importFromWorkFolder(options);
|
||||
} catch (error) {
|
||||
throw new BadRequestError((error as { message: string }).message);
|
||||
}
|
||||
}
|
||||
|
||||
async getBranches(): Promise<{ branches: string[]; currentBranch: string }> {
|
||||
// fetch first to get include remote changes
|
||||
await this.gitService.fetch();
|
||||
return this.gitService.getBranches();
|
||||
}
|
||||
|
||||
async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> {
|
||||
await this.versionControlPreferencesService.setPreferences({
|
||||
branchName: branch,
|
||||
connected: true,
|
||||
});
|
||||
return this.gitService.setBranch(branch);
|
||||
}
|
||||
|
||||
// will reset the branch to the remote branch and pull
|
||||
// this will discard all local changes
|
||||
async resetWorkfolder(options: VersionControllPullOptions): Promise<ImportResult | undefined> {
|
||||
const currentBranch = await this.gitService.getCurrentBranch();
|
||||
await this.versionControlExportService.cleanWorkFolder();
|
||||
await this.gitService.resetBranch({
|
||||
hard: true,
|
||||
target: currentBranch.remote,
|
||||
});
|
||||
await this.gitService.pull();
|
||||
if (options.importAfterPull) {
|
||||
return this.import(options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async pushWorkfolder(
|
||||
options: VersionControlPushWorkFolder,
|
||||
): Promise<PushResult | VersionControlledFile[]> {
|
||||
if (this.versionControlPreferencesService.isBranchReadOnly()) {
|
||||
throw new BadRequestError('Cannot push onto read-only branch.');
|
||||
}
|
||||
if (!options.skipDiff) {
|
||||
const diffResult = await this.getStatus();
|
||||
const possibleConflicts = diffResult?.filter((file) => file.conflict);
|
||||
if (possibleConflicts?.length > 0 && options.force !== true) {
|
||||
await this.unstage();
|
||||
return diffResult;
|
||||
}
|
||||
}
|
||||
await this.unstage();
|
||||
await this.stage(options);
|
||||
await this.gitService.commit(options.message ?? 'Updated Workfolder');
|
||||
return this.gitService.push({
|
||||
branch: this.versionControlPreferencesService.getBranchName(),
|
||||
force: options.force ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// async pushWorkfolder(
|
||||
// options: VersionControlPushWorkFolder,
|
||||
// ): Promise<PushResult | VersionControlledFile[]> {
|
||||
// await this.gitService.fetch();
|
||||
// await this.export(); // refresh workfolder
|
||||
// await this.stage(options);
|
||||
// await this.gitService.commit(options.message ?? 'Updated Workfolder');
|
||||
// return this.gitService.push({
|
||||
// branch: this.versionControlPreferencesService.getBranchName(),
|
||||
// force: options.force ?? false,
|
||||
// });
|
||||
// }
|
||||
|
||||
// TODO: Alternate implementation for pull
|
||||
// async pullWorkfolder(
|
||||
// options: VersionControllPullOptions,
|
||||
// ): Promise<ImportResult | VersionControlledFile[] | PullResult | undefined> {
|
||||
// const diffResult = await this.getStatus();
|
||||
// const possibleConflicts = diffResult?.filter((file) => file.conflict);
|
||||
// if (possibleConflicts?.length > 0 || options.force === true) {
|
||||
// await this.unstage();
|
||||
// if (options.force === true) {
|
||||
// return this.resetWorkfolder(options);
|
||||
// } else {
|
||||
// return diffResult;
|
||||
// }
|
||||
// }
|
||||
// const pullResult = await this.gitService.pull();
|
||||
// if (options.importAfterPull) {
|
||||
// return this.import(options);
|
||||
// }
|
||||
// return pullResult;
|
||||
// }
|
||||
|
||||
async pullWorkfolder(
|
||||
options: VersionControllPullOptions,
|
||||
): Promise<ImportResult | StatusResult | undefined> {
|
||||
await this.resetWorkfolder({
|
||||
importAfterPull: false,
|
||||
userId: options.userId,
|
||||
force: false,
|
||||
});
|
||||
await this.export(); // refresh workfolder
|
||||
const status = await this.gitService.status();
|
||||
|
||||
if (status.modified.length > 0 && options.force !== true) {
|
||||
return status;
|
||||
}
|
||||
await this.resetWorkfolder({ ...options, importAfterPull: false });
|
||||
if (options.importAfterPull) {
|
||||
return this.import(options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async stage(
|
||||
options: Pick<VersionControlPushWorkFolder, 'fileNames' | 'credentialIds' | 'workflowIds'>,
|
||||
): Promise<{ staged: string[] } | string> {
|
||||
const { fileNames, credentialIds, workflowIds } = options;
|
||||
const status = await this.gitService.status();
|
||||
let mergedFileNames = new Set<string>();
|
||||
fileNames?.forEach((e) => mergedFileNames.add(e));
|
||||
credentialIds?.forEach((e) =>
|
||||
mergedFileNames.add(this.versionControlExportService.getCredentialsPath(e)),
|
||||
);
|
||||
workflowIds?.forEach((e) =>
|
||||
mergedFileNames.add(this.versionControlExportService.getWorkflowPath(e)),
|
||||
);
|
||||
if (mergedFileNames.size === 0) {
|
||||
mergedFileNames = new Set<string>([
|
||||
...status.not_added,
|
||||
...status.created,
|
||||
...status.modified,
|
||||
]);
|
||||
}
|
||||
const deletedFiles = new Set<string>(status.deleted);
|
||||
deletedFiles.forEach((e) => mergedFileNames.delete(e));
|
||||
await this.unstage();
|
||||
const stageResult = await this.gitService.stage(mergedFileNames, deletedFiles);
|
||||
if (!stageResult) {
|
||||
const statusResult = await this.gitService.status();
|
||||
return { staged: statusResult.staged };
|
||||
}
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
async unstage(): Promise<StatusResult | string> {
|
||||
const stageResult = await this.gitService.resetBranch();
|
||||
if (!stageResult) {
|
||||
return this.gitService.status();
|
||||
}
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
async status(): Promise<StatusResult> {
|
||||
return this.gitService.status();
|
||||
}
|
||||
|
||||
private async fileNameToVersionControlledFile(
|
||||
fileName: string,
|
||||
location: VersionControlledFileLocation,
|
||||
statusResult: StatusResult,
|
||||
): Promise<VersionControlledFile | undefined> {
|
||||
let id: string | undefined = undefined;
|
||||
let name = '';
|
||||
let conflict = false;
|
||||
let status: VersionControlledFileStatus = 'unknown';
|
||||
let type: VersionControlledFileType = 'file';
|
||||
|
||||
// initialize status from git status result
|
||||
if (statusResult.not_added.find((e) => e === fileName)) status = 'new';
|
||||
else if (statusResult.conflicted.find((e) => e === fileName)) {
|
||||
status = 'conflicted';
|
||||
conflict = true;
|
||||
} else if (statusResult.created.find((e) => e === fileName)) status = 'created';
|
||||
else if (statusResult.deleted.find((e) => e === fileName)) status = 'deleted';
|
||||
else if (statusResult.modified.find((e) => e === fileName)) status = 'modified';
|
||||
|
||||
if (fileName.startsWith(VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER)) {
|
||||
type = 'workflow';
|
||||
if (status === 'deleted') {
|
||||
id = fileName
|
||||
.replace(VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER, '')
|
||||
.replace(/[\/,\\]/, '')
|
||||
.replace('.json', '');
|
||||
if (location === 'remote') {
|
||||
const existingWorkflow = await Db.collections.Workflow.find({
|
||||
where: { id },
|
||||
});
|
||||
if (existingWorkflow?.length > 0) {
|
||||
name = existingWorkflow[0].name;
|
||||
}
|
||||
} else {
|
||||
name = '(deleted)';
|
||||
}
|
||||
} else {
|
||||
const workflow = await this.versionControlExportService.getWorkflowFromFile(fileName);
|
||||
if (!workflow?.id) {
|
||||
if (location === 'local') {
|
||||
return;
|
||||
}
|
||||
id = fileName
|
||||
.replace(VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER + '/', '')
|
||||
.replace('.json', '');
|
||||
status = 'created';
|
||||
} else {
|
||||
id = workflow.id;
|
||||
name = workflow.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fileName.startsWith(VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
|
||||
type = 'credential';
|
||||
if (status === 'deleted') {
|
||||
id = fileName
|
||||
.replace(VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER, '')
|
||||
.replace(/[\/,\\]/, '')
|
||||
.replace('.json', '');
|
||||
if (location === 'remote') {
|
||||
const existingCredential = await Db.collections.Credentials.find({
|
||||
where: { id },
|
||||
});
|
||||
if (existingCredential?.length > 0) {
|
||||
name = existingCredential[0].name;
|
||||
}
|
||||
} else {
|
||||
name = '(deleted)';
|
||||
}
|
||||
} else {
|
||||
const credential = await this.versionControlExportService.getCredentialFromFile(fileName);
|
||||
if (!credential?.id) {
|
||||
if (location === 'local') {
|
||||
return;
|
||||
}
|
||||
id = fileName
|
||||
.replace(VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER + '/', '')
|
||||
.replace('.json', '');
|
||||
status = 'created';
|
||||
} else {
|
||||
id = credential.id;
|
||||
name = credential.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileName.startsWith(VERSION_CONTROL_VARIABLES_EXPORT_FILE)) {
|
||||
id = 'variables';
|
||||
name = 'variables';
|
||||
type = 'variables';
|
||||
}
|
||||
|
||||
if (fileName.startsWith(VERSION_CONTROL_TAGS_EXPORT_FILE)) {
|
||||
id = 'tags';
|
||||
name = 'tags';
|
||||
type = 'tags';
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
return {
|
||||
file: fileName,
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
status,
|
||||
location,
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(): Promise<VersionControlledFile[]> {
|
||||
await this.export();
|
||||
await this.stage({});
|
||||
await this.gitService.fetch();
|
||||
const versionControlledFiles: VersionControlledFile[] = [];
|
||||
const diffRemote = await this.gitService.diffRemote();
|
||||
const diffLocal = await this.gitService.diffLocal();
|
||||
const status = await this.gitService.status();
|
||||
await Promise.all([
|
||||
...(diffRemote?.files.map(async (e) => {
|
||||
const resolvedFile = await this.fileNameToVersionControlledFile(e.file, 'remote', status);
|
||||
if (resolvedFile) {
|
||||
versionControlledFiles.push(resolvedFile);
|
||||
}
|
||||
}) ?? []),
|
||||
...(diffLocal?.files.map(async (e) => {
|
||||
const resolvedFile = await this.fileNameToVersionControlledFile(e.file, 'local', status);
|
||||
if (resolvedFile) {
|
||||
versionControlledFiles.push(resolvedFile);
|
||||
}
|
||||
}) ?? []),
|
||||
]);
|
||||
versionControlledFiles.forEach((e, index, array) => {
|
||||
const similarItems = array.filter(
|
||||
(f) => f.type === e.type && (f.file === e.file || f.id === e.id),
|
||||
);
|
||||
if (similarItems.length > 1) {
|
||||
similarItems.forEach((item) => {
|
||||
item.conflict = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
return versionControlledFiles;
|
||||
}
|
||||
|
||||
// #region Version Control Test Functions
|
||||
//TODO: SEPARATE FUNCTIONS FOR DEVELOPMENT ONLY
|
||||
//TODO: REMOVE THESE FUNCTIONS AFTER TESTING
|
||||
|
||||
async commit(message?: string): Promise<CommitResult> {
|
||||
return this.gitService.commit(message ?? 'Updated Workfolder');
|
||||
}
|
||||
|
||||
async fetch(): Promise<FetchResult> {
|
||||
return this.gitService.fetch();
|
||||
}
|
||||
|
||||
async diff(): Promise<DiffResult> {
|
||||
return this.gitService.diff();
|
||||
}
|
||||
|
||||
async pull(): Promise<PullResult> {
|
||||
return this.gitService.pull();
|
||||
}
|
||||
|
||||
async push(force = false): Promise<PushResult> {
|
||||
return this.gitService.push({
|
||||
branch: this.versionControlPreferencesService.getBranchName(),
|
||||
force,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
|
|
@ -0,0 +1,674 @@
|
|||
import Container, { Service } from 'typedi';
|
||||
import path from 'path';
|
||||
import {
|
||||
VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
VERSION_CONTROL_GIT_FOLDER,
|
||||
VERSION_CONTROL_TAGS_EXPORT_FILE,
|
||||
VERSION_CONTROL_VARIABLES_EXPORT_FILE,
|
||||
VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER,
|
||||
} from './constants';
|
||||
import * as Db from '@/Db';
|
||||
import glob from 'fast-glob';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { LoggerProxy, jsonParse } from 'n8n-workflow';
|
||||
import { writeFile as fsWriteFile, readFile as fsReadFile, rm as fsRm } from 'fs/promises';
|
||||
import { VersionControlGitService } from './versionControlGit.service.ee';
|
||||
import { Credentials, UserSettings } from 'n8n-core';
|
||||
import type { IWorkflowToImport } from '@/Interfaces';
|
||||
import type { ExportableWorkflow } from './types/exportableWorkflow';
|
||||
import type { ExportableCredential } from './types/exportableCredential';
|
||||
import type { ExportResult } from './types/exportResult';
|
||||
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
|
||||
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
|
||||
import { Variables } from '@/databases/entities/Variables';
|
||||
import type { ImportResult } from './types/importResult';
|
||||
import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand';
|
||||
import config from '@/config';
|
||||
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
|
||||
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
||||
import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
|
||||
import { TagEntity } from '@/databases/entities/TagEntity';
|
||||
import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner';
|
||||
import without from 'lodash.without';
|
||||
import type { VersionControllPullOptions } from './types/versionControlPullWorkFolder';
|
||||
import { versionControlFoldersExistCheck } from './versionControlHelper.ee';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Service()
|
||||
export class VersionControlExportService {
|
||||
private gitFolder: string;
|
||||
|
||||
private workflowExportFolder: string;
|
||||
|
||||
private credentialExportFolder: string;
|
||||
|
||||
constructor(private gitService: VersionControlGitService) {
|
||||
const userFolder = UserSettings.getUserN8nFolderPath();
|
||||
this.gitFolder = path.join(userFolder, VERSION_CONTROL_GIT_FOLDER);
|
||||
this.workflowExportFolder = path.join(this.gitFolder, VERSION_CONTROL_WORKFLOW_EXPORT_FOLDER);
|
||||
this.credentialExportFolder = path.join(
|
||||
this.gitFolder,
|
||||
VERSION_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
);
|
||||
}
|
||||
|
||||
getWorkflowPath(workflowId: string): string {
|
||||
return path.join(this.workflowExportFolder, `${workflowId}.json`);
|
||||
}
|
||||
|
||||
getCredentialsPath(credentialsId: string): string {
|
||||
return path.join(this.credentialExportFolder, `${credentialsId}.json`);
|
||||
}
|
||||
|
||||
getTagsPath(): string {
|
||||
return path.join(this.gitFolder, VERSION_CONTROL_TAGS_EXPORT_FILE);
|
||||
}
|
||||
|
||||
getVariablesPath(): string {
|
||||
return path.join(this.gitFolder, VERSION_CONTROL_VARIABLES_EXPORT_FILE);
|
||||
}
|
||||
|
||||
async getWorkflowFromFile(
|
||||
filePath: string,
|
||||
root = this.gitFolder,
|
||||
): Promise<IWorkflowToImport | undefined> {
|
||||
try {
|
||||
const importedWorkflow = jsonParse<IWorkflowToImport>(
|
||||
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
|
||||
);
|
||||
return importedWorkflow;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getCredentialFromFile(
|
||||
filePath: string,
|
||||
root = this.gitFolder,
|
||||
): Promise<ExportableCredential | undefined> {
|
||||
try {
|
||||
const credential = jsonParse<ExportableCredential>(
|
||||
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
|
||||
);
|
||||
return credential;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnerGlobalRole() {
|
||||
const ownerCredentiallRole = await Db.collections.Role.findOne({
|
||||
where: { name: 'owner', scope: 'global' },
|
||||
});
|
||||
|
||||
if (!ownerCredentiallRole) {
|
||||
throw new Error(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
|
||||
}
|
||||
|
||||
return ownerCredentiallRole;
|
||||
}
|
||||
|
||||
private async getOwnerCredentialRole() {
|
||||
const ownerCredentiallRole = await Db.collections.Role.findOne({
|
||||
where: { name: 'owner', scope: 'credential' },
|
||||
});
|
||||
|
||||
if (!ownerCredentiallRole) {
|
||||
throw new Error(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
|
||||
}
|
||||
|
||||
return ownerCredentiallRole;
|
||||
}
|
||||
|
||||
private async getOwnerWorkflowRole() {
|
||||
const ownerWorkflowRole = await Db.collections.Role.findOne({
|
||||
where: { name: 'owner', scope: 'workflow' },
|
||||
});
|
||||
|
||||
if (!ownerWorkflowRole) {
|
||||
throw new Error(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`);
|
||||
}
|
||||
|
||||
return ownerWorkflowRole;
|
||||
}
|
||||
|
||||
async cleanWorkFolder() {
|
||||
try {
|
||||
const workflowFiles = await glob('*.json', {
|
||||
cwd: this.workflowExportFolder,
|
||||
absolute: true,
|
||||
});
|
||||
const credentialFiles = await glob('*.json', {
|
||||
cwd: this.credentialExportFolder,
|
||||
absolute: true,
|
||||
});
|
||||
const variablesFile = await glob(VERSION_CONTROL_VARIABLES_EXPORT_FILE, {
|
||||
cwd: this.gitFolder,
|
||||
absolute: true,
|
||||
});
|
||||
const tagsFile = await glob(VERSION_CONTROL_TAGS_EXPORT_FILE, {
|
||||
cwd: this.gitFolder,
|
||||
absolute: true,
|
||||
});
|
||||
await Promise.all(tagsFile.map(async (e) => fsRm(e)));
|
||||
await Promise.all(variablesFile.map(async (e) => fsRm(e)));
|
||||
await Promise.all(workflowFiles.map(async (e) => fsRm(e)));
|
||||
await Promise.all(credentialFiles.map(async (e) => fsRm(e)));
|
||||
LoggerProxy.debug('Cleaned work folder.');
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to clean work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRepositoryFolder() {
|
||||
try {
|
||||
await fsRm(this.gitFolder, { recursive: true });
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to delete work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async rmDeletedWorkflowsFromExportFolder(
|
||||
workflowsToBeExported: SharedWorkflow[],
|
||||
): Promise<Set<string>> {
|
||||
const sharedWorkflowsFileNames = new Set<string>(
|
||||
workflowsToBeExported.map((e) => this.getWorkflowPath(e?.workflow?.name)),
|
||||
);
|
||||
const existingWorkflowsInFolder = new Set<string>(
|
||||
await glob('*.json', {
|
||||
cwd: this.workflowExportFolder,
|
||||
absolute: true,
|
||||
}),
|
||||
);
|
||||
const deletedWorkflows = new Set(existingWorkflowsInFolder);
|
||||
for (const elem of sharedWorkflowsFileNames) {
|
||||
deletedWorkflows.delete(elem);
|
||||
}
|
||||
try {
|
||||
await Promise.all([...deletedWorkflows].map(async (e) => fsRm(e)));
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to delete workflows from work folder: ${(error as Error).message}`);
|
||||
}
|
||||
return deletedWorkflows;
|
||||
}
|
||||
|
||||
private async writeExportableWorkflowsToExportFolder(workflowsToBeExported: SharedWorkflow[]) {
|
||||
await Promise.all(
|
||||
workflowsToBeExported.map(async (e) => {
|
||||
if (!e.workflow) {
|
||||
LoggerProxy.debug(
|
||||
`Found no corresponding workflow ${e.workflowId ?? 'unknown'}, skipping export`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fileName = this.getWorkflowPath(e.workflow?.id);
|
||||
const sanitizedWorkflow: ExportableWorkflow = {
|
||||
active: e.workflow?.active,
|
||||
id: e.workflow?.id,
|
||||
name: e.workflow?.name,
|
||||
nodes: e.workflow?.nodes,
|
||||
connections: e.workflow?.connections,
|
||||
settings: e.workflow?.settings,
|
||||
triggerCount: e.workflow?.triggerCount,
|
||||
owner: e.user.email,
|
||||
versionId: e.workflow?.versionId,
|
||||
};
|
||||
LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`);
|
||||
return fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async exportWorkflowsToWorkFolder(): Promise<ExportResult> {
|
||||
try {
|
||||
versionControlFoldersExistCheck([this.workflowExportFolder]);
|
||||
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
|
||||
relations: ['workflow', 'role', 'user'],
|
||||
where: {
|
||||
role: {
|
||||
name: 'owner',
|
||||
scope: 'workflow',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// before exporting, figure out which workflows have been deleted and remove them from the export folder
|
||||
const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows);
|
||||
// write the workflows to the export folder as json files
|
||||
await this.writeExportableWorkflowsToExportFolder(sharedWorkflows);
|
||||
return {
|
||||
count: sharedWorkflows.length,
|
||||
folder: this.workflowExportFolder,
|
||||
files: sharedWorkflows.map((e) => ({
|
||||
id: e?.workflow?.id,
|
||||
name: this.getWorkflowPath(e?.workflow?.name),
|
||||
})),
|
||||
removedFiles: [...removedFiles],
|
||||
};
|
||||
} catch (error) {
|
||||
throw Error(`Failed to export workflows to work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exportVariablesToWorkFolder(): Promise<ExportResult> {
|
||||
try {
|
||||
versionControlFoldersExistCheck([this.gitFolder]);
|
||||
const variables = await Db.collections.Variables.find();
|
||||
// do not export empty variables
|
||||
if (variables.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
folder: this.gitFolder,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
const fileName = this.getVariablesPath();
|
||||
const sanitizedVariables = variables.map((e) => ({ ...e, value: '' }));
|
||||
await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2));
|
||||
return {
|
||||
count: sanitizedVariables.length,
|
||||
folder: this.gitFolder,
|
||||
files: [
|
||||
{
|
||||
id: '',
|
||||
name: fileName,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
throw Error(`Failed to export variables to work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exportTagsToWorkFolder(): Promise<ExportResult> {
|
||||
try {
|
||||
versionControlFoldersExistCheck([this.gitFolder]);
|
||||
const tags = await Db.collections.Tag.find();
|
||||
const mappings = await Db.collections.WorkflowTagMapping.find();
|
||||
const fileName = this.getTagsPath();
|
||||
await fsWriteFile(
|
||||
fileName,
|
||||
JSON.stringify(
|
||||
{
|
||||
tags: tags.map((tag) => ({ id: tag.id, name: tag.name })),
|
||||
mappings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return {
|
||||
count: tags.length,
|
||||
folder: this.gitFolder,
|
||||
files: [
|
||||
{
|
||||
id: '',
|
||||
name: fileName,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
throw Error(`Failed to export variables to work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private replaceCredentialData = (
|
||||
data: ICredentialDataDecryptedObject,
|
||||
): ICredentialDataDecryptedObject => {
|
||||
for (const [key] of Object.entries(data)) {
|
||||
try {
|
||||
if (typeof data[key] === 'object') {
|
||||
data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject);
|
||||
} else if (typeof data[key] === 'string') {
|
||||
data[key] = (data[key] as string)?.startsWith('={{') ? data[key] : '';
|
||||
} else if (typeof data[key] === 'number') {
|
||||
// TODO: leaving numbers in for now, but maybe we should remove them
|
||||
// data[key] = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to sanitize credential data: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
async exportCredentialsToWorkFolder(): Promise<ExportResult> {
|
||||
try {
|
||||
versionControlFoldersExistCheck([this.credentialExportFolder]);
|
||||
const sharedCredentials = await Db.collections.SharedCredentials.find({
|
||||
relations: ['credentials', 'role', 'user'],
|
||||
});
|
||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||
await Promise.all(
|
||||
sharedCredentials.map(async (sharedCredential) => {
|
||||
const { name, type, nodesAccess, data, id } = sharedCredential.credentials;
|
||||
const credentialObject = new Credentials({ id, name }, type, nodesAccess, data);
|
||||
const plainData = credentialObject.getData(encryptionKey);
|
||||
const sanitizedData = this.replaceCredentialData(plainData);
|
||||
const fileName = path.join(
|
||||
this.credentialExportFolder,
|
||||
`${sharedCredential.credentials.id}.json`,
|
||||
);
|
||||
const sanitizedCredential: ExportableCredential = {
|
||||
id: sharedCredential.credentials.id,
|
||||
name: sharedCredential.credentials.name,
|
||||
type: sharedCredential.credentials.type,
|
||||
data: sanitizedData,
|
||||
};
|
||||
LoggerProxy.debug(`Writing credential ${sharedCredential.credentials.id} to ${fileName}`);
|
||||
return fsWriteFile(fileName, JSON.stringify(sanitizedCredential, null, 2));
|
||||
}),
|
||||
);
|
||||
return {
|
||||
count: sharedCredentials.length,
|
||||
folder: this.credentialExportFolder,
|
||||
files: sharedCredentials.map((e) => ({
|
||||
id: e.credentials.id,
|
||||
name: path.join(this.credentialExportFolder, `${e.credentials.name}.json`),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
throw Error(`Failed to export credentials to work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async importCredentialsFromFiles(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: string; name: string; type: string }>> {
|
||||
const credentialFiles = await glob('*.json', {
|
||||
cwd: this.credentialExportFolder,
|
||||
absolute: true,
|
||||
});
|
||||
const existingCredentials = await Db.collections.Credentials.find();
|
||||
const ownerCredentialRole = await this.getOwnerCredentialRole();
|
||||
const ownerGlobalRole = await this.getOwnerGlobalRole();
|
||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||
let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
importCredentialsResult = await Promise.all(
|
||||
credentialFiles.map(async (file) => {
|
||||
LoggerProxy.debug(`Importing credentials file ${file}`);
|
||||
const credential = jsonParse<ExportableCredential>(
|
||||
await fsReadFile(file, { encoding: 'utf8' }),
|
||||
);
|
||||
const existingCredential = existingCredentials.find(
|
||||
(e) => e.id === credential.id && e.type === credential.type,
|
||||
);
|
||||
const sharedOwner = await Db.collections.SharedCredentials.findOne({
|
||||
select: ['userId'],
|
||||
where: {
|
||||
credentialsId: credential.id,
|
||||
roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]),
|
||||
},
|
||||
});
|
||||
|
||||
const { name, type, data, id } = credential;
|
||||
const newCredentialObject = new Credentials({ id, name }, type, []);
|
||||
if (existingCredential?.data) {
|
||||
newCredentialObject.data = existingCredential.data;
|
||||
} else {
|
||||
newCredentialObject.setData(data, encryptionKey);
|
||||
}
|
||||
if (existingCredential?.nodesAccess) {
|
||||
newCredentialObject.nodesAccess = existingCredential.nodesAccess;
|
||||
}
|
||||
|
||||
LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`);
|
||||
await transactionManager.upsert(CredentialsEntity, newCredentialObject, ['id']);
|
||||
|
||||
if (!sharedOwner) {
|
||||
const newSharedCredential = new SharedCredentials();
|
||||
newSharedCredential.credentialsId = newCredentialObject.id as string;
|
||||
newSharedCredential.userId = userId;
|
||||
newSharedCredential.roleId = ownerGlobalRole.id;
|
||||
|
||||
await transactionManager.upsert(SharedCredentials, { ...newSharedCredential }, [
|
||||
'credentialsId',
|
||||
'userId',
|
||||
]);
|
||||
}
|
||||
|
||||
// TODO: once IDs are unique, remove this
|
||||
if (config.getEnv('database.type') === 'postgresdb') {
|
||||
await transactionManager.query(
|
||||
"SELECT setval('credentials_entity_id_seq', (SELECT MAX(id) from credentials_entity))",
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: newCredentialObject.id as string,
|
||||
name: newCredentialObject.name,
|
||||
type: newCredentialObject.type,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
return importCredentialsResult.filter((e) => e !== undefined);
|
||||
}
|
||||
|
||||
private async importVariablesFromFile(valueOverrides?: {
|
||||
[key: string]: string;
|
||||
}): Promise<{ added: string[]; changed: string[] }> {
|
||||
const variablesFile = await glob(VERSION_CONTROL_VARIABLES_EXPORT_FILE, {
|
||||
cwd: this.gitFolder,
|
||||
absolute: true,
|
||||
});
|
||||
if (variablesFile.length > 0) {
|
||||
LoggerProxy.debug(`Importing variables from file ${variablesFile[0]}`);
|
||||
const overriddenKeys = Object.keys(valueOverrides ?? {});
|
||||
const importedVariables = jsonParse<Variables[]>(
|
||||
await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
|
||||
{ fallbackValue: [] },
|
||||
);
|
||||
const importedKeys = importedVariables.map((variable) => variable.key);
|
||||
const existingVariables = await Db.collections.Variables.find();
|
||||
const existingKeys = existingVariables.map((variable) => variable.key);
|
||||
const addedKeysFromImport = without(importedKeys, ...existingKeys);
|
||||
const addedKeysFromOverride = without(overriddenKeys, ...existingKeys);
|
||||
const addedVariables = importedVariables.filter((e) => addedKeysFromImport.includes(e.key));
|
||||
addedKeysFromOverride.forEach((key) => {
|
||||
addedVariables.push({
|
||||
key,
|
||||
value: valueOverrides ? valueOverrides[key] : '',
|
||||
type: 'string',
|
||||
} as Variables);
|
||||
});
|
||||
|
||||
// first round, add missing variable keys to Db without touching values
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
await Promise.all(
|
||||
addedVariables.map(async (addedVariable) => {
|
||||
await transactionManager.insert(Variables, {
|
||||
...addedVariable,
|
||||
id: undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// second round, update values of existing variables if overridden
|
||||
if (valueOverrides) {
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
await Promise.all(
|
||||
overriddenKeys.map(async (key) => {
|
||||
await transactionManager.update(Variables, { key }, { value: valueOverrides[key] });
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
return {
|
||||
added: [...addedKeysFromImport, ...addedKeysFromOverride],
|
||||
changed: without(overriddenKeys, ...addedKeysFromOverride),
|
||||
};
|
||||
}
|
||||
return { added: [], changed: [] };
|
||||
}
|
||||
|
||||
private async importTagsFromFile() {
|
||||
const tagsFile = await glob(VERSION_CONTROL_TAGS_EXPORT_FILE, {
|
||||
cwd: this.gitFolder,
|
||||
absolute: true,
|
||||
});
|
||||
if (tagsFile.length > 0) {
|
||||
LoggerProxy.debug(`Importing tags from file ${tagsFile[0]}`);
|
||||
const mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
|
||||
await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
|
||||
{ fallbackValue: { tags: [], mappings: [] } },
|
||||
);
|
||||
const existingWorkflowIds = new Set(
|
||||
(
|
||||
await Db.collections.Workflow.find({
|
||||
select: ['id'],
|
||||
})
|
||||
).map((e) => e.id),
|
||||
);
|
||||
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
await Promise.all(
|
||||
mappedTags.tags.map(async (tag) => {
|
||||
await transactionManager.upsert(
|
||||
TagEntity,
|
||||
{
|
||||
...tag,
|
||||
},
|
||||
{
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
conflictPaths: { id: true },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
mappedTags.mappings.map(async (mapping) => {
|
||||
if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
|
||||
await transactionManager.upsert(
|
||||
WorkflowTagMapping,
|
||||
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
|
||||
{
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
conflictPaths: { tagId: true, workflowId: true },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
return mappedTags;
|
||||
}
|
||||
return { tags: [], mappings: [] };
|
||||
}
|
||||
|
||||
private async importWorkflowsFromFiles(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const workflowFiles = await glob('*.json', {
|
||||
cwd: this.workflowExportFolder,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const existingWorkflows = await Db.collections.Workflow.find({
|
||||
select: ['id', 'name', 'active', 'versionId'],
|
||||
});
|
||||
|
||||
const ownerWorkflowRole = await this.getOwnerWorkflowRole();
|
||||
const workflowRunner = Container.get(ActiveWorkflowRunner);
|
||||
|
||||
let importWorkflowsResult = new Array<{ id: string; name: string }>();
|
||||
// TODO: once IDs are unique and we removed autoincrement, remove this
|
||||
if (config.getEnv('database.type') === 'postgresdb') {
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
await transactionManager.query(
|
||||
'ALTER SEQUENCE IF EXISTS "workflow_entity_id_seq" RESTART;',
|
||||
);
|
||||
await transactionManager.query(
|
||||
"SELECT setval('workflow_entity_id_seq', (SELECT MAX(id) from workflow_entity) );",
|
||||
// "SELECT setval('workflow_entity_id_seq', (SELECT MAX(v) FROM (VALUES (1), ((SELECT MAX(id) from workflow_entity))) as value(v)));",
|
||||
);
|
||||
});
|
||||
}
|
||||
await Db.transaction(async (transactionManager) => {
|
||||
importWorkflowsResult = await Promise.all(
|
||||
workflowFiles.map(async (file) => {
|
||||
LoggerProxy.debug(`Parsing workflow file ${file}`);
|
||||
const importedWorkflow = jsonParse<IWorkflowToImport>(
|
||||
await fsReadFile(file, { encoding: 'utf8' }),
|
||||
);
|
||||
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
|
||||
if (existingWorkflow?.versionId === importedWorkflow.versionId) {
|
||||
LoggerProxy.debug(
|
||||
`Skipping import of workflow ${
|
||||
importedWorkflow.id ?? 'n/a'
|
||||
} - versionId is up to date`,
|
||||
);
|
||||
return {
|
||||
id: importedWorkflow.id ?? 'n/a',
|
||||
name: 'skipped',
|
||||
};
|
||||
}
|
||||
LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`);
|
||||
importedWorkflow.active = existingWorkflow?.active ?? false;
|
||||
LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
|
||||
const upsertResult = await transactionManager.upsert(
|
||||
WorkflowEntity,
|
||||
{ ...importedWorkflow },
|
||||
['id'],
|
||||
);
|
||||
if (upsertResult?.identifiers?.length !== 1) {
|
||||
throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`);
|
||||
}
|
||||
// due to sequential Ids, this may have changed during the insert
|
||||
// TODO: once IDs are unique and we removed autoincrement, remove this
|
||||
const upsertedWorkflowId = upsertResult.identifiers[0].id as string;
|
||||
await transactionManager.upsert(
|
||||
SharedWorkflow,
|
||||
{
|
||||
workflowId: upsertedWorkflowId,
|
||||
userId,
|
||||
roleId: ownerWorkflowRole.id,
|
||||
},
|
||||
['workflowId', 'userId'],
|
||||
);
|
||||
|
||||
if (existingWorkflow?.active) {
|
||||
try {
|
||||
// remove active pre-import workflow
|
||||
LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`);
|
||||
await workflowRunner.remove(existingWorkflow.id);
|
||||
// try activating the imported workflow
|
||||
LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`);
|
||||
await workflowRunner.add(existingWorkflow.id, 'activate');
|
||||
} catch (error) {
|
||||
LoggerProxy.error(
|
||||
`Failed to activate workflow ${existingWorkflow.id}`,
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: importedWorkflow.id ?? 'unknown',
|
||||
name: file,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
return importWorkflowsResult;
|
||||
}
|
||||
|
||||
async importFromWorkFolder(options: VersionControllPullOptions): Promise<ImportResult> {
|
||||
try {
|
||||
const importedVariables = await this.importVariablesFromFile(options.variables);
|
||||
const importedCredentials = await this.importCredentialsFromFiles(options.userId);
|
||||
const importWorkflows = await this.importWorkflowsFromFiles(options.userId);
|
||||
const importTags = await this.importTagsFromFile();
|
||||
|
||||
return {
|
||||
variables: importedVariables,
|
||||
credentials: importedCredentials,
|
||||
workflows: importWorkflows,
|
||||
tags: importTags,
|
||||
};
|
||||
} catch (error) {
|
||||
throw Error(`Failed to import workflows from work folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,330 @@
|
|||
import { Service } from 'typedi';
|
||||
import { execSync } from 'child_process';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
import type {
|
||||
CommitResult,
|
||||
DiffResult,
|
||||
FetchResult,
|
||||
PullResult,
|
||||
PushResult,
|
||||
SimpleGit,
|
||||
SimpleGitOptions,
|
||||
StatusResult,
|
||||
} from 'simple-git';
|
||||
import { simpleGit } from 'simple-git';
|
||||
import type { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import { VERSION_CONTROL_DEFAULT_BRANCH, VERSION_CONTROL_ORIGIN } from './constants';
|
||||
import { versionControlFoldersExistCheck } from './versionControlHelper.ee';
|
||||
|
||||
@Service()
|
||||
export class VersionControlGitService {
|
||||
git: SimpleGit | null = null;
|
||||
|
||||
private gitOptions: Partial<SimpleGitOptions> = {};
|
||||
|
||||
/**
|
||||
* Run pre-checks before initialising git
|
||||
* Checks for existence of required binaries (git and ssh)
|
||||
*/
|
||||
preInitCheck(): boolean {
|
||||
LoggerProxy.debug('GitService.preCheck');
|
||||
try {
|
||||
const gitResult = execSync('git --version', {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
LoggerProxy.debug(`Git binary found: ${gitResult.toString()}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Git binary not found: ${(error as Error).message}`);
|
||||
}
|
||||
try {
|
||||
const sshResult = execSync('ssh -V', {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
LoggerProxy.debug(`SSH binary found: ${sshResult.toString()}`);
|
||||
} catch (error) {
|
||||
throw new Error(`SSH binary not found: ${(error as Error).message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async initService(options: {
|
||||
versionControlPreferences: VersionControlPreferences;
|
||||
gitFolder: string;
|
||||
sshFolder: string;
|
||||
sshKeyName: string;
|
||||
}): Promise<void> {
|
||||
const { versionControlPreferences, gitFolder, sshKeyName, sshFolder } = options;
|
||||
LoggerProxy.debug('GitService.init');
|
||||
if (this.git !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.preInitCheck();
|
||||
LoggerProxy.debug('Git pre-check passed');
|
||||
|
||||
versionControlFoldersExistCheck([gitFolder, sshFolder]);
|
||||
|
||||
const sshKnownHosts = path.join(sshFolder, 'known_hosts');
|
||||
const sshCommand = `ssh -o UserKnownHostsFile=${sshKnownHosts} -o StrictHostKeyChecking=no -i ${sshKeyName}`;
|
||||
|
||||
this.gitOptions = {
|
||||
baseDir: gitFolder,
|
||||
binary: 'git',
|
||||
maxConcurrentProcesses: 6,
|
||||
trimmed: false,
|
||||
};
|
||||
|
||||
this.git = simpleGit(this.gitOptions)
|
||||
// Tell git not to ask for any information via the terminal like for
|
||||
// example the username. As nobody will be able to answer it would
|
||||
// n8n keep on waiting forever.
|
||||
.env('GIT_SSH_COMMAND', sshCommand)
|
||||
.env('GIT_TERMINAL_PROMPT', '0');
|
||||
|
||||
if (!(await this.checkRepositorySetup())) {
|
||||
await this.git.init();
|
||||
}
|
||||
if (!(await this.hasRemote(versionControlPreferences.repositoryUrl))) {
|
||||
if (versionControlPreferences.connected && versionControlPreferences.repositoryUrl) {
|
||||
await this.initRepository(versionControlPreferences);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetService() {
|
||||
this.git = null;
|
||||
}
|
||||
|
||||
resetLocalRepository() {
|
||||
// TODO: Implement
|
||||
this.git = null;
|
||||
}
|
||||
|
||||
async checkRepositorySetup(): Promise<boolean> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (!(await this.git.checkIsRepo())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.git.status();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hasRemote(remote: string): Promise<boolean> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
try {
|
||||
const remotes = await this.git.getRemotes(true);
|
||||
const foundRemote = remotes.find(
|
||||
(e) => e.name === VERSION_CONTROL_ORIGIN && e.refs.push === remote,
|
||||
);
|
||||
if (foundRemote) {
|
||||
LoggerProxy.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Git is not initialized ${(error as Error).message}`);
|
||||
}
|
||||
LoggerProxy.debug(`Git remote not found: ${remote}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async initRepository(
|
||||
versionControlPreferences: Pick<
|
||||
VersionControlPreferences,
|
||||
'repositoryUrl' | 'authorEmail' | 'authorName' | 'branchName' | 'initRepo'
|
||||
>,
|
||||
): Promise<void> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (versionControlPreferences.initRepo) {
|
||||
try {
|
||||
await this.git.init();
|
||||
} catch (error) {
|
||||
LoggerProxy.debug(`Git init: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.git.addRemote(VERSION_CONTROL_ORIGIN, versionControlPreferences.repositoryUrl);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('remote origin already exists')) {
|
||||
LoggerProxy.debug(`Git remote already exists: ${(error as Error).message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.git.addConfig('user.email', versionControlPreferences.authorEmail);
|
||||
await this.git.addConfig('user.name', versionControlPreferences.authorName);
|
||||
if (versionControlPreferences.initRepo) {
|
||||
try {
|
||||
const branches = await this.getBranches();
|
||||
if (branches.branches?.length === 0) {
|
||||
await this.git.raw(['branch', '-M', versionControlPreferences.branchName]);
|
||||
}
|
||||
} catch (error) {
|
||||
LoggerProxy.debug(`Git init: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBranches(): Promise<{ branches: string[]; currentBranch: string }> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get remote branches
|
||||
const { branches } = await this.git.branch(['-r']);
|
||||
const remoteBranches = Object.keys(branches)
|
||||
.map((name) => name.split('/')[1])
|
||||
.filter((name) => name !== 'HEAD');
|
||||
|
||||
const { current } = await this.git.branch();
|
||||
|
||||
return {
|
||||
branches: remoteBranches,
|
||||
currentBranch: current,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Could not get remote branches from repository ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
await this.git.checkout(branch);
|
||||
return this.getBranches();
|
||||
}
|
||||
|
||||
async fetch(): Promise<FetchResult> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
return this.git.fetch();
|
||||
}
|
||||
|
||||
async getCurrentBranch(): Promise<{ current: string; remote: string }> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
const currentBranch = (await this.git.branch()).current;
|
||||
return {
|
||||
current: currentBranch,
|
||||
remote: 'origin/' + currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
async diff(options?: { target?: string; dots?: '..' | '...' }): Promise<DiffResult> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
const currentBranch = await this.getCurrentBranch();
|
||||
const target = options?.target ?? currentBranch.remote;
|
||||
const dots = options?.dots ?? '...';
|
||||
return this.git.diffSummary([dots + target]);
|
||||
}
|
||||
|
||||
async diffRemote(): Promise<DiffResult | undefined> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
const currentBranch = await this.getCurrentBranch();
|
||||
if (currentBranch.remote) {
|
||||
const target = currentBranch.remote;
|
||||
return this.git.diffSummary(['...' + target]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async diffLocal(): Promise<DiffResult | undefined> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
const currentBranch = await this.getCurrentBranch();
|
||||
if (currentBranch.remote) {
|
||||
const target = currentBranch.current;
|
||||
return this.git.diffSummary([target]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (options.ffOnly) {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
return this.git.pull(undefined, undefined, { '--ff-only': null });
|
||||
}
|
||||
return this.git.pull();
|
||||
}
|
||||
|
||||
async push(
|
||||
options: { force: boolean; branch: string } = {
|
||||
force: false,
|
||||
branch: VERSION_CONTROL_DEFAULT_BRANCH,
|
||||
},
|
||||
): Promise<PushResult> {
|
||||
const { force, branch } = options;
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (force) {
|
||||
return this.git.push(VERSION_CONTROL_ORIGIN, branch, ['-f']);
|
||||
}
|
||||
return this.git.push(VERSION_CONTROL_ORIGIN, branch);
|
||||
}
|
||||
|
||||
async stage(files: Set<string>, deletedFiles?: Set<string>): Promise<string> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (deletedFiles?.size) {
|
||||
try {
|
||||
await this.git.rm(Array.from(deletedFiles));
|
||||
} catch (error) {
|
||||
LoggerProxy.debug(`Git rm: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.git.add(Array.from(files));
|
||||
}
|
||||
|
||||
async resetBranch(
|
||||
options: { hard?: boolean; target: string } = { hard: false, target: 'HEAD' },
|
||||
): Promise<string> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
if (options?.hard) {
|
||||
return this.git.raw(['reset', '--hard', options.target]);
|
||||
}
|
||||
return this.git.raw(['reset', options.target]);
|
||||
// built-in reset method does not work
|
||||
// return this.git.reset();
|
||||
}
|
||||
|
||||
async commit(message: string): Promise<CommitResult> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
return this.git.commit(message);
|
||||
}
|
||||
|
||||
async status(): Promise<StatusResult> {
|
||||
if (!this.git) {
|
||||
throw new Error('Git is not initialized');
|
||||
}
|
||||
const statusResult = await this.git.status();
|
||||
return statusResult;
|
||||
}
|
||||
}
|
|
@ -3,21 +3,30 @@ import { License } from '../../License';
|
|||
import { generateKeyPairSync } from 'crypto';
|
||||
import sshpk from 'sshpk';
|
||||
import type { KeyPair } from './types/keyPair';
|
||||
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import { VERSION_CONTROL_GIT_KEY_COMMENT } from './constants';
|
||||
|
||||
export function versionControlFoldersExistCheck(folders: string[]) {
|
||||
// running these file access function synchronously to avoid race conditions
|
||||
folders.forEach((folder) => {
|
||||
try {
|
||||
accessSync(folder, fsConstants.F_OK);
|
||||
} catch {
|
||||
try {
|
||||
mkdirSync(folder);
|
||||
} catch (error) {
|
||||
LoggerProxy.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isVersionControlLicensed() {
|
||||
const license = Container.get(License);
|
||||
return license.isVersionControlLicensed();
|
||||
}
|
||||
|
||||
export function isVersionControlEnabled() {
|
||||
// TODO: VERSION CONTROL check if enabled
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isVersionControlLicensedAndEnabled() {
|
||||
return isVersionControlLicensed() && isVersionControlEnabled();
|
||||
}
|
||||
|
||||
export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
|
||||
const keyPair: KeyPair = {
|
||||
publicKey: '',
|
||||
|
@ -46,8 +55,10 @@ export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
|
|||
break;
|
||||
}
|
||||
const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem');
|
||||
keyPublic.comment = VERSION_CONTROL_GIT_KEY_COMMENT;
|
||||
keyPair.publicKey = keyPublic.toString('ssh');
|
||||
const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem');
|
||||
keyPrivate.comment = VERSION_CONTROL_GIT_KEY_COMMENT;
|
||||
keyPair.privateKey = keyPrivate.toString('ssh-private');
|
||||
return {
|
||||
privateKey: keyPair.privateKey,
|
|
@ -0,0 +1,191 @@
|
|||
import { Service } from 'typedi';
|
||||
import { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { validate } from 'class-validator';
|
||||
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
|
||||
import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises';
|
||||
import {
|
||||
generateSshKeyPair,
|
||||
isVersionControlLicensed,
|
||||
versionControlFoldersExistCheck,
|
||||
} from './versionControlHelper.ee';
|
||||
import { UserSettings } from 'n8n-core';
|
||||
import { LoggerProxy, jsonParse } from 'n8n-workflow';
|
||||
import * as Db from '@/Db';
|
||||
import {
|
||||
VERSION_CONTROL_SSH_FOLDER,
|
||||
VERSION_CONTROL_GIT_FOLDER,
|
||||
VERSION_CONTROL_SSH_KEY_NAME,
|
||||
VERSION_CONTROL_PREFERENCES_DB_KEY,
|
||||
} from './constants';
|
||||
import path from 'path';
|
||||
|
||||
@Service()
|
||||
export class VersionControlPreferencesService {
|
||||
private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences();
|
||||
|
||||
private sshKeyName: string;
|
||||
|
||||
private sshFolder: string;
|
||||
|
||||
private gitFolder: string;
|
||||
|
||||
constructor() {
|
||||
const userFolder = UserSettings.getUserN8nFolderPath();
|
||||
this.sshFolder = path.join(userFolder, VERSION_CONTROL_SSH_FOLDER);
|
||||
this.gitFolder = path.join(userFolder, VERSION_CONTROL_GIT_FOLDER);
|
||||
this.sshKeyName = path.join(this.sshFolder, VERSION_CONTROL_SSH_KEY_NAME);
|
||||
}
|
||||
|
||||
public get versionControlPreferences(): VersionControlPreferences {
|
||||
return {
|
||||
...this._versionControlPreferences,
|
||||
connected: this._versionControlPreferences.connected ?? false,
|
||||
publicKey: this.getPublicKey(),
|
||||
};
|
||||
}
|
||||
|
||||
public set versionControlPreferences(preferences: Partial<VersionControlPreferences>) {
|
||||
this._versionControlPreferences = VersionControlPreferences.merge(
|
||||
preferences,
|
||||
this._versionControlPreferences,
|
||||
);
|
||||
}
|
||||
|
||||
getPublicKey(): string {
|
||||
try {
|
||||
return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to read public key: ${(error as Error).message}`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
hasKeyPairFiles(): boolean {
|
||||
return fsExistsSync(this.sshKeyName) && fsExistsSync(this.sshKeyName + '.pub');
|
||||
}
|
||||
|
||||
async deleteKeyPairFiles(): Promise<void> {
|
||||
try {
|
||||
await fsRm(this.sshFolder, { recursive: true });
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`Failed to delete ssh folder: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will generate an ed25519 key pair and save it to the database and the file system
|
||||
* Note: this will overwrite any existing key pair
|
||||
*/
|
||||
async generateAndSaveKeyPair(): Promise<VersionControlPreferences> {
|
||||
versionControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
||||
const keyPair = generateSshKeyPair('ed25519');
|
||||
if (keyPair.publicKey && keyPair.privateKey) {
|
||||
try {
|
||||
await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o666,
|
||||
});
|
||||
await fsWriteFile(this.sshKeyName, keyPair.privateKey, { encoding: 'utf8', mode: 0o600 });
|
||||
} catch (error) {
|
||||
throw Error(`Failed to save key pair: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.getPreferences();
|
||||
}
|
||||
|
||||
isBranchReadOnly(): boolean {
|
||||
return this._versionControlPreferences.branchReadOnly;
|
||||
}
|
||||
|
||||
isVersionControlConnected(): boolean {
|
||||
return this.versionControlPreferences.connected;
|
||||
}
|
||||
|
||||
isVersionControlLicensedAndEnabled(): boolean {
|
||||
return this.isVersionControlConnected() && isVersionControlLicensed();
|
||||
}
|
||||
|
||||
getBranchName(): string {
|
||||
return this.versionControlPreferences.branchName;
|
||||
}
|
||||
|
||||
getPreferences(): VersionControlPreferences {
|
||||
return this.versionControlPreferences;
|
||||
}
|
||||
|
||||
setBranchReadOnly(branchReadOnly: boolean): void {
|
||||
this._versionControlPreferences.branchReadOnly = branchReadOnly;
|
||||
}
|
||||
|
||||
async validateVersionControlPreferences(
|
||||
preferences: Partial<VersionControlPreferences>,
|
||||
allowMissingProperties = true,
|
||||
): Promise<ValidationError[]> {
|
||||
if (this.isVersionControlConnected()) {
|
||||
if (preferences.repositoryUrl !== this._versionControlPreferences.repositoryUrl) {
|
||||
throw new Error('Cannot change repository while connected');
|
||||
}
|
||||
}
|
||||
const preferencesObject = new VersionControlPreferences(preferences);
|
||||
const validationResult = await validate(preferencesObject, {
|
||||
forbidUnknownValues: false,
|
||||
skipMissingProperties: allowMissingProperties,
|
||||
stopAtFirstError: false,
|
||||
validationError: { target: false },
|
||||
});
|
||||
if (validationResult.length > 0) {
|
||||
throw new Error(`Invalid version control preferences: ${JSON.stringify(validationResult)}`);
|
||||
}
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
async setPreferences(
|
||||
preferences: Partial<VersionControlPreferences>,
|
||||
saveToDb = true,
|
||||
): Promise<VersionControlPreferences> {
|
||||
versionControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
||||
if (!this.hasKeyPairFiles()) {
|
||||
LoggerProxy.debug('No key pair files found, generating new pair');
|
||||
await this.generateAndSaveKeyPair();
|
||||
}
|
||||
this.versionControlPreferences = preferences;
|
||||
if (saveToDb) {
|
||||
const settingsValue = JSON.stringify(this._versionControlPreferences);
|
||||
try {
|
||||
await Db.collections.Settings.save({
|
||||
key: VERSION_CONTROL_PREFERENCES_DB_KEY,
|
||||
value: settingsValue,
|
||||
loadOnStartup: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save version control preferences: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.versionControlPreferences;
|
||||
}
|
||||
|
||||
async loadFromDbAndApplyVersionControlPreferences(): Promise<
|
||||
VersionControlPreferences | undefined
|
||||
> {
|
||||
const loadedPreferences = await Db.collections.Settings.findOne({
|
||||
where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY },
|
||||
});
|
||||
if (loadedPreferences) {
|
||||
try {
|
||||
const preferences = jsonParse<VersionControlPreferences>(loadedPreferences.value);
|
||||
if (preferences) {
|
||||
// set local preferences but don't write back to db
|
||||
await this.setPreferences(preferences, false);
|
||||
return preferences;
|
||||
}
|
||||
} catch (error) {
|
||||
LoggerProxy.warn(
|
||||
`Could not parse Version Control settings from database: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.setPreferences(new VersionControlPreferences(), true);
|
||||
return this.versionControlPreferences;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import type { User } from '@db/entities/User';
|
|||
import { License } from '@/License';
|
||||
import * as testDb from '../shared/testDb';
|
||||
import * as utils from '../shared/utils';
|
||||
import { VersionControlService } from '../../../src/environments/versionControl/versionControl.service.ee';
|
||||
import { VERSION_CONTROL_API_ROOT } from '@/environments/versionControl/constants';
|
||||
|
||||
let owner: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
|
@ -22,17 +22,11 @@ afterAll(async () => {
|
|||
|
||||
describe('GET /versionControl/preferences', () => {
|
||||
test('should return Version Control preferences', async () => {
|
||||
await Container.get(VersionControlService).generateAndSaveKeyPair();
|
||||
await authOwnerAgent
|
||||
.get('/versionControl/preferences')
|
||||
.get(`/${VERSION_CONTROL_API_ROOT}/preferences`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
return (
|
||||
'privateKey' in res.body &&
|
||||
'publicKey' in res.body &&
|
||||
res.body.publicKey.includes('ssh-ed25519') &&
|
||||
res.body.privateKey.includes('BEGIN OPENSSH PRIVATE KEY')
|
||||
);
|
||||
return 'repositoryUrl' in res.body && 'branchName' in res.body;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -81,6 +81,7 @@ import { EventBusController } from '@/eventbus/eventBus.controller';
|
|||
import { License } from '@/License';
|
||||
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
|
||||
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
|
||||
import { VersionControlPreferencesService } from '@/environments/versionControl/versionControlPreferences.service.ee';
|
||||
|
||||
export const mockInstance = <T>(
|
||||
ctor: new (...args: any[]) => T,
|
||||
|
@ -203,10 +204,11 @@ export async function initTestServer({
|
|||
break;
|
||||
case 'versionControl':
|
||||
const versionControlService = Container.get(VersionControlService);
|
||||
const versionControlPreferencesService = Container.get(VersionControlPreferencesService);
|
||||
registerController(
|
||||
testServer.app,
|
||||
config,
|
||||
new VersionControlController(versionControlService),
|
||||
new VersionControlController(versionControlService, versionControlPreferencesService),
|
||||
);
|
||||
break;
|
||||
case 'nodes':
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper';
|
||||
import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper.ee';
|
||||
|
||||
describe('Version Control', () => {
|
||||
it('should generate an SSH key pair', () => {
|
||||
|
|
|
@ -98,7 +98,7 @@ export default defineComponent({
|
|||
`${this.disabled ? ` ${this.$style.disabled}` : ''}` +
|
||||
`${this.block ? ` ${this.$style.block}` : ''}` +
|
||||
`${this.active ? ` ${this.$style.active}` : ''}` +
|
||||
`${this.icon || this.loading ? ` ${this.$style.icon}` : ''}` +
|
||||
`${this.icon || this.loading ? ` ${this.$style.withIcon}` : ''}` +
|
||||
`${this.square ? ` ${this.$style.square}` : ''}`
|
||||
);
|
||||
},
|
||||
|
@ -464,6 +464,12 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0);
|
|||
--button-active-background-color: transparent;
|
||||
}
|
||||
|
||||
.withIcon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`components > N8nButton > overrides > should render as \`secondary\` when \`text\` is given as type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button secondary medium icon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > overrides > should render as \`secondary\` when \`text\` is given as type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button secondary medium withIcon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > overrides > should render as \`tertiary\` when \`info\` is given as type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button tertiary medium icon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > overrides > should render as \`tertiary\` when \`info\` is given as type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button tertiary medium withIcon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > overrides > should use default (\`primary\`) type when no type is given 1`] = `"<button aria-live=\\"polite\\" class=\\"button button primary medium icon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > overrides > should use default (\`primary\`) type when no type is given 1`] = `"<button aria-live=\\"polite\\" class=\\"button button primary medium withIcon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > overrides > should use given (\`secondary\`) type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button secondary medium icon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > overrides > should use given (\`secondary\`) type 1`] = `"<button aria-live=\\"polite\\" class=\\"button button secondary medium withIcon\\" icon=\\"plus-circle\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > props > icon > should render icon button 1`] = `"<button aria-live=\\"polite\\" class=\\"button button primary medium icon\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > props > icon > should render icon button 1`] = `"<button aria-live=\\"polite\\" class=\\"button button primary medium withIcon\\"><span class=\\"icon\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `"<button disabled=\\"disabled\\" aria-busy=\\"true\\" aria-live=\\"polite\\" class=\\"button button primary medium loading icon\\"><span class=\\"icon\\"><n8n-spinner-stub size=\\"medium\\" type=\\"dots\\"></n8n-spinner-stub></span><span>Button</span></button>"`;
|
||||
exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `"<button disabled=\\"disabled\\" aria-busy=\\"true\\" aria-live=\\"polite\\" class=\\"button button primary medium loading withIcon\\"><span class=\\"icon\\"><n8n-spinner-stub size=\\"medium\\" type=\\"dots\\"></n8n-spinner-stub></span><span>Button</span></button>"`;
|
||||
|
||||
exports[`components > N8nButton > props > square > should render square button 1`] = `
|
||||
"<button aria-live=\\"polite\\" class=\\"button button primary medium square\\">
|
||||
|
|
|
@ -38,18 +38,20 @@ import { CLOUD_TRIAL_CHECK_INTERVAL, HIRING_BANNER, LOCAL_STORAGE_THEME, VIEWS }
|
|||
import { userHelpers } from '@/mixins/userHelpers';
|
||||
import { loadLanguage } from '@/plugins/i18n';
|
||||
import { useGlobalLinkActions, useToast } from '@/composables';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCloudPlanStore } from './stores/cloudPlan.store';
|
||||
import {
|
||||
useUIStore,
|
||||
useSettingsStore,
|
||||
useUsersStore,
|
||||
useRootStore,
|
||||
useTemplatesStore,
|
||||
useNodeTypesStore,
|
||||
useCloudPlanStore,
|
||||
useVersionControlStore,
|
||||
useUsageStore,
|
||||
} from '@/stores';
|
||||
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||
import { newVersions } from '@/mixins/newVersions';
|
||||
import { useRoute } from 'vue-router/composables';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { useExternalHooks } from '@/composables';
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -220,6 +222,13 @@ export default defineComponent({
|
|||
void this.checkForNewVersions();
|
||||
void this.checkForCloudPlanData();
|
||||
|
||||
if (
|
||||
this.versionControlStore.isEnterpriseVersionControlEnabled &&
|
||||
this.usersStore.isInstanceOwner
|
||||
) {
|
||||
await this.versionControlStore.getPreferences();
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.trackPage();
|
||||
|
@ -228,13 +237,6 @@ export default defineComponent({
|
|||
if (this.defaultLocale !== 'en') {
|
||||
await this.nodeTypesStore.getNodeTranslationHeaders();
|
||||
}
|
||||
|
||||
if (
|
||||
this.versionControlStore.isEnterpriseVersionControlEnabled &&
|
||||
this.usersStore.isInstanceOwner
|
||||
) {
|
||||
void this.versionControlStore.getPreferences();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(route) {
|
||||
|
|
|
@ -1447,13 +1447,44 @@ export type VersionControlPreferences = {
|
|||
repositoryUrl: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
currentBranch: string;
|
||||
branchName: string;
|
||||
branches: string[];
|
||||
branchReadOnly: boolean;
|
||||
branchColor: string;
|
||||
publicKey?: string;
|
||||
currentBranch?: string;
|
||||
};
|
||||
|
||||
export interface VersionControlStatus {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
conflicted: string[];
|
||||
created: string[];
|
||||
current: string;
|
||||
deleted: string[];
|
||||
detached: boolean;
|
||||
files: Array<{
|
||||
path: string;
|
||||
index: string;
|
||||
working_dir: string;
|
||||
}>;
|
||||
modified: string[];
|
||||
not_added: string[];
|
||||
renamed: string[];
|
||||
staged: string[];
|
||||
tracking: null;
|
||||
}
|
||||
|
||||
export interface VersionControlAggregatedFile {
|
||||
conflict: boolean;
|
||||
file: string;
|
||||
id: string;
|
||||
location: string;
|
||||
name: string;
|
||||
status: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export declare namespace Cloud {
|
||||
export interface PlanData {
|
||||
planId: number;
|
||||
|
|
|
@ -1,27 +1,39 @@
|
|||
import type { IRestApiContext, VersionControlPreferences } from '@/Interface';
|
||||
import type {
|
||||
IRestApiContext,
|
||||
VersionControlAggregatedFile,
|
||||
VersionControlPreferences,
|
||||
VersionControlStatus,
|
||||
} from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
const versionControlApiRoot = '/version-control';
|
||||
|
||||
export const initSsh = async (context: IRestApiContext, data: IDataObject): Promise<string> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/init-ssh`, data);
|
||||
export const pushWorkfolder = async (
|
||||
context: IRestApiContext,
|
||||
data: IDataObject,
|
||||
): Promise<void> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/push-workfolder`, data);
|
||||
};
|
||||
|
||||
export const initRepository = async (
|
||||
export const pullWorkfolder = async (
|
||||
context: IRestApiContext,
|
||||
data: IDataObject,
|
||||
): Promise<void> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/pull-workfolder`, data);
|
||||
};
|
||||
|
||||
export const getBranches = async (
|
||||
context: IRestApiContext,
|
||||
): Promise<{ branches: string[]; currentBranch: string }> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/init-repository`);
|
||||
return makeRestApiRequest(context, 'GET', `${versionControlApiRoot}/get-branches`);
|
||||
};
|
||||
|
||||
export const sync = async (context: IRestApiContext, data: IDataObject): Promise<void> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/push`, data);
|
||||
};
|
||||
|
||||
export const getConfig = async (
|
||||
export const setBranch = async (
|
||||
context: IRestApiContext,
|
||||
): Promise<{ remoteRepository: string; name: string; email: string; currentBranch: string }> => {
|
||||
return makeRestApiRequest(context, 'GET', `${versionControlApiRoot}/config`);
|
||||
branch: string,
|
||||
): Promise<{ branches: string[]; currentBranch: string }> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/set-branch`, { branch });
|
||||
};
|
||||
|
||||
export const setPreferences = async (
|
||||
|
@ -36,3 +48,35 @@ export const getPreferences = async (
|
|||
): Promise<VersionControlPreferences> => {
|
||||
return makeRestApiRequest(context, 'GET', `${versionControlApiRoot}/preferences`);
|
||||
};
|
||||
|
||||
export const getStatus = async (context: IRestApiContext): Promise<VersionControlStatus> => {
|
||||
return makeRestApiRequest(context, 'GET', `${versionControlApiRoot}/status`);
|
||||
};
|
||||
|
||||
export const getAggregatedStatus = async (
|
||||
context: IRestApiContext,
|
||||
): Promise<VersionControlAggregatedFile[]> => {
|
||||
return makeRestApiRequest(context, 'GET', `${versionControlApiRoot}/get-status`);
|
||||
};
|
||||
|
||||
export const disconnect = async (
|
||||
context: IRestApiContext,
|
||||
keepKeyPair: boolean,
|
||||
): Promise<string> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/disconnect`, {
|
||||
keepKeyPair,
|
||||
});
|
||||
};
|
||||
|
||||
export const setBranchReadonly = async (
|
||||
context: IRestApiContext,
|
||||
branchReadOnly: boolean,
|
||||
): Promise<string> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/set-read-only`, {
|
||||
branchReadOnly,
|
||||
});
|
||||
};
|
||||
|
||||
export const generateKeyPair = async (context: IRestApiContext): Promise<string> => {
|
||||
return makeRestApiRequest(context, 'POST', `${versionControlApiRoot}/generate-key-pair`);
|
||||
};
|
||||
|
|
|
@ -108,7 +108,7 @@ export default defineComponent({
|
|||
|
||||
.medium {
|
||||
span {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<span @keydown.stop class="inline-edit">
|
||||
<span v-if="isEditEnabled">
|
||||
<span v-if="isEditEnabled && !disabled">
|
||||
<ExpandableInputEdit
|
||||
:placeholder="placeholder"
|
||||
:value="newValue"
|
||||
|
@ -29,12 +29,36 @@ import { createEventBus } from 'n8n-design-system';
|
|||
export default defineComponent({
|
||||
name: 'InlineTextEdit',
|
||||
components: { ExpandableInputEdit, ExpandableInputPreview },
|
||||
props: ['isEditEnabled', 'value', 'placeholder', 'maxLength', 'previewValue'],
|
||||
props: {
|
||||
isEditEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
previewValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newValue: '',
|
||||
escPressed: false,
|
||||
disabled: false,
|
||||
inputBus: createEventBus(),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div>
|
||||
<div :class="{ 'main-header': true, expanded: !this.uiStore.sidebarMenuCollapsed }">
|
||||
<div v-show="!hideMenuBar" class="top-menu">
|
||||
<WorkflowDetails />
|
||||
<WorkflowDetails :readOnly="readOnly" />
|
||||
<tab-bar
|
||||
v-if="onWorkflowPage"
|
||||
:items="tabBarItems"
|
||||
|
@ -18,6 +18,7 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import type { Route } from 'vue-router';
|
||||
import { mapStores } from 'pinia';
|
||||
import type { IExecutionsSummary } from 'n8n-workflow';
|
||||
import { pushConnection } from '@/mixins/pushConnection';
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
|
@ -27,10 +28,9 @@ import {
|
|||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import type { IExecutionsSummary, INodeUi, ITabBarItem } from '@/Interface';
|
||||
import type { INodeUi, ITabBarItem } from '@/Interface';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useUIStore, useNDVStore, useVersionControlStore } from '@/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainHeader',
|
||||
|
@ -53,7 +53,7 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore, useUIStore),
|
||||
...mapStores(useNDVStore, useUIStore, useVersionControlStore),
|
||||
tabBarItems(): ITabBarItem[] {
|
||||
return [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.editor') },
|
||||
|
@ -81,6 +81,9 @@ export default defineComponent({
|
|||
activeExecution(): IExecutionsSummary {
|
||||
return this.workflowsStore.activeWorkflowExecution as IExecutionsSummary;
|
||||
},
|
||||
readOnly(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.dirtyState = this.uiStore.stateIsDirty;
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
:previewValue="shortenedName"
|
||||
:isEditEnabled="isNameEditEnabled"
|
||||
:maxLength="MAX_WORKFLOW_NAME_LENGTH"
|
||||
:disabled="readOnly"
|
||||
@toggle="onNameToggle"
|
||||
@submit="onNameSubmit"
|
||||
placeholder="Enter workflow name"
|
||||
|
@ -25,7 +26,7 @@
|
|||
</BreakpointsObserver>
|
||||
|
||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||
<div v-if="isTagsEditEnabled">
|
||||
<div v-if="isTagsEditEnabled && !readOnly">
|
||||
<TagsDropdown
|
||||
:createEnabled="true"
|
||||
:currentTagIds="appliedTagIds"
|
||||
|
@ -39,7 +40,7 @@
|
|||
data-test-id="workflow-tags-dropdown"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="currentWorkflowTagIds.length === 0">
|
||||
<div v-else-if="currentWorkflowTagIds.length === 0 && !readOnly">
|
||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
|
@ -99,7 +100,7 @@
|
|||
<SaveButton
|
||||
type="primary"
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
:disabled="isWorkflowSaving"
|
||||
:disabled="isWorkflowSaving || readOnly"
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
/>
|
||||
|
@ -152,15 +153,17 @@ import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '
|
|||
import { saveAs } from 'file-saver';
|
||||
import { useTitleChange, useToast, useMessage } from '@/composables';
|
||||
import type { MessageBoxInputData } from 'element-ui/types/message-box';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import {
|
||||
useUIStore,
|
||||
useSettingsStore,
|
||||
useWorkflowsStore,
|
||||
useRootStore,
|
||||
useTagsStore,
|
||||
useUsersStore,
|
||||
useUsageStore,
|
||||
} from '@/stores';
|
||||
import type { IPermissions } from '@/permissions';
|
||||
import { getWorkflowPermissions } from '@/permissions';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { useCloudPlanStore } from '@/stores';
|
||||
|
||||
|
@ -186,6 +189,12 @@ export default defineComponent({
|
|||
InlineTextEdit,
|
||||
BreakpointsObserver,
|
||||
},
|
||||
props: {
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
...useTitleChange(),
|
||||
|
@ -266,44 +275,52 @@ export default defineComponent({
|
|||
return getWorkflowPermissions(this.usersStore.currentUser, this.workflow);
|
||||
},
|
||||
workflowMenuItems(): Array<{}> {
|
||||
return [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: this.$locale.baseText('menuActions.duplicate'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
|
||||
},
|
||||
const actions = [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
||||
label: this.$locale.baseText('menuActions.download'),
|
||||
disabled: !this.onWorkflowPage,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: this.$locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: this.$locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: this.$locale.baseText('generic.settings'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
},
|
||||
...(this.workflowPermissions.delete
|
||||
? [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: this.$locale.baseText('menuActions.delete'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
customClass: this.$style.deleteItem,
|
||||
divided: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (!this.readOnly) {
|
||||
actions.unshift({
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: this.$locale.baseText('menuActions.duplicate'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
|
||||
});
|
||||
|
||||
actions.push(
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: this.$locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: this.$locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: this.$locale.baseText('generic.settings'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
});
|
||||
|
||||
if (this.workflowPermissions.delete && !this.readOnly) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: this.$locale.baseText('menuActions.delete'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
customClass: this.$style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
v-if="!isCollapsed && userIsTrialing"
|
||||
/></template>
|
||||
<template #menuSuffix>
|
||||
<div v-if="hasVersionUpdates || versionControlStore.state.currentBranch">
|
||||
<div v-if="hasVersionUpdates || versionControlStore.preferences.connected">
|
||||
<div v-if="hasVersionUpdates" :class="$style.updates" @click="openUpdatesPanel">
|
||||
<div :class="$style.giftContainer">
|
||||
<GiftNotificationIcon />
|
||||
|
@ -46,24 +46,10 @@
|
|||
}}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.sync" v-if="versionControlStore.state.currentBranch">
|
||||
<span>
|
||||
<n8n-icon icon="code-branch" class="mr-xs" />
|
||||
{{ currentBranch }}
|
||||
</span>
|
||||
<n8n-button
|
||||
:title="
|
||||
$locale.baseText('settings.versionControl.sync.prompt.title', {
|
||||
interpolate: { branch: currentBranch },
|
||||
})
|
||||
"
|
||||
icon="sync"
|
||||
type="tertiary"
|
||||
:size="isCollapsed ? 'mini' : 'small'"
|
||||
square
|
||||
@click="sync"
|
||||
/>
|
||||
</div>
|
||||
<MainSidebarVersionControl
|
||||
v-if="versionControlStore.preferences.connected"
|
||||
:is-collapsed="isCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer v-if="showUserArea">
|
||||
|
@ -117,7 +103,6 @@
|
|||
|
||||
<script lang="ts">
|
||||
import type { CloudPlanAndUsageData, IExecutionResponse, IMenuItem, IVersion } from '@/Interface';
|
||||
import type { MessageBoxInputData } from 'element-ui/types/message-box';
|
||||
import GiftNotificationIcon from './GiftNotificationIcon.vue';
|
||||
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
|
@ -138,14 +123,16 @@ import { useRootStore } from '@/stores/n8nRoot.store';
|
|||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { isNavigationFailure } from 'vue-router';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import MainSidebarVersionControl from '@/components/MainSidebarVersionControl.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainSidebar',
|
||||
components: {
|
||||
GiftNotificationIcon,
|
||||
ExecutionsUsage,
|
||||
MainSidebarVersionControl,
|
||||
},
|
||||
mixins: [genericHelpers, workflowHelpers, workflowRun, userHelpers, debounceHelper],
|
||||
setup(props) {
|
||||
|
@ -171,9 +158,6 @@ export default defineComponent({
|
|||
useVersionControlStore,
|
||||
useCloudPlanStore,
|
||||
),
|
||||
currentBranch(): string {
|
||||
return this.versionControlStore.state.currentBranch;
|
||||
},
|
||||
hasVersionUpdates(): boolean {
|
||||
return this.versionsStore.hasVersionUpdates;
|
||||
},
|
||||
|
@ -500,29 +484,6 @@ export default defineComponent({
|
|||
});
|
||||
}
|
||||
},
|
||||
async sync() {
|
||||
const prompt = (await this.prompt(
|
||||
this.$locale.baseText('settings.versionControl.sync.prompt.description', {
|
||||
interpolate: { branch: this.versionControlStore.state.currentBranch },
|
||||
}),
|
||||
this.$locale.baseText('settings.versionControl.sync.prompt.title', {
|
||||
interpolate: { branch: this.versionControlStore.state.currentBranch },
|
||||
}),
|
||||
{
|
||||
confirmButtonText: 'Sync',
|
||||
cancelButtonText: 'Cancel',
|
||||
inputPlaceholder: this.$locale.baseText(
|
||||
'settings.versionControl.sync.prompt.placeholder',
|
||||
),
|
||||
inputPattern: /^.+$/,
|
||||
inputErrorMessage: this.$locale.baseText('settings.versionControl.sync.prompt.error'),
|
||||
},
|
||||
)) as MessageBoxInputData;
|
||||
|
||||
if (prompt.value) {
|
||||
await this.versionControlStore.sync({ commitMessage: prompt.value });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -638,27 +599,4 @@ export default defineComponent({
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-l);
|
||||
margin: 0 calc(var(--spacing-l) * -1) calc(var(--spacing-m) * -1);
|
||||
background: var(--color-background-light);
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
span {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.sideMenuCollapsed & {
|
||||
justify-content: center;
|
||||
margin-left: calc(var(--spacing-xl) * -1);
|
||||
> span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
154
packages/editor-ui/src/components/MainSidebarVersionControl.vue
Normal file
154
packages/editor-ui/src/components/MainSidebarVersionControl.vue
Normal file
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts" setup>
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n, useLoadingService, useMessage, useToast } from '@/composables';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { VERSION_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
isCollapsed: boolean;
|
||||
}>();
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const { i18n } = useI18n();
|
||||
|
||||
const eventBus = createEventBus();
|
||||
const tooltipOpenDelay = ref(300);
|
||||
|
||||
const currentBranch = computed(() => {
|
||||
return versionControlStore.preferences.branchName;
|
||||
});
|
||||
|
||||
async function pushWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
try {
|
||||
const status = await versionControlStore.getAggregatedStatus();
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus, status },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
}
|
||||
|
||||
async function pullWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.versionControl.loading.pull'));
|
||||
try {
|
||||
await versionControlStore.pullWorkfolder(false);
|
||||
} catch (error) {
|
||||
const confirm = await message.confirm(
|
||||
i18n.baseText('settings.versionControl.modals.pull.description'),
|
||||
i18n.baseText('settings.versionControl.modals.pull.title'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.save'),
|
||||
cancelButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (confirm === 'confirm') {
|
||||
await versionControlStore.pullWorkfolder(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error');
|
||||
}
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ [$style.sync]: true, [$style.collapsed]: isCollapsed }">
|
||||
<span>
|
||||
<n8n-icon icon="code-branch" />
|
||||
{{ currentBranch }}
|
||||
</span>
|
||||
<div :class="{ 'pt-xs': !isCollapsed }">
|
||||
<n8n-tooltip :disabled="!isCollapsed" :open-delay="tooltipOpenDelay" placement="right">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.versionControl.button.pull') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-button
|
||||
:class="{
|
||||
'mr-2xs': !isCollapsed,
|
||||
'mb-2xs': isCollapsed && !versionControlStore.preferences.branchReadOnly,
|
||||
}"
|
||||
icon="arrow-down"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
:square="isCollapsed"
|
||||
@click="pullWorkfolder"
|
||||
>
|
||||
<span v-if="!isCollapsed">{{
|
||||
i18n.baseText('settings.versionControl.button.pull')
|
||||
}}</span>
|
||||
</n8n-button>
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip
|
||||
v-if="!versionControlStore.preferences.branchReadOnly"
|
||||
:disabled="!isCollapsed"
|
||||
:open-delay="tooltipOpenDelay"
|
||||
placement="right"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.versionControl.button.push') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-button
|
||||
:square="isCollapsed"
|
||||
icon="arrow-up"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
@click="pushWorkfolder"
|
||||
>
|
||||
<span v-if="!isCollapsed">{{
|
||||
i18n.baseText('settings.versionControl.button.push')
|
||||
}}</span>
|
||||
</n8n-button>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.sync {
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-l);
|
||||
margin: 0 calc(var(--spacing-l) * -1) calc(var(--spacing-m) * -1);
|
||||
background: var(--color-background-light);
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
span {
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: var(--font-size-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
text-align: center;
|
||||
margin-left: calc(var(--spacing-xl) * -1);
|
||||
|
||||
> span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -115,6 +115,12 @@
|
|||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="VERSION_CONTROL_PUSH_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<VersionControlPushModal :modalName="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -144,6 +150,7 @@ import {
|
|||
LOG_STREAM_MODAL_KEY,
|
||||
ASK_AI_MODAL_KEY,
|
||||
USER_ACTIVATION_SURVEY_MODAL,
|
||||
VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from './AboutModal.vue';
|
||||
|
@ -170,6 +177,7 @@ import ImportCurlModal from './ImportCurlModal.vue';
|
|||
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||
import WorkflowSuccessModal from './UserActivationSurveyModal.vue';
|
||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||
import VersionControlPushModal from '@/components/VersionControlPushModal.ee.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modals',
|
||||
|
@ -198,6 +206,7 @@ export default defineComponent({
|
|||
ImportCurlModal,
|
||||
EventDestinationSettingsModal,
|
||||
WorkflowSuccessModal,
|
||||
VersionControlPushModal,
|
||||
},
|
||||
data: () => ({
|
||||
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
|
||||
|
@ -223,6 +232,7 @@ export default defineComponent({
|
|||
IMPORT_CURL_MODAL_KEY,
|
||||
LOG_STREAM_MODAL_KEY,
|
||||
USER_ACTIVATION_SURVEY_MODAL,
|
||||
VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -183,7 +183,7 @@ import { nodeHelpers } from '@/mixins/nodeHelpers';
|
|||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { pinData } from '@/mixins/pinData';
|
||||
|
||||
import type { INodeTypeDescription, ITaskData } from 'n8n-workflow';
|
||||
import type { IExecutionsSummary, INodeTypeDescription, ITaskData } from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
|
@ -191,7 +191,7 @@ import TitledList from '@/components/TitledList.vue';
|
|||
|
||||
import { get } from 'lodash-es';
|
||||
import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils';
|
||||
import type { IExecutionsSummary, INodeUi, XYPosition } from '@/Interface';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
import { debounceHelper } from '@/mixins/debounce';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
|
254
packages/editor-ui/src/components/VersionControlPushModal.ee.vue
Normal file
254
packages/editor-ui/src/components/VersionControlPushModal.ee.vue
Normal file
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts" setup>
|
||||
import Modal from './Modal.vue';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, VERSION_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
import type { VersionControlAggregatedFile } from '@/Interface';
|
||||
import { useI18n, useLoadingService, useToast } from '@/composables';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { useRoute } from 'vue-router/composables';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<{ eventBus: EventBus; status: VersionControlAggregatedFile[] }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const { i18n } = useI18n();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
const route = useRoute();
|
||||
|
||||
const staged = ref<Record<string, boolean>>({});
|
||||
const files = ref<VersionControlAggregatedFile[]>(props.data.status || []);
|
||||
|
||||
const commitMessage = ref('');
|
||||
const loading = ref(true);
|
||||
const context = ref<'workflow' | 'workflows' | 'credentials' | string>('');
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return !commitMessage.value || Object.values(staged.value).every((value) => !value);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
context.value = getContext();
|
||||
try {
|
||||
staged.value = getStagedFilesByContext(files.value);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function getContext() {
|
||||
if (route.fullPath.startsWith('/workflows')) {
|
||||
return 'workflows';
|
||||
} else if (
|
||||
route.fullPath.startsWith('/credentials') ||
|
||||
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open
|
||||
) {
|
||||
return 'credentials';
|
||||
} else if (route.fullPath.startsWith('/workflow/')) {
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getStagedFilesByContext(files: VersionControlAggregatedFile[]): Record<string, boolean> {
|
||||
const stagedFiles: VersionControlAggregatedFile[] = [];
|
||||
if (context.value === 'workflows') {
|
||||
stagedFiles.push(...files.filter((file) => file.file.startsWith('workflows')));
|
||||
} else if (context.value === 'credentials') {
|
||||
stagedFiles.push(...files.filter((file) => file.file.startsWith('credentials')));
|
||||
} else if (context.value === 'workflow') {
|
||||
const workflowId = route.params.name as string;
|
||||
stagedFiles.push(...files.filter((file) => file.type === 'workflow' && file.id === workflowId));
|
||||
}
|
||||
|
||||
return stagedFiles.reduce<Record<string, boolean>>((acc, file) => {
|
||||
acc[file.file] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function setStagedStatus(file: VersionControlAggregatedFile, status: boolean) {
|
||||
staged.value = {
|
||||
...staged.value,
|
||||
[file.file]: status,
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
uiStore.closeModal(VERSION_CONTROL_PUSH_MODAL_KEY);
|
||||
}
|
||||
|
||||
async function commitAndPush() {
|
||||
const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file);
|
||||
|
||||
loadingService.startLoading(i18n.baseText('settings.versionControl.loading.push'));
|
||||
close();
|
||||
|
||||
try {
|
||||
await versionControlStore.pushWorkfolder({
|
||||
commitMessage: commitMessage.value,
|
||||
fileNames,
|
||||
});
|
||||
|
||||
toast.showToast({
|
||||
title: i18n.baseText('settings.versionControl.modals.push.success.title'),
|
||||
message: i18n.baseText('settings.versionControl.modals.push.success.description'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="812px"
|
||||
:title="i18n.baseText('settings.versionControl.modals.push.title')"
|
||||
:eventBus="data.eventBus"
|
||||
:name="VERSION_CONTROL_PUSH_MODAL_KEY"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.description') }}
|
||||
<span v-if="context">
|
||||
{{ i18n.baseText(`settings.versionControl.modals.push.description.${context}`) }}
|
||||
</span>
|
||||
<n8n-link
|
||||
:href="i18n.baseText('settings.versionControl.modals.push.description.learnMore.url')"
|
||||
>
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.description.learnMore') }}
|
||||
</n8n-link>
|
||||
</n8n-text>
|
||||
|
||||
<div v-if="files.length > 0">
|
||||
<n8n-text bold tag="p" class="mt-l mb-2xs">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.filesToCommit') }}
|
||||
</n8n-text>
|
||||
<n8n-card
|
||||
v-for="file in files"
|
||||
:key="file.file"
|
||||
:class="$style.listItem"
|
||||
@click="setStagedStatus(file, !staged[file.file])"
|
||||
>
|
||||
<div :class="$style.listItemBody">
|
||||
<n8n-checkbox
|
||||
:value="staged[file.file]"
|
||||
:class="$style.listItemCheckbox"
|
||||
@input="setStagedStatus(file, !staged[file.file])"
|
||||
/>
|
||||
<n8n-text bold>
|
||||
<span v-if="file.status === 'deleted'">
|
||||
<span v-if="file.type === 'workflow'"> Workflow </span>
|
||||
<span v-if="file.type === 'credential'"> Credential </span>
|
||||
Id: {{ file.id }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</n8n-text>
|
||||
<n8n-badge :class="$style.listItemStatus">
|
||||
{{ file.status }}
|
||||
</n8n-badge>
|
||||
</div>
|
||||
</n8n-card>
|
||||
|
||||
<n8n-text bold tag="p" class="mt-l mb-2xs">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.commitMessage') }}
|
||||
</n8n-text>
|
||||
<n8n-input
|
||||
type="text"
|
||||
v-model="commitMessage"
|
||||
:placeholder="
|
||||
i18n.baseText('settings.versionControl.modals.push.commitMessage.placeholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading">
|
||||
<n8n-callout class="mt-l">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.everythingIsUpToDate') }}
|
||||
</n8n-callout>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<n8n-button type="tertiary" class="mr-2xs" @click="close">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.buttons.cancel') }}
|
||||
</n8n-button>
|
||||
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.buttons.save') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container > * {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
margin-top: var(--spacing-2xs);
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
cursor: pointer;
|
||||
transition: border 0.3s ease;
|
||||
padding: var(--spacing-xs);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-foreground-dark);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.listItemBody {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.listItemCheckbox {
|
||||
display: inline-flex !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.listItemStatus {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
|
@ -123,7 +123,7 @@ export default defineComponent({
|
|||
versionId: '',
|
||||
}),
|
||||
},
|
||||
readonly: {
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -137,7 +137,7 @@ export default defineComponent({
|
|||
return getWorkflowPermissions(this.currentUser, this.data);
|
||||
},
|
||||
actions(): Array<{ label: string; value: string }> {
|
||||
return [
|
||||
const actions = [
|
||||
{
|
||||
label: this.$locale.baseText('workflows.item.open'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.OPEN,
|
||||
|
@ -146,20 +146,23 @@ export default defineComponent({
|
|||
label: this.$locale.baseText('workflows.item.share'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.SHARE,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (!this.readOnly) {
|
||||
actions.push({
|
||||
label: this.$locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
},
|
||||
].concat(
|
||||
this.workflowPermissions.delete
|
||||
? [
|
||||
{
|
||||
label: this.$locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.workflowPermissions.delete && !this.readOnly) {
|
||||
actions.push({
|
||||
label: this.$locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
formattedCreatedAtDate(): string {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
placeholder="Select Workflow"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-error-workflow"
|
||||
>
|
||||
|
@ -57,6 +58,7 @@
|
|||
<el-col :span="14" class="ignore-key-press">
|
||||
<n8n-select
|
||||
v-model="workflowSettings.callerPolicy"
|
||||
:disabled="readOnlyEnv"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
|
@ -84,6 +86,7 @@
|
|||
</el-col>
|
||||
<el-col :span="14">
|
||||
<n8n-input
|
||||
:disabled="readOnlyEnv"
|
||||
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
|
||||
type="text"
|
||||
size="medium"
|
||||
|
@ -109,6 +112,7 @@
|
|||
placeholder="Select Timezone"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-timezone"
|
||||
>
|
||||
|
@ -138,6 +142,7 @@
|
|||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-failed-executions"
|
||||
>
|
||||
|
@ -167,6 +172,7 @@
|
|||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-success-executions"
|
||||
>
|
||||
|
@ -196,6 +202,7 @@
|
|||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-manual-executions"
|
||||
>
|
||||
|
@ -225,6 +232,7 @@
|
|||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-execution-progress"
|
||||
>
|
||||
|
@ -252,6 +260,7 @@
|
|||
<div>
|
||||
<el-switch
|
||||
ref="inputField"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="workflowSettings.executionTimeout > -1"
|
||||
@change="toggleTimeout"
|
||||
active-color="#13ce66"
|
||||
|
@ -277,6 +286,7 @@
|
|||
<el-col :span="4">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.hours"
|
||||
@input="(value) => setTimeout('hours', value)"
|
||||
:min="0"
|
||||
|
@ -287,6 +297,7 @@
|
|||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.minutes"
|
||||
@input="(value) => setTimeout('minutes', value)"
|
||||
:min="0"
|
||||
|
@ -298,6 +309,7 @@
|
|||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.seconds"
|
||||
@input="(value) => setTimeout('seconds', value)"
|
||||
:min="0"
|
||||
|
@ -313,6 +325,7 @@
|
|||
<template #footer>
|
||||
<div class="action-buttons" data-test-id="workflow-settings-save-button">
|
||||
<n8n-button
|
||||
:disabled="readOnlyEnv"
|
||||
:label="$locale.baseText('workflowSettings.save')"
|
||||
size="large"
|
||||
float="right"
|
||||
|
@ -348,11 +361,14 @@ import {
|
|||
|
||||
import type { WorkflowSettings } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import {
|
||||
useWorkflowsStore,
|
||||
useSettingsStore,
|
||||
useRootStore,
|
||||
useWorkflowsEEStore,
|
||||
useUsersStore,
|
||||
useVersionControlStore,
|
||||
} from '@/stores';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -424,6 +440,7 @@ export default defineComponent({
|
|||
useSettingsStore,
|
||||
useWorkflowsStore,
|
||||
useWorkflowsEEStore,
|
||||
useVersionControlStore,
|
||||
),
|
||||
workflowName(): string {
|
||||
return this.workflowsStore.workflowName;
|
||||
|
@ -447,6 +464,9 @@ export default defineComponent({
|
|||
|
||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
|
||||
},
|
||||
readOnlyEnv(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.executionTimeout = this.rootStore.executionTimeout;
|
||||
|
|
|
@ -6,6 +6,7 @@ export * from './useExternalHooks';
|
|||
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
||||
export * from './useHistoryHelper';
|
||||
export * from './useI18n';
|
||||
export * from './useLoadingService';
|
||||
export * from './useMessage';
|
||||
export * from './useTelemetry';
|
||||
export * from './useTitleChange';
|
||||
|
|
45
packages/editor-ui/src/composables/useLoadingService.ts
Normal file
45
packages/editor-ui/src/composables/useLoadingService.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { ref } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { Loading } from 'element-ui';
|
||||
|
||||
interface LoadingService {
|
||||
text: string;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function useLoadingService() {
|
||||
const { i18n } = useI18n();
|
||||
const loadingService = ref<LoadingService | null>(null);
|
||||
|
||||
function startLoading(text?: string) {
|
||||
if (loadingService.value !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingService.value = Loading.service({
|
||||
lock: true,
|
||||
text: text || i18n.baseText('genericHelpers.loading'),
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
}) as unknown as LoadingService;
|
||||
}
|
||||
|
||||
function setLoadingText(text: string) {
|
||||
if (loadingService.value) {
|
||||
loadingService.value.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
if (loadingService.value) {
|
||||
loadingService.value.close();
|
||||
loadingService.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startLoading,
|
||||
setLoadingText,
|
||||
stopLoading,
|
||||
};
|
||||
}
|
|
@ -49,6 +49,8 @@ export const IMPORT_CURL_MODAL_KEY = 'importCurl';
|
|||
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
|
||||
export const USER_ACTIVATION_SURVEY_MODAL = 'userActivationSurvey';
|
||||
|
||||
export const VERSION_CONTROL_PUSH_MODAL_KEY = 'versionControlPush';
|
||||
|
||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||
UNINSTALL: 'uninstall',
|
||||
UPDATE: 'update',
|
||||
|
|
|
@ -65,6 +65,10 @@ export const genericHelpers = defineComponent({
|
|||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* @note Loading helpers extracted as composable in useLoadingService
|
||||
*/
|
||||
|
||||
startLoading(text?: string) {
|
||||
if (this.loadingService !== null) {
|
||||
return;
|
||||
|
|
|
@ -1339,13 +1339,19 @@
|
|||
"settings.versionControl.repoUrl": "Git repository URL (SSH)",
|
||||
"settings.versionControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository",
|
||||
"settings.versionControl.repoUrlDescription": "The SSH url of your Git repository",
|
||||
"settings.versionControl.repoUrlInvalid": "The Git repository URL is not valid",
|
||||
"settings.versionControl.authorName": "Commit author name",
|
||||
"settings.versionControl.authorEmail": "Commit author email",
|
||||
"settings.versionControl.authorEmailInvalid": "The provided email is not correct",
|
||||
"settings.versionControl.sshKey": "SSH Key",
|
||||
"settings.versionControl.sshKeyDescription": "Paste the SSH key in yout git repository settings. {link}.",
|
||||
"settings.versionControl.sshKeyDescriptionLink": "More info.",
|
||||
"settings.versionControl.sshKeyDescription": "Paste the SSH key in your git repository settings. {link}.",
|
||||
"settings.versionControl.sshKeyDescriptionLink": "More info",
|
||||
"settings.versionControl.refreshSshKey": "Refresh Key",
|
||||
"settings.versionControl.refreshSshKey.successful.title": "SSH Key refreshed successfully",
|
||||
"settings.versionControl.refreshSshKey.error.title": "SSH Key refresh failed",
|
||||
"settings.versionControl.button.continue": "Continue",
|
||||
"settings.versionControl.button.connect": "Connect",
|
||||
"settings.versionControl.button.disconnect": "Disconnect Git",
|
||||
"settings.versionControl.button.save": "Save settings",
|
||||
"settings.versionControl.instanceSettings": "Instance settings",
|
||||
"settings.versionControl.branches": "Branch connected to this n8n instance",
|
||||
|
@ -1359,6 +1365,44 @@
|
|||
"settings.versionControl.sync.prompt.description": "All the changes on your n8n instances will be synced with branch {branch} on the remote git repository. The following git sequence will be executed: pull > commit > push.",
|
||||
"settings.versionControl.sync.prompt.placeholder": "Commit message",
|
||||
"settings.versionControl.sync.prompt.error": "Please enter a commit message",
|
||||
"settings.versionControl.button.push": "Push",
|
||||
"settings.versionControl.button.pull": "Pull",
|
||||
"settings.versionControl.modals.push.title": "Commit and push changes",
|
||||
"settings.versionControl.modals.push.description": "Select the files you want to stage in your commit and add a commit message. ",
|
||||
"settings.versionControl.modals.push.description.workflows": "Since you are on the Workflows page, the modified workflow files have been pre-selected for you.",
|
||||
"settings.versionControl.modals.push.description.workflow": "Since you are currently editing a Workflow, the modified workflow file has been pre-selected for you.",
|
||||
"settings.versionControl.modals.push.description.credentials": "Since you are on the Credentials page, the modified credential files have been pre-selected for you.",
|
||||
"settings.versionControl.modals.push.description.learnMore": "Learn more",
|
||||
"settings.versionControl.modals.push.description.learnMore.url": "https://n8n.io/docs",
|
||||
"settings.versionControl.modals.push.filesToCommit": "Files to commit",
|
||||
"settings.versionControl.modals.push.everythingIsUpToDate": "Everything is up to date",
|
||||
"settings.versionControl.modals.push.commitMessage": "Commit message",
|
||||
"settings.versionControl.modals.push.commitMessage.placeholder": "e.g. My commit",
|
||||
"settings.versionControl.modals.push.buttons.cancel": "Cancel",
|
||||
"settings.versionControl.modals.push.buttons.save": "Commit and Push",
|
||||
"settings.versionControl.modals.push.success.title": "Pushed successfully",
|
||||
"settings.versionControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository",
|
||||
"settings.versionControl.modals.pull.title": "Override local changes",
|
||||
"settings.versionControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?",
|
||||
"settings.versionControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel",
|
||||
"settings.versionControl.modals.pull.buttons.save": "Pull and override",
|
||||
"settings.versionControl.modals.disconnect.title": "Disconnect Git repository",
|
||||
"settings.versionControl.modals.disconnect.message": "Please confirm you want to disconnect this n8n instance from the Git repository",
|
||||
"settings.versionControl.modals.disconnect.confirm": "Disconnect Git",
|
||||
"settings.versionControl.modals.disconnect.cancel": "@:_reusableBaseText.cancel",
|
||||
"settings.versionControl.modals.refreshSshKey.title": "Refresh SSH Key",
|
||||
"settings.versionControl.modals.refreshSshKey.message": "This will delete the current SSH key and create a new one. You will not be able to authenticate with the current key anymore.",
|
||||
"settings.versionControl.modals.refreshSshKey.cancel": "Cancel",
|
||||
"settings.versionControl.modals.refreshSshKey.confirm": "Refresh key",
|
||||
"settings.versionControl.toast.connected.title": "Git repository connected",
|
||||
"settings.versionControl.toast.connected.message": "Select the branch to complete the configuration",
|
||||
"settings.versionControl.toast.connected.error": "Error connecting to Git",
|
||||
"settings.versionControl.toast.disconnected.title": "Git repository disconnected",
|
||||
"settings.versionControl.toast.disconnected.message": "You can no longer sync your instance with the remote repository",
|
||||
"settings.versionControl.toast.disconnected.error": "Error disconnecting from Git",
|
||||
"settings.versionControl.loading.pull": "Pulling from remote",
|
||||
"settings.versionControl.loading.push": "Pushing to remote",
|
||||
"settings.versionControl.saved.title": "Settings successfully saved",
|
||||
"showMessage.cancel": "@:_reusableBaseText.cancel",
|
||||
"showMessage.ok": "OK",
|
||||
"showMessage.showDetails": "Show Details",
|
||||
|
|
|
@ -10,6 +10,8 @@ import {
|
|||
faAngleUp,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowUp,
|
||||
faArrowDown,
|
||||
faAt,
|
||||
faBan,
|
||||
faBolt,
|
||||
|
@ -147,6 +149,8 @@ addIcon(faAngleRight);
|
|||
addIcon(faAngleUp);
|
||||
addIcon(faArrowLeft);
|
||||
addIcon(faArrowRight);
|
||||
addIcon(faArrowUp);
|
||||
addIcon(faArrowDown);
|
||||
addIcon(faAt);
|
||||
addIcon(faBan);
|
||||
addIcon(faBolt);
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import normalizeWheel from 'normalize-wheel';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import {
|
||||
useWorkflowsStore,
|
||||
useNodeTypesStore,
|
||||
useUIStore,
|
||||
useHistoryStore,
|
||||
useVersionControlStore,
|
||||
} from '@/stores';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
import { scaleBigger, scaleReset, scaleSmaller } from '@/utils';
|
||||
import { START_NODE_TYPE } from '@/constants';
|
||||
|
@ -40,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
|
||||
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
||||
const isDragging = ref<boolean>(false);
|
||||
|
@ -55,6 +59,13 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
const isDemo = ref<boolean>(false);
|
||||
const nodeViewScale = ref<number>(1);
|
||||
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
|
||||
const readOnlyEnv = computed(() => versionControlStore.preferences.branchReadOnly);
|
||||
|
||||
watch(readOnlyEnv, (readOnly) => {
|
||||
if (jsPlumbInstanceRef.value) {
|
||||
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
|
||||
}
|
||||
});
|
||||
|
||||
Connectors.register(N8nConnector.type, N8nConnector);
|
||||
N8nPlusEndpointRenderer.register();
|
||||
|
@ -166,6 +177,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
paintStyle: CONNECTOR_PAINT_STYLE_DEFAULT,
|
||||
hoverPaintStyle: CONNECTOR_PAINT_STYLE_PRIMARY,
|
||||
connectionOverlays: CONNECTOR_ARROW_OVERLAYS,
|
||||
elementsDraggable: !readOnlyEnv.value,
|
||||
dragOptions: {
|
||||
cursor: 'pointer',
|
||||
grid: { w: GRID_SIZE, h: GRID_SIZE },
|
||||
|
|
|
@ -22,3 +22,5 @@ export * from './webhooks.store';
|
|||
export * from './workflows.ee.store';
|
||||
export * from './workflows.store';
|
||||
export * from './cloudPlan.store';
|
||||
export * from './versionControl.store';
|
||||
export * from './sso.store';
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
USER_ACTIVATION_SURVEY_MODAL,
|
||||
VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
CurlToJSONResponse,
|
||||
|
@ -137,6 +138,9 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
[USER_ACTIVATION_SURVEY_MODAL]: {
|
||||
open: false,
|
||||
},
|
||||
[VERSION_CONTROL_PUSH_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
},
|
||||
modalStack: [],
|
||||
sidebarMenuCollapsed: true,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { computed, reactive } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import * as vcApi from '@/api/versionControl';
|
||||
|
@ -16,7 +15,7 @@ export const useVersionControlStore = defineStore('versionControl', () => {
|
|||
);
|
||||
|
||||
const preferences = reactive<VersionControlPreferences>({
|
||||
currentBranch: '',
|
||||
branchName: '',
|
||||
branches: [],
|
||||
authorName: '',
|
||||
authorEmail: '',
|
||||
|
@ -27,44 +26,33 @@ export const useVersionControlStore = defineStore('versionControl', () => {
|
|||
publicKey: '',
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
branches: [] as string[],
|
||||
currentBranch: '',
|
||||
authorName: '',
|
||||
authorEmail: '',
|
||||
repositoryUrl: '',
|
||||
sshKey: '',
|
||||
const state = reactive<{
|
||||
commitMessage: string;
|
||||
}>({
|
||||
commitMessage: 'commit message',
|
||||
});
|
||||
|
||||
const initSsh = async (data: IDataObject) => {
|
||||
state.sshKey = await vcApi.initSsh(rootStore.getRestApiContext, data);
|
||||
};
|
||||
|
||||
const initRepository = async () => {
|
||||
const { branches, currentBranch } = await vcApi.initRepository(rootStore.getRestApiContext);
|
||||
state.branches = branches;
|
||||
state.currentBranch = currentBranch;
|
||||
};
|
||||
|
||||
const sync = async (data: { commitMessage: string }) => {
|
||||
const pushWorkfolder = async (data: { commitMessage: string; fileNames?: string[] }) => {
|
||||
state.commitMessage = data.commitMessage;
|
||||
return vcApi.sync(rootStore.getRestApiContext, { message: data.commitMessage });
|
||||
await vcApi.pushWorkfolder(rootStore.getRestApiContext, {
|
||||
message: data.commitMessage,
|
||||
...(data.fileNames ? { fileNames: data.fileNames } : {}),
|
||||
});
|
||||
};
|
||||
const getConfig = async () => {
|
||||
const { remoteRepository, name, email, currentBranch } = await vcApi.getConfig(
|
||||
rootStore.getRestApiContext,
|
||||
);
|
||||
state.repositoryUrl = remoteRepository;
|
||||
state.authorName = name;
|
||||
state.authorEmail = email;
|
||||
state.currentBranch = currentBranch;
|
||||
|
||||
const pullWorkfolder = async (force: boolean) => {
|
||||
await vcApi.pullWorkfolder(rootStore.getRestApiContext, { force });
|
||||
};
|
||||
|
||||
const setPreferences = (data: Partial<VersionControlPreferences>) => {
|
||||
Object.assign(preferences, data);
|
||||
};
|
||||
|
||||
const getBranches = async () => {
|
||||
const data = await vcApi.getBranches(rootStore.getRestApiContext);
|
||||
setPreferences(data);
|
||||
};
|
||||
|
||||
const getPreferences = async () => {
|
||||
const data = await vcApi.getPreferences(rootStore.getRestApiContext);
|
||||
setPreferences(data);
|
||||
|
@ -75,16 +63,52 @@ export const useVersionControlStore = defineStore('versionControl', () => {
|
|||
setPreferences(data);
|
||||
};
|
||||
|
||||
const setBranch = async (branch: string) => {
|
||||
const data = await vcApi.setBranch(rootStore.getRestApiContext, branch);
|
||||
setPreferences({ ...data, connected: true });
|
||||
};
|
||||
|
||||
const disconnect = async (keepKeyPair: boolean) => {
|
||||
await vcApi.disconnect(rootStore.getRestApiContext, keepKeyPair);
|
||||
setPreferences({ connected: false, branches: [] });
|
||||
};
|
||||
|
||||
const generateKeyPair = async () => {
|
||||
await vcApi.generateKeyPair(rootStore.getRestApiContext);
|
||||
const data = await vcApi.getPreferences(rootStore.getRestApiContext); // To be removed once the API is updated
|
||||
|
||||
preferences.publicKey = data.publicKey;
|
||||
|
||||
return { publicKey: data.publicKey };
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
return vcApi.getStatus(rootStore.getRestApiContext);
|
||||
};
|
||||
|
||||
const getAggregatedStatus = async () => {
|
||||
return vcApi.getAggregatedStatus(rootStore.getRestApiContext);
|
||||
};
|
||||
|
||||
const setBranchReadonly = async (branchReadOnly: boolean) => {
|
||||
return vcApi.setBranchReadonly(rootStore.getRestApiContext, branchReadOnly);
|
||||
};
|
||||
|
||||
return {
|
||||
isEnterpriseVersionControlEnabled,
|
||||
state,
|
||||
preferences,
|
||||
initSsh,
|
||||
initRepository,
|
||||
sync,
|
||||
getConfig,
|
||||
pushWorkfolder,
|
||||
pullWorkfolder,
|
||||
getPreferences,
|
||||
setPreferences,
|
||||
generateKeyPair,
|
||||
getBranches,
|
||||
savePreferences,
|
||||
setBranch,
|
||||
disconnect,
|
||||
getStatus,
|
||||
getAggregatedStatus,
|
||||
setBranchReadonly,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -2,8 +2,7 @@ import { beforeAll } from 'vitest';
|
|||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { merge } from 'lodash-es';
|
||||
import { isAuthorized } from '@/utils';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { useSettingsStore, useSSOStore } from '@/stores';
|
||||
import type { IUser } from '@/Interface';
|
||||
import { routes } from '@/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import type { VersionControlStatus } from '@/Interface';
|
||||
import { beforeEach } from 'vitest';
|
||||
import { aggregateVersionControlFiles } from '@/utils';
|
||||
|
||||
describe('versionControlUtils', () => {
|
||||
describe('aggregateVersionControlFiles()', () => {
|
||||
let status: VersionControlStatus;
|
||||
|
||||
beforeEach(() => {
|
||||
status = {
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
conflicted: [],
|
||||
created: [],
|
||||
current: 'main',
|
||||
deleted: [],
|
||||
detached: false,
|
||||
files: [],
|
||||
modified: [],
|
||||
not_added: [],
|
||||
renamed: [],
|
||||
staged: [],
|
||||
tracking: null,
|
||||
};
|
||||
});
|
||||
|
||||
it('should be empty array if no files', () => {
|
||||
expect(aggregateVersionControlFiles(status)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should contain list of conflicted, created, deleted, modified, and renamed files', () => {
|
||||
status.files = [
|
||||
{ path: 'conflicted.json', index: 'A', working_dir: '' },
|
||||
{ path: 'created.json', index: 'A', working_dir: '' },
|
||||
{ path: 'deleted.json', index: 'A', working_dir: '' },
|
||||
{ path: 'modified.json', index: 'A', working_dir: '' },
|
||||
{ path: 'renamed.json', index: 'A', working_dir: '' },
|
||||
];
|
||||
|
||||
status.conflicted.push('conflicted.json');
|
||||
status.created.push('created.json');
|
||||
status.deleted.push('deleted.json');
|
||||
status.modified.push('modified.json');
|
||||
status.renamed.push('renamed.json');
|
||||
status.staged = status.files.map((file) => file.path);
|
||||
|
||||
expect(aggregateVersionControlFiles(status)).toEqual([
|
||||
{ path: 'conflicted.json', status: 'conflicted', staged: true },
|
||||
{ path: 'created.json', status: 'created', staged: true },
|
||||
{ path: 'deleted.json', status: 'deleted', staged: true },
|
||||
{ path: 'modified.json', status: 'modified', staged: true },
|
||||
{ path: 'renamed.json', status: 'renamed', staged: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,3 +8,4 @@ export * from './pairedItemUtils';
|
|||
export * from './typeGuards';
|
||||
export * from './typesUtils';
|
||||
export * from './userUtils';
|
||||
export * from './versionControlUtils';
|
||||
|
|
27
packages/editor-ui/src/utils/versionControlUtils.ts
Normal file
27
packages/editor-ui/src/utils/versionControlUtils.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { VersionControlAggregatedFile, VersionControlStatus } from '@/Interface';
|
||||
|
||||
export function aggregateVersionControlFiles(versionControlStatus: VersionControlStatus) {
|
||||
return versionControlStatus.files.reduce<VersionControlAggregatedFile[]>((acc, file) => {
|
||||
const staged = versionControlStatus.staged.includes(file.path);
|
||||
|
||||
let status = '';
|
||||
(
|
||||
['conflicted', 'created', 'deleted', 'modified', 'renamed'] as Array<
|
||||
keyof VersionControlStatus
|
||||
>
|
||||
).forEach((key) => {
|
||||
const filesForStatus = versionControlStatus[key] as string[];
|
||||
if (filesForStatus.includes(file.path)) {
|
||||
status = key;
|
||||
}
|
||||
});
|
||||
|
||||
acc.push({
|
||||
path: file.path,
|
||||
status,
|
||||
staged,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
|
@ -57,11 +57,12 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
|
||||
type IResourcesListLayoutInstance = Vue & { sendFiltersTelemetry: (source: string) => void };
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SettingsPersonalView',
|
||||
name: 'CredentialsView',
|
||||
components: {
|
||||
ResourcesListLayout,
|
||||
CredentialCard,
|
||||
|
@ -74,10 +75,17 @@ export default defineComponent({
|
|||
sharedWith: '',
|
||||
type: '',
|
||||
},
|
||||
versionControlStoreUnsubscribe: () => {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useCredentialsStore, useNodeTypesStore, useUIStore, useUsersStore),
|
||||
...mapStores(
|
||||
useCredentialsStore,
|
||||
useNodeTypesStore,
|
||||
useUIStore,
|
||||
useUsersStore,
|
||||
useVersionControlStore,
|
||||
),
|
||||
allCredentials(): ICredentialsResponse[] {
|
||||
return this.credentialsStore.allCredentials;
|
||||
},
|
||||
|
@ -141,6 +149,18 @@ export default defineComponent({
|
|||
this.sendFiltersTelemetry('type');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.versionControlStoreUnsubscribe = this.versionControlStore.$onAction(({ name, after }) => {
|
||||
if (name === 'pullWorkfolder' && after) {
|
||||
after(() => {
|
||||
void this.initialize();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.versionControlStoreUnsubscribe();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
@run="onNodeRun"
|
||||
:key="`${nodeData.id}_node`"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
:isReadOnly="isReadOnly || readOnlyEnv"
|
||||
:instance="instance"
|
||||
:isActive="!!activeNode && activeNode.name === nodeData.name"
|
||||
:hideActions="pullConnActive"
|
||||
|
@ -76,7 +76,7 @@
|
|||
@removeNode="(name) => removeNode(name, true)"
|
||||
:key="`${nodeData.id}_sticky`"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
:isReadOnly="isReadOnly || readOnlyEnv"
|
||||
:instance="instance"
|
||||
:isActive="!!activeNode && activeNode.name === nodeData.name"
|
||||
:nodeViewScale="nodeViewScale"
|
||||
|
@ -87,7 +87,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<node-details-view
|
||||
:readOnly="isReadOnly"
|
||||
:readOnly="isReadOnly || readOnlyEnv"
|
||||
:renaming="renamingActive"
|
||||
:isProductionExecutionPreview="isProductionExecutionPreview"
|
||||
@valueChanged="valueChanged"
|
||||
|
@ -95,7 +95,7 @@
|
|||
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
||||
/>
|
||||
<node-creation
|
||||
v-if="!isReadOnly"
|
||||
v-if="!isReadOnly && !readOnlyEnv"
|
||||
:create-node-active="createNodeActive"
|
||||
:node-view-scale="nodeViewScale"
|
||||
@toggleNodeCreator="onToggleNodeCreator"
|
||||
|
@ -262,26 +262,29 @@ import type {
|
|||
} from '@/Interface';
|
||||
|
||||
import { debounceHelper } from '@/mixins/debounce';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { Route, RawLocation } from 'vue-router';
|
||||
import { dataPinningEventBus, nodeViewEventBus } from '@/event-bus';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useSegment } from '@/stores/segment.store';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
||||
import { useEnvironmentsStore } from '@/stores';
|
||||
import {
|
||||
useEnvironmentsStore,
|
||||
useWorkflowsEEStore,
|
||||
useCanvasStore,
|
||||
useNodeCreatorStore,
|
||||
useTagsStore,
|
||||
useCredentialsStore,
|
||||
useNodeTypesStore,
|
||||
useTemplatesStore,
|
||||
useSegment,
|
||||
useNDVStore,
|
||||
useRootStore,
|
||||
useWorkflowsStore,
|
||||
useUsersStore,
|
||||
useSettingsStore,
|
||||
useUIStore,
|
||||
useHistoryStore,
|
||||
useVersionControlStore,
|
||||
} from '@/stores';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import {
|
||||
AddConnectionCommand,
|
||||
AddNodeCommand,
|
||||
|
@ -480,7 +483,11 @@ export default defineComponent({
|
|||
useEnvironmentsStore,
|
||||
useWorkflowsEEStore,
|
||||
useHistoryStore,
|
||||
useVersionControlStore,
|
||||
),
|
||||
readOnlyEnv(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
nativelyNumberSuffixedDefaults(): string[] {
|
||||
return this.rootStore.nativelyNumberSuffixedDefaults;
|
||||
},
|
||||
|
@ -497,7 +504,9 @@ export default defineComponent({
|
|||
return this.$route.name === VIEWS.DEMO;
|
||||
},
|
||||
showCanvasAddButton(): boolean {
|
||||
return this.loadingService === null && !this.containsTrigger && !this.isDemo;
|
||||
return (
|
||||
this.loadingService === null && !this.containsTrigger && !this.isDemo && !this.readOnlyEnv
|
||||
);
|
||||
},
|
||||
lastSelectedNode(): INodeUi | null {
|
||||
return this.uiStore.getLastSelectedNode;
|
||||
|
@ -2195,6 +2204,7 @@ export default defineComponent({
|
|||
|
||||
if (
|
||||
this.isReadOnly ||
|
||||
this.readOnlyEnv ||
|
||||
this.enterTimer ||
|
||||
!connection ||
|
||||
connection === this.activeConnection
|
||||
|
@ -2223,7 +2233,13 @@ export default defineComponent({
|
|||
this.enterTimer = undefined;
|
||||
}
|
||||
|
||||
if (this.isReadOnly || !connection || this.activeConnection?.id !== connection.id) return;
|
||||
if (
|
||||
this.isReadOnly ||
|
||||
this.readOnlyEnv ||
|
||||
!connection ||
|
||||
this.activeConnection?.id !== connection.id
|
||||
)
|
||||
return;
|
||||
|
||||
this.exitTimer = setTimeout(() => {
|
||||
this.exitTimer = undefined;
|
||||
|
|
|
@ -1,40 +1,167 @@
|
|||
<script lang="ts" setup>
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import { computed, reactive, onBeforeMount, ref } from 'vue';
|
||||
import type { Rule, RuleGroup } from 'n8n-design-system/types';
|
||||
import { MODAL_CONFIRM, VALID_EMAIL_REGEX } from '@/constants';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useMessage } from '@/composables';
|
||||
import { useToast, useMessage, useLoadingService, useI18n } from '@/composables';
|
||||
import CopyInput from '@/components/CopyInput.vue';
|
||||
|
||||
const { i18n: locale } = useI18n();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const loadingService = useLoadingService();
|
||||
|
||||
const onContinue = () => {
|
||||
void versionControlStore.initSsh({
|
||||
name: versionControlStore.state.authorName,
|
||||
email: versionControlStore.state.authorEmail,
|
||||
remoteRepository: versionControlStore.state.repositoryUrl,
|
||||
});
|
||||
const isConnected = ref(false);
|
||||
|
||||
const onConnect = async () => {
|
||||
loadingService.startLoading();
|
||||
try {
|
||||
await versionControlStore.savePreferences({
|
||||
authorName: versionControlStore.preferences.authorName,
|
||||
authorEmail: versionControlStore.preferences.authorEmail,
|
||||
repositoryUrl: versionControlStore.preferences.repositoryUrl,
|
||||
});
|
||||
await versionControlStore.getBranches();
|
||||
isConnected.value = true;
|
||||
toast.showMessage({
|
||||
title: locale.baseText('settings.versionControl.toast.connected.title'),
|
||||
message: locale.baseText('settings.versionControl.toast.connected.message'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('settings.versionControl.toast.connected.error'));
|
||||
}
|
||||
loadingService.stopLoading();
|
||||
};
|
||||
|
||||
const onConnect = () => {
|
||||
void versionControlStore.initRepository();
|
||||
const onDisconnect = async () => {
|
||||
try {
|
||||
const confirmation = await message.confirm(
|
||||
locale.baseText('settings.versionControl.modals.disconnect.message'),
|
||||
locale.baseText('settings.versionControl.modals.disconnect.title'),
|
||||
{
|
||||
confirmButtonText: locale.baseText('settings.versionControl.modals.disconnect.confirm'),
|
||||
cancelButtonText: locale.baseText('settings.versionControl.modals.disconnect.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmation === MODAL_CONFIRM) {
|
||||
loadingService.startLoading();
|
||||
await versionControlStore.disconnect(true);
|
||||
isConnected.value = false;
|
||||
toast.showMessage({
|
||||
title: locale.baseText('settings.versionControl.toast.disconnected.title'),
|
||||
message: locale.baseText('settings.versionControl.toast.disconnected.message'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('settings.versionControl.toast.disconnected.error'));
|
||||
}
|
||||
loadingService.stopLoading();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
void versionControlStore.savePreferences(versionControlStore.preferences);
|
||||
const onSave = async () => {
|
||||
loadingService.startLoading();
|
||||
try {
|
||||
await Promise.all([
|
||||
versionControlStore.setBranch(versionControlStore.preferences.branchName),
|
||||
versionControlStore.setBranchReadonly(versionControlStore.preferences.branchReadOnly),
|
||||
]);
|
||||
toast.showMessage({
|
||||
title: locale.baseText('settings.versionControl.saved.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error setting branch');
|
||||
}
|
||||
loadingService.stopLoading();
|
||||
};
|
||||
|
||||
const onSelect = async (b: string) => {
|
||||
if (b === versionControlStore.preferences.currentBranch) {
|
||||
if (b === versionControlStore.preferences.branchName) {
|
||||
return;
|
||||
}
|
||||
versionControlStore.preferences.currentBranch = b;
|
||||
versionControlStore.preferences.branchName = b;
|
||||
};
|
||||
|
||||
const goToUpgrade = () => {
|
||||
uiStore.goToUpgrade('version-control', 'upgrade-version-control');
|
||||
};
|
||||
|
||||
onBeforeMount(async () => {
|
||||
if (versionControlStore.preferences.connected) {
|
||||
isConnected.value = true;
|
||||
void versionControlStore.getBranches();
|
||||
}
|
||||
});
|
||||
|
||||
const formValidationStatus = reactive<Record<string, boolean>>({
|
||||
repoUrl: false,
|
||||
authorName: false,
|
||||
authorEmail: false,
|
||||
});
|
||||
|
||||
function onValidate(key: string, value: boolean) {
|
||||
formValidationStatus[key] = value;
|
||||
}
|
||||
|
||||
const repoUrlValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'REQUIRED' },
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/,
|
||||
message: locale.baseText('settings.versionControl.repoUrlInvalid'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const authorNameValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
|
||||
|
||||
const authorEmailValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'REQUIRED' },
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: VALID_EMAIL_REGEX,
|
||||
message: locale.baseText('settings.versionControl.authorEmailInvalid'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const validForConnection = computed(
|
||||
() =>
|
||||
formValidationStatus.repoUrl &&
|
||||
formValidationStatus.authorName &&
|
||||
formValidationStatus.authorEmail,
|
||||
);
|
||||
|
||||
async function refreshSshKey() {
|
||||
try {
|
||||
const confirmation = await message.confirm(
|
||||
locale.baseText('settings.versionControl.modals.refreshSshKey.message'),
|
||||
locale.baseText('settings.versionControl.modals.refreshSshKey.title'),
|
||||
{
|
||||
confirmButtonText: locale.baseText('settings.versionControl.modals.refreshSshKey.confirm'),
|
||||
cancelButtonText: locale.baseText('settings.versionControl.modals.refreshSshKey.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmation === MODAL_CONFIRM) {
|
||||
await versionControlStore.generateKeyPair();
|
||||
toast.showMessage({
|
||||
title: locale.baseText('settings.versionControl.refreshSshKey.successful.title'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('settings.versionControl.refreshSshKey.error.title'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -60,11 +187,29 @@ const goToUpgrade = () => {
|
|||
}}</n8n-heading>
|
||||
<div :class="$style.group">
|
||||
<label for="repoUrl">{{ locale.baseText('settings.versionControl.repoUrl') }}</label>
|
||||
<n8n-input
|
||||
id="repoUrl"
|
||||
:placeholder="locale.baseText('settings.versionControl.repoUrlPlaceholder')"
|
||||
v-model="versionControlStore.preferences.repositoryUrl"
|
||||
/>
|
||||
<div :class="$style.groupFlex">
|
||||
<n8n-form-input
|
||||
label
|
||||
class="ml-0"
|
||||
id="repoUrl"
|
||||
name="repoUrl"
|
||||
validateOnBlur
|
||||
:validationRules="repoUrlValidationRules"
|
||||
:disabled="isConnected"
|
||||
:placeholder="locale.baseText('settings.versionControl.repoUrlPlaceholder')"
|
||||
v-model="versionControlStore.preferences.repositoryUrl"
|
||||
@validate="(value) => onValidate('repoUrl', value)"
|
||||
/>
|
||||
<n8n-button
|
||||
class="ml-2xs"
|
||||
type="tertiary"
|
||||
v-if="isConnected"
|
||||
@click="onDisconnect"
|
||||
size="large"
|
||||
icon="trash"
|
||||
>{{ locale.baseText('settings.versionControl.button.disconnect') }}</n8n-button
|
||||
>
|
||||
</div>
|
||||
<small>{{ locale.baseText('settings.versionControl.repoUrlDescription') }}</small>
|
||||
</div>
|
||||
<div :class="[$style.group, $style.groupFlex]">
|
||||
|
@ -72,49 +217,71 @@ const goToUpgrade = () => {
|
|||
<label for="authorName">{{
|
||||
locale.baseText('settings.versionControl.authorName')
|
||||
}}</label>
|
||||
<n8n-input id="authorName" v-model="versionControlStore.preferences.authorName" />
|
||||
<n8n-form-input
|
||||
label
|
||||
id="authorName"
|
||||
name="authorName"
|
||||
validateOnBlur
|
||||
:validationRules="authorNameValidationRules"
|
||||
v-model="versionControlStore.preferences.authorName"
|
||||
@validate="(value) => onValidate('authorName', value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="authorEmail">{{
|
||||
locale.baseText('settings.versionControl.authorEmail')
|
||||
}}</label>
|
||||
<n8n-input id="authorEmail" v-model="versionControlStore.preferences.authorEmail" />
|
||||
<n8n-form-input
|
||||
label
|
||||
type="email"
|
||||
id="authorEmail"
|
||||
name="authorEmail"
|
||||
validateOnBlur
|
||||
:validationRules="authorEmailValidationRules"
|
||||
v-model="versionControlStore.preferences.authorEmail"
|
||||
@validate="(value) => onValidate('authorEmail', value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n8n-button
|
||||
v-if="!versionControlStore.preferences.publicKey"
|
||||
@click="onContinue"
|
||||
size="large"
|
||||
class="mt-2xs"
|
||||
>{{ locale.baseText('settings.versionControl.button.continue') }}</n8n-button
|
||||
>
|
||||
<div v-if="versionControlStore.preferences.publicKey" :class="$style.group">
|
||||
<label>{{ locale.baseText('settings.versionControl.sshKey') }}</label>
|
||||
<CopyInput
|
||||
:value="versionControlStore.preferences.publicKey"
|
||||
:copy-button-text="locale.baseText('generic.clickToCopy')"
|
||||
/>
|
||||
<div :class="{ [$style.sshInput]: !isConnected }">
|
||||
<CopyInput
|
||||
collapse
|
||||
size="medium"
|
||||
:value="versionControlStore.preferences.publicKey"
|
||||
:copy-button-text="locale.baseText('generic.clickToCopy')"
|
||||
/>
|
||||
<n8n-button
|
||||
v-if="!isConnected"
|
||||
size="large"
|
||||
type="tertiary"
|
||||
icon="sync"
|
||||
class="ml-s"
|
||||
@click="refreshSshKey"
|
||||
>
|
||||
{{ locale.baseText('settings.versionControl.refreshSshKey') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<n8n-notice type="info" class="mt-s">
|
||||
<i18n path="settings.versionControl.sshKeyDescription">
|
||||
<template #link>
|
||||
<a href="#" target="_blank">
|
||||
{{ locale.baseText('settings.versionControl.sshKeyDescriptionLink') }}
|
||||
</a>
|
||||
<a href="#" target="_blank">{{
|
||||
locale.baseText('settings.versionControl.sshKeyDescriptionLink')
|
||||
}}</a>
|
||||
</template>
|
||||
</i18n>
|
||||
</n8n-notice>
|
||||
</div>
|
||||
<n8n-button
|
||||
v-if="
|
||||
versionControlStore.preferences.publicKey &&
|
||||
!versionControlStore.preferences.branches.length
|
||||
"
|
||||
v-if="!isConnected"
|
||||
@click="onConnect"
|
||||
size="large"
|
||||
:disabled="!validForConnection"
|
||||
:class="$style.connect"
|
||||
>{{ locale.baseText('settings.versionControl.button.connect') }}</n8n-button
|
||||
>
|
||||
<div v-if="versionControlStore.preferences.branches.length">
|
||||
<div v-if="isConnected">
|
||||
<div :class="$style.group">
|
||||
<hr />
|
||||
<n8n-heading size="xlarge" tag="h2" class="mb-s">{{
|
||||
|
@ -122,7 +289,7 @@ const goToUpgrade = () => {
|
|||
}}</n8n-heading>
|
||||
<label>{{ locale.baseText('settings.versionControl.branches') }}</label>
|
||||
<n8n-select
|
||||
:value="versionControlStore.preferences.currentBranch"
|
||||
:value="versionControlStore.preferences.branchName"
|
||||
class="mb-s"
|
||||
size="medium"
|
||||
filterable
|
||||
|
@ -151,20 +318,17 @@ const goToUpgrade = () => {
|
|||
</i18n>
|
||||
</n8n-checkbox>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<!-- <div :class="$style.group">
|
||||
<label>{{ locale.baseText('settings.versionControl.color') }}</label>
|
||||
<div>
|
||||
<n8n-color-picker size="small" v-model="versionControlStore.preferences.branchColor" />
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div :class="[$style.group, 'pt-s']">
|
||||
<n8n-button
|
||||
v-if="
|
||||
versionControlStore.preferences.publicKey &&
|
||||
versionControlStore.preferences.currentBranch
|
||||
"
|
||||
@click="onSave"
|
||||
size="large"
|
||||
:disabled="!versionControlStore.preferences.branchName"
|
||||
>{{ locale.baseText('settings.versionControl.button.save') }}</n8n-button
|
||||
>
|
||||
</div>
|
||||
|
@ -188,6 +352,8 @@ const goToUpgrade = () => {
|
|||
<style lang="scss" module>
|
||||
.group {
|
||||
padding: 0 0 var(--spacing-s);
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
|
@ -211,6 +377,7 @@ const goToUpgrade = () => {
|
|||
|
||||
.groupFlex {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
|
@ -233,6 +400,20 @@ const goToUpgrade = () => {
|
|||
margin: var(--spacing-2xl) 0 0;
|
||||
}
|
||||
|
||||
.sshInput {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> div {
|
||||
width: calc(100% - 144px - var(--spacing-s));
|
||||
}
|
||||
|
||||
> button {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0 0 var(--spacing-xl);
|
||||
border: 1px solid var(--color-foreground-light);
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useEnvironmentsStore, useUIStore, useSettingsStore, useUsersStore } from '@/stores';
|
||||
import { computed, ref, onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
useEnvironmentsStore,
|
||||
useUIStore,
|
||||
useSettingsStore,
|
||||
useUsersStore,
|
||||
useVersionControlStore,
|
||||
} from '@/stores';
|
||||
import { useI18n, useTelemetry, useToast, useUpgradeLink, useMessage } from '@/composables';
|
||||
|
||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||
|
@ -22,6 +28,8 @@ const uiStore = useUIStore();
|
|||
const telemetry = useTelemetry();
|
||||
const { i18n } = useI18n();
|
||||
const message = useMessage();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
let versionControlStoreUnsubscribe = () => {};
|
||||
|
||||
const layoutRef = ref<InstanceType<typeof ResourcesListLayout> | null>(null);
|
||||
|
||||
|
@ -207,6 +215,20 @@ function goToUpgrade() {
|
|||
function displayName(resource: EnvironmentVariable) {
|
||||
return resource.key;
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
versionControlStoreUnsubscribe = versionControlStore.$onAction(({ name, after }) => {
|
||||
if (name === 'pullWorkfolder' && after) {
|
||||
after(() => {
|
||||
void initialize();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
versionControlStoreUnsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
:show-aside="allWorkflows.length > 0"
|
||||
:shareable="isShareable"
|
||||
:initialize="initialize"
|
||||
:disabled="readOnlyEnv"
|
||||
@click:add="addWorkflow"
|
||||
@update:filters="filters = $event"
|
||||
>
|
||||
|
@ -19,9 +20,10 @@
|
|||
:data="data"
|
||||
@expand:tags="updateItemSize(data)"
|
||||
@click:tag="onClickTag"
|
||||
:readOnly="readOnlyEnv"
|
||||
/>
|
||||
</template>
|
||||
<template #empty>
|
||||
<template v-if="!readOnlyEnv" #empty>
|
||||
<div class="text-center mt-s">
|
||||
<n8n-heading tag="h2" size="xlarge" class="mb-2xs">
|
||||
{{
|
||||
|
@ -103,6 +105,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
|
||||
type IResourcesListLayoutInstance = Vue & { sendFiltersTelemetry: (source: string) => void };
|
||||
|
||||
|
@ -128,6 +131,7 @@ const WorkflowsView = defineComponent({
|
|||
status: StatusFilter.ALL,
|
||||
tags: [] as string[],
|
||||
},
|
||||
versionControlStoreUnsubscribe: () => {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -137,6 +141,7 @@ const WorkflowsView = defineComponent({
|
|||
useUsersStore,
|
||||
useWorkflowsStore,
|
||||
useCredentialsStore,
|
||||
useVersionControlStore,
|
||||
),
|
||||
currentUser(): IUser {
|
||||
return this.usersStore.currentUser || ({} as IUser);
|
||||
|
@ -163,6 +168,9 @@ const WorkflowsView = defineComponent({
|
|||
},
|
||||
];
|
||||
},
|
||||
readOnlyEnv(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addWorkflow() {
|
||||
|
@ -220,6 +228,17 @@ const WorkflowsView = defineComponent({
|
|||
},
|
||||
mounted() {
|
||||
void this.usersStore.showPersonalizationSurvey();
|
||||
|
||||
this.versionControlStoreUnsubscribe = this.versionControlStore.$onAction(({ name, after }) => {
|
||||
if (name === 'pullWorkfolder' && after) {
|
||||
after(() => {
|
||||
void this.initialize();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.versionControlStoreUnsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) =>
|
|||
},
|
||||
);
|
||||
|
||||
describe('SettingsSso', () => {
|
||||
describe('SettingsVersionControl', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
|
|
|
@ -1675,6 +1675,7 @@ export interface IWorkflowBase {
|
|||
settings?: IWorkflowSettings;
|
||||
staticData?: IDataObject;
|
||||
pinData?: IPinData;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
export interface IWorkflowCredentials {
|
||||
|
|
|
@ -322,6 +322,9 @@ importers:
|
|||
localtunnel:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.2
|
||||
lodash.difference:
|
||||
specifier: ^4
|
||||
version: 4.5.0
|
||||
lodash.get:
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
|
@ -361,6 +364,9 @@ importers:
|
|||
lodash.unset:
|
||||
specifier: ^4.5.2
|
||||
version: 4.5.2
|
||||
lodash.without:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
luxon:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
@ -436,6 +442,9 @@ importers:
|
|||
shelljs:
|
||||
specifier: ^0.8.5
|
||||
version: 0.8.5
|
||||
simple-git:
|
||||
specifier: ^3.17.0
|
||||
version: 3.17.0
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
|
@ -521,6 +530,9 @@ importers:
|
|||
'@types/lodash.debounce':
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
'@types/lodash.difference':
|
||||
specifier: ^4
|
||||
version: 4.5.7
|
||||
'@types/lodash.get':
|
||||
specifier: ^4.4.6
|
||||
version: 4.4.7
|
||||
|
@ -560,6 +572,9 @@ importers:
|
|||
'@types/lodash.unset':
|
||||
specifier: ^4.5.7
|
||||
version: 4.5.7
|
||||
'@types/lodash.without':
|
||||
specifier: ^4.4.7
|
||||
version: 4.4.7
|
||||
'@types/parseurl':
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
|
@ -7824,6 +7839,12 @@ packages:
|
|||
'@types/lodash': 4.14.191
|
||||
dev: true
|
||||
|
||||
/@types/lodash.without@4.4.7:
|
||||
resolution: {integrity: sha512-T5Tfz45ZNn5YyFz8lFdsEN8os5T7BEXGuMCRSzmDavxUGwSOX2ijaOkjicnNlL/l6Hrs6UJPIsHebch3gLnpJg==}
|
||||
dependencies:
|
||||
'@types/lodash': 4.14.191
|
||||
dev: true
|
||||
|
||||
/@types/lodash.zip@4.2.7:
|
||||
resolution: {integrity: sha512-wRtK2bZ0HYXkJkeldrD35qOquGn5GOmp8+o886N18Aqw2DGFLP7JCTEb00j3xQZ+PCMTyfMS2OMbLUwah+bcyg==}
|
||||
dependencies:
|
||||
|
@ -16701,6 +16722,10 @@ packages:
|
|||
resolution: {integrity: sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==}
|
||||
dev: true
|
||||
|
||||
/lodash.without@4.4.0:
|
||||
resolution: {integrity: sha512-M3MefBwfDhgKgINVuBJCO1YR3+gf6s9HNJsIiZ/Ru77Ws6uTb9eBuvrkpzO+9iLoAaRodGuq7tyrPCx+74QYGQ==}
|
||||
dev: false
|
||||
|
||||
/lodash.zip@4.2.0:
|
||||
resolution: {integrity: sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==}
|
||||
dev: false
|
||||
|
|
Loading…
Reference in a new issue