mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-06 15:35:28 +00:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
30bf0cb16c
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
@ -8,19 +8,12 @@
|
|||||||
"name": "Debug notion import",
|
"name": "Debug notion import",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
// "args": ["src/__start.ts", "import-notion-to-teamspace", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "w-user1-ws1-66d8018b-ce1e0c3164-006bb0", "-ts", "notion"],
|
"args": ["src/__start.ts", "import-notion-to-teamspace", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "notion-test", "-u", "user1", "-pw", "1234", "-ts", "natalya"],
|
||||||
"args": ["src/__start.ts", "import-notion", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "w-user1-ws1-66d8018b-ce1e0c3164-006bb0"],
|
// "args": ["src/__start.ts", "import-notion-with-teamspaces", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "ws1", "-u", "user1", "-pw", "1234"],
|
||||||
"env": {
|
"env": {
|
||||||
"SERVER_SECRET": "secret",
|
"SERVER_SECRET": "secret",
|
||||||
"MINIO_ACCESS_KEY": "minioadmin",
|
"FRONT_URL": "http://localhost:8087",
|
||||||
"MINIO_SECRET_KEY": "minioadmin",
|
|
||||||
"MINIO_ENDPOINT": "localhost",
|
|
||||||
"TRANSACTOR_URL": "ws://localhost:3333",
|
|
||||||
"MONGO_URL": "mongodb://localhost:27017",
|
|
||||||
"ACCOUNTS_URL": "http://localhost:3000",
|
"ACCOUNTS_URL": "http://localhost:3000",
|
||||||
"TELEGRAM_DATABASE": "telegram-service",
|
|
||||||
"ELASTIC_URL": "http://localhost:9200",
|
|
||||||
"REKONI_URL": "http://localhost:4004"
|
|
||||||
},
|
},
|
||||||
"runtimeVersion": "20",
|
"runtimeVersion": "20",
|
||||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||||
|
@ -50,28 +50,33 @@ rushx run-local import-notion-to-teamspace /home/john/extracted-notion-docs \
|
|||||||
--teamspace imported
|
--teamspace imported
|
||||||
```
|
```
|
||||||
|
|
||||||
#### For clouds:
|
#### For cloud deployment:
|
||||||
##### Here is an example for those who's using huly.app cloud:
|
##### Here is an example for those who's using huly.app cloud:
|
||||||
To import Notion workspace with teamspaces
|
1. Go to the root folder of the extracted archive with exported data.
|
||||||
|
2. Run import command as follow:
|
||||||
|
|
||||||
|
* To import Notion workspace with teamspaces
|
||||||
```
|
```
|
||||||
docker run \
|
docker run \
|
||||||
-e SERVER_SECRET="" \
|
-e SERVER_SECRET="" \
|
||||||
-e ACCOUNTS_URL="https://account.huly.app" \
|
-e ACCOUNTS_URL="https://account.huly.app" \
|
||||||
-e FRONT_URL="https://huly.app" \
|
-e FRONT_URL="https://huly.app" \
|
||||||
|
-v $(pwd):/data \
|
||||||
hardcoreeng/tool:latest \
|
hardcoreeng/tool:latest \
|
||||||
-- bundle.js import-notion-with-teamspaces /tmp/notion-docs \
|
-- bundle.js import-notion-with-teamspaces /data \
|
||||||
--user jane.doe@gmail.com \
|
--user jane.doe@gmail.com \
|
||||||
--password 4321qwe \
|
--password 4321qwe \
|
||||||
--workspace ws1
|
--workspace ws1
|
||||||
```
|
```
|
||||||
To import Notion workspace without teamspaces or a page with subpages.
|
* To import Notion workspace without teamspaces or a page with subpages.
|
||||||
```
|
```
|
||||||
docker run \
|
docker run \
|
||||||
-e SERVER_SECRET="" \
|
-e SERVER_SECRET="" \
|
||||||
-e ACCOUNTS_URL="https://account.huly.app" \
|
-e ACCOUNTS_URL="https://account.huly.app" \
|
||||||
-e FRONT_URL="https://huly.app" \
|
-e FRONT_URL="https://huly.app" \
|
||||||
|
-v $(pwd):/data \
|
||||||
hardcoreeng/tool:latest \
|
hardcoreeng/tool:latest \
|
||||||
-- bundle.js import-notion-to-teamspace /tmp/notion-docs \
|
-- bundle.js import-notion-to-teamspace /data \
|
||||||
--user jane.doe@gmail.com \
|
--user jane.doe@gmail.com \
|
||||||
--password 4321qwe \
|
--password 4321qwe \
|
||||||
--workspace ws1 \
|
--workspace ws1 \
|
||||||
|
@ -55,7 +55,7 @@ import serverClientPlugin, {
|
|||||||
} from '@hcengineering/server-client'
|
} from '@hcengineering/server-client'
|
||||||
import { getServerPipeline } from '@hcengineering/server-pipeline'
|
import { getServerPipeline } from '@hcengineering/server-pipeline'
|
||||||
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
|
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
|
||||||
import toolPlugin, { connect, FileModelLogger } from '@hcengineering/server-tool'
|
import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool'
|
||||||
import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service'
|
import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@ -75,7 +75,6 @@ import core, {
|
|||||||
systemAccountEmail,
|
systemAccountEmail,
|
||||||
TxOperations,
|
TxOperations,
|
||||||
versionToString,
|
versionToString,
|
||||||
type Client as CoreClient,
|
|
||||||
type Data,
|
type Data,
|
||||||
type Doc,
|
type Doc,
|
||||||
type Ref,
|
type Ref,
|
||||||
@ -297,17 +296,13 @@ export function devTool (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = (await connect(
|
const connection = await createClient(selectedWs.endpoint, selectedWs.token)
|
||||||
selectedWs.endpoint,
|
const acc = connection.getModel().getAccountByEmail(user)
|
||||||
{
|
if (acc === undefined) {
|
||||||
name: selectedWs.workspaceId
|
console.log('Account not found for email: ', user)
|
||||||
},
|
return
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
mode: 'backup'
|
|
||||||
}
|
}
|
||||||
)) as unknown as CoreClient
|
const client = new TxOperations(connection, acc._id)
|
||||||
const client = new TxOperations(connection, core.account.System)
|
|
||||||
await importNotion(client, uploader(selectedWs.token), dir, teamspace)
|
await importNotion(client, uploader(selectedWs.token), dir, teamspace)
|
||||||
await connection.close()
|
await connection.close()
|
||||||
}
|
}
|
||||||
|
@ -201,18 +201,12 @@ export function createModel (builder: Builder): void {
|
|||||||
{ createdBy: 1 },
|
{ createdBy: 1 },
|
||||||
{ createdBy: -1 },
|
{ createdBy: -1 },
|
||||||
{ createdOn: -1 },
|
{ createdOn: -1 },
|
||||||
{ modifiedBy: 1 },
|
{ modifiedBy: 1 }
|
||||||
{ objectSpace: 1 }
|
|
||||||
],
|
],
|
||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
keys: {
|
keys: {
|
||||||
objectSpace: 1,
|
objectSpace: 1
|
||||||
_id: 1,
|
|
||||||
modifiedOn: 1
|
|
||||||
},
|
|
||||||
filter: {
|
|
||||||
objectSpace: core.space.Model
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -659,6 +659,16 @@ export interface DomainIndexConfiguration extends Doc {
|
|||||||
|
|
||||||
export type WorkspaceMode = 'pending-creation' | 'creating' | 'upgrading' | 'pending-deletion' | 'deleting' | 'active'
|
export type WorkspaceMode = 'pending-creation' | 'creating' | 'upgrading' | 'pending-deletion' | 'deleting' | 'active'
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
dataSize: number
|
||||||
|
blobsSize: number
|
||||||
|
|
||||||
|
backupSize: number
|
||||||
|
|
||||||
|
lastBackup: Timestamp
|
||||||
|
backups: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseWorkspaceInfo {
|
export interface BaseWorkspaceInfo {
|
||||||
workspace: string // An uniq workspace name, Database names
|
workspace: string // An uniq workspace name, Database names
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@ -676,4 +686,6 @@ export interface BaseWorkspaceInfo {
|
|||||||
progress?: number // Some progress
|
progress?: number // Some progress
|
||||||
|
|
||||||
endpoint: string
|
endpoint: string
|
||||||
|
|
||||||
|
backupInfo?: BackupStatus
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { AccountRole, Doc, Ref, Timestamp, WorkspaceMode } from '@hcengineering/core'
|
import { AccountRole, Doc, Ref, Timestamp, WorkspaceMode, type BackupStatus } from '@hcengineering/core'
|
||||||
import type { Asset, IntlString, Metadata, Plugin, Resource, Status } from '@hcengineering/platform'
|
import type { Asset, IntlString, Metadata, Plugin, Resource, Status } from '@hcengineering/platform'
|
||||||
import { plugin } from '@hcengineering/platform'
|
import { plugin } from '@hcengineering/platform'
|
||||||
import type { AnyComponent } from '@hcengineering/ui'
|
import type { AnyComponent } from '@hcengineering/ui'
|
||||||
@ -35,6 +35,8 @@ export interface Workspace {
|
|||||||
progress?: number
|
progress?: number
|
||||||
|
|
||||||
lastVisit: number
|
lastVisit: number
|
||||||
|
|
||||||
|
backupInfo?: BackupStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,9 +37,23 @@
|
|||||||
import { workspacesStore } from '../utils'
|
import { workspacesStore } from '../utils'
|
||||||
// import Drag from './icons/Drag.svelte'
|
// import Drag from './icons/Drag.svelte'
|
||||||
|
|
||||||
|
function getLastVisitDays (it: Workspace): number {
|
||||||
|
return Math.floor((Date.now() - it.lastVisit) / (1000 * 3600 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void getResource(login.function.GetWorkspaces).then(async (f) => {
|
void getResource(login.function.GetWorkspaces).then(async (f) => {
|
||||||
const workspaces = await f()
|
const workspaces = await f()
|
||||||
|
|
||||||
|
workspaces.sort((a, b) => {
|
||||||
|
const adays = getLastVisitDays(a)
|
||||||
|
const bdays = getLastVisitDays(a)
|
||||||
|
if (adays === bdays) {
|
||||||
|
return (b.backupInfo?.backupSize ?? 0) - (a.backupInfo?.backupSize ?? 0)
|
||||||
|
}
|
||||||
|
return bdays - adays
|
||||||
|
})
|
||||||
|
|
||||||
$workspacesStore = workspaces
|
$workspacesStore = workspaces
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -181,6 +195,9 @@
|
|||||||
{wsName}
|
{wsName}
|
||||||
{#if isAdmin && ws.lastVisit != null && ws.lastVisit !== 0}
|
{#if isAdmin && ws.lastVisit != null && ws.lastVisit !== 0}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
|
{#if ws.backupInfo != null}
|
||||||
|
{ws.backupInfo.backupSize}Mb -
|
||||||
|
{/if}
|
||||||
({lastUsageDays} days)
|
({lastUsageDays} days)
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -44,6 +44,7 @@ import core, {
|
|||||||
Version,
|
Version,
|
||||||
versionToString,
|
versionToString,
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
|
type BackupStatus,
|
||||||
type Branding,
|
type Branding,
|
||||||
type WorkspaceMode
|
type WorkspaceMode
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
@ -1356,6 +1357,40 @@ export async function updateWorkspaceInfo (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export async function updateBackupInfo (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: Db,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
backupInfo: BackupStatus
|
||||||
|
): Promise<void> {
|
||||||
|
const decodedToken = decodeToken(ctx, token)
|
||||||
|
if (decodedToken.extra?.service !== 'backup') {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
const workspaceInfo = await getWorkspaceById(db, decodedToken.workspace.name)
|
||||||
|
if (workspaceInfo === null) {
|
||||||
|
throw new PlatformError(
|
||||||
|
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: decodedToken.workspace.name })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsCollection = db.collection<Omit<Workspace, '_id'>>(WORKSPACE_COLLECTION)
|
||||||
|
|
||||||
|
await wsCollection.updateOne(
|
||||||
|
{ _id: workspaceInfo._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
backupInfo,
|
||||||
|
lastProcessingTime: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function postCreateUserWorkspace (
|
async function postCreateUserWorkspace (
|
||||||
ctx: MeasureContext,
|
ctx: MeasureContext,
|
||||||
db: Db,
|
db: Db,
|
||||||
@ -2747,6 +2782,7 @@ export function getMethods (): Record<string, AccountMethod> {
|
|||||||
// Workspace service methods
|
// Workspace service methods
|
||||||
getPendingWorkspace: wrap(getPendingWorkspace),
|
getPendingWorkspace: wrap(getPendingWorkspace),
|
||||||
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
||||||
|
updateBackupInfo: wrap(updateBackupInfo),
|
||||||
workerHandshake: wrap(workerHandshake)
|
workerHandshake: wrap(workerHandshake)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@ import { Writable } from 'stream'
|
|||||||
import { extract, Pack, pack } from 'tar-stream'
|
import { extract, Pack, pack } from 'tar-stream'
|
||||||
import { createGunzip, gunzipSync, gzipSync } from 'zlib'
|
import { createGunzip, gunzipSync, gzipSync } from 'zlib'
|
||||||
import { BackupStorage } from './storage'
|
import { BackupStorage } from './storage'
|
||||||
|
import type { BackupStatus } from '@hcengineering/core/src/classes'
|
||||||
export * from './storage'
|
export * from './storage'
|
||||||
|
|
||||||
const dataBlobSize = 50 * 1024 * 1024
|
const dataBlobSize = 50 * 1024 * 1024
|
||||||
@ -498,6 +499,10 @@ function doTrimHash (s: string | undefined): string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupResult extends Omit<BackupStatus, 'backups' | 'lastBackup'> {
|
||||||
|
result: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
@ -531,7 +536,13 @@ export async function backup (
|
|||||||
skipBlobContentTypes: [],
|
skipBlobContentTypes: [],
|
||||||
blobDownloadLimit: 15
|
blobDownloadLimit: 15
|
||||||
}
|
}
|
||||||
): Promise<boolean> {
|
): Promise<BackupResult> {
|
||||||
|
const result: BackupResult = {
|
||||||
|
result: false,
|
||||||
|
dataSize: 0,
|
||||||
|
blobsSize: 0,
|
||||||
|
backupSize: 0
|
||||||
|
}
|
||||||
ctx = ctx.newChild('backup', {
|
ctx = ctx.newChild('backup', {
|
||||||
workspaceId: workspaceId.name,
|
workspaceId: workspaceId.name,
|
||||||
force: options.force,
|
force: options.force,
|
||||||
@ -589,7 +600,8 @@ export async function backup (
|
|||||||
if (lastTx._id === backupInfo.lastTxId && !options.force) {
|
if (lastTx._id === backupInfo.lastTxId && !options.force) {
|
||||||
printEnd = false
|
printEnd = false
|
||||||
ctx.info('No transaction changes. Skipping backup.', { workspace: workspaceId.name })
|
ctx.info('No transaction changes. Skipping backup.', { workspace: workspaceId.name })
|
||||||
return false
|
result.result = false
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastTxChecked = true
|
lastTxChecked = true
|
||||||
@ -613,14 +625,19 @@ export async function backup (
|
|||||||
)) as CoreClient & BackupClient)
|
)) as CoreClient & BackupClient)
|
||||||
|
|
||||||
if (!lastTxChecked) {
|
if (!lastTxChecked) {
|
||||||
lastTx = await connection.findOne(core.class.Tx, {}, { limit: 1, sort: { modifiedOn: SortingOrder.Descending } })
|
lastTx = await connection.findOne(
|
||||||
|
core.class.Tx,
|
||||||
|
{ objectSpace: { $ne: core.space.Model } },
|
||||||
|
{ limit: 1, sort: { modifiedOn: SortingOrder.Descending } }
|
||||||
|
)
|
||||||
if (lastTx !== undefined) {
|
if (lastTx !== undefined) {
|
||||||
if (lastTx._id === backupInfo.lastTxId && !options.force) {
|
if (lastTx._id === backupInfo.lastTxId && !options.force) {
|
||||||
ctx.info('No transaction changes. Skipping backup.', { workspace: workspaceId.name })
|
ctx.info('No transaction changes. Skipping backup.', { workspace: workspaceId.name })
|
||||||
if (options.getConnection === undefined) {
|
if (options.getConnection === undefined) {
|
||||||
await connection.close()
|
await connection.close()
|
||||||
}
|
}
|
||||||
return false
|
result.result = false
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -700,6 +717,11 @@ export async function backup (
|
|||||||
let currentNeedRetrieveSize = 0
|
let currentNeedRetrieveSize = 0
|
||||||
|
|
||||||
for (const { id, hash, size } of currentChunk.docs) {
|
for (const { id, hash, size } of currentChunk.docs) {
|
||||||
|
if (domain === DOMAIN_BLOB) {
|
||||||
|
result.blobsSize += size
|
||||||
|
} else {
|
||||||
|
result.dataSize += size
|
||||||
|
}
|
||||||
processed++
|
processed++
|
||||||
if (Date.now() - st > 2500) {
|
if (Date.now() - st > 2500) {
|
||||||
ctx.info('processed', {
|
ctx.info('processed', {
|
||||||
@ -1034,10 +1056,33 @@ export async function backup (
|
|||||||
backupInfo.lastTxId = lastTx?._id ?? '0' // We could store last tx, since full backup is complete
|
backupInfo.lastTxId = lastTx?._id ?? '0' // We could store last tx, since full backup is complete
|
||||||
await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2), { level: defaultLevel }))
|
await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2), { level: defaultLevel }))
|
||||||
}
|
}
|
||||||
return true
|
result.result = true
|
||||||
|
|
||||||
|
const addFileSize = async (file: string | undefined | null): Promise<void> => {
|
||||||
|
if (file != null && (await storage.exists(file))) {
|
||||||
|
const fileSize = await storage.stat(file)
|
||||||
|
result.backupSize += fileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's calculate data size for backup
|
||||||
|
for (const sn of backupInfo.snapshots) {
|
||||||
|
for (const [, d] of Object.entries(sn.domains)) {
|
||||||
|
await addFileSize(d.snapshot)
|
||||||
|
for (const snp of d.snapshots ?? []) {
|
||||||
|
await addFileSize(snp)
|
||||||
|
}
|
||||||
|
for (const snp of d.storage ?? []) {
|
||||||
|
await addFileSize(snp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await addFileSize(infoFile)
|
||||||
|
|
||||||
|
return result
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.error('backup error', { err, workspace: workspaceId.name })
|
ctx.error('backup error', { err, workspace: workspaceId.name })
|
||||||
return false
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
if (printEnd) {
|
if (printEnd) {
|
||||||
ctx.info('end backup', { workspace: workspaceId.name, totalTime: Date.now() - st })
|
ctx.info('end backup', { workspace: workspaceId.name, totalTime: Date.now() - st })
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import {
|
import core, {
|
||||||
BaseWorkspaceInfo,
|
BaseWorkspaceInfo,
|
||||||
DOMAIN_TX,
|
DOMAIN_TX,
|
||||||
getWorkspaceId,
|
getWorkspaceId,
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
SortingOrder,
|
SortingOrder,
|
||||||
systemAccountEmail,
|
systemAccountEmail,
|
||||||
type BackupClient,
|
type BackupClient,
|
||||||
|
type BackupStatus,
|
||||||
type Branding,
|
type Branding,
|
||||||
type Client,
|
type Client,
|
||||||
type MeasureContext,
|
type MeasureContext,
|
||||||
@ -29,7 +30,7 @@ import {
|
|||||||
type WorkspaceIdWithUrl
|
type WorkspaceIdWithUrl
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||||
import { listAccountWorkspaces } from '@hcengineering/server-client'
|
import { listAccountWorkspaces, updateBackupInfo } from '@hcengineering/server-client'
|
||||||
import {
|
import {
|
||||||
BackupClientOps,
|
BackupClientOps,
|
||||||
SessionDataImpl,
|
SessionDataImpl,
|
||||||
@ -38,6 +39,7 @@ import {
|
|||||||
type PipelineFactory,
|
type PipelineFactory,
|
||||||
type StorageAdapter
|
type StorageAdapter
|
||||||
} from '@hcengineering/server-core'
|
} from '@hcengineering/server-core'
|
||||||
|
import { generateToken } from '@hcengineering/server-token'
|
||||||
import { backup } from '.'
|
import { backup } from '.'
|
||||||
import { createStorageBackupStorage } from './storage'
|
import { createStorageBackupStorage } from './storage'
|
||||||
export interface BackupConfig {
|
export interface BackupConfig {
|
||||||
@ -114,6 +116,23 @@ class BackupWorker {
|
|||||||
): Promise<{ failedWorkspaces: BaseWorkspaceInfo[], processed: number, skipped: number }> {
|
): Promise<{ failedWorkspaces: BaseWorkspaceInfo[], processed: number, skipped: number }> {
|
||||||
const workspacesIgnore = new Set(this.config.SkipWorkspaces.split(';'))
|
const workspacesIgnore = new Set(this.config.SkipWorkspaces.split(';'))
|
||||||
const workspaces = (await listAccountWorkspaces(this.config.Token)).filter((it) => {
|
const workspaces = (await listAccountWorkspaces(this.config.Token)).filter((it) => {
|
||||||
|
const lastBackup = it.backupInfo?.lastBackup ?? 0
|
||||||
|
if ((Date.now() - lastBackup) / 1000 < this.config.Interval) {
|
||||||
|
// No backup required, interval not elapsed
|
||||||
|
ctx.info('Skip backup', { workspace: it.workspace, lastBackup: Math.round((Date.now() - lastBackup) / 1000) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastVisitSec = Math.floor((Date.now() - it.lastVisit) / 1000)
|
||||||
|
if (lastVisitSec > this.config.Interval) {
|
||||||
|
// No backup required, interval not elapsed
|
||||||
|
ctx.info('Skip backup, since not visited since last check', {
|
||||||
|
workspace: it.workspace,
|
||||||
|
days: Math.floor(lastVisitSec / 3600 / 24),
|
||||||
|
seconds: lastVisitSec
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
return !workspacesIgnore.has(it.workspace)
|
return !workspacesIgnore.has(it.workspace)
|
||||||
})
|
})
|
||||||
workspaces.sort((a, b) => b.lastVisit - a.lastVisit)
|
workspaces.sort((a, b) => b.lastVisit - a.lastVisit)
|
||||||
@ -133,6 +152,7 @@ class BackupWorker {
|
|||||||
return { failedWorkspaces, processed, skipped: workspaces.length - processed }
|
return { failedWorkspaces, processed, skipped: workspaces.length - processed }
|
||||||
}
|
}
|
||||||
index++
|
index++
|
||||||
|
const st = Date.now()
|
||||||
rootCtx.warn('\n\nBACKUP WORKSPACE ', {
|
rootCtx.warn('\n\nBACKUP WORKSPACE ', {
|
||||||
workspace: ws.workspace,
|
workspace: ws.workspace,
|
||||||
index,
|
index,
|
||||||
@ -156,13 +176,10 @@ class BackupWorker {
|
|||||||
workspaceName: ws.workspaceName ?? '',
|
workspaceName: ws.workspaceName ?? '',
|
||||||
workspaceUrl: ws.workspaceUrl ?? ''
|
workspaceUrl: ws.workspaceUrl ?? ''
|
||||||
}
|
}
|
||||||
processed += (await ctx.with(
|
const result = await ctx.with('backup', { workspace: ws.workspace }, (ctx) =>
|
||||||
'backup',
|
backup(ctx, '', getWorkspaceId(ws.workspace), storage, {
|
||||||
{ workspace: ws.workspace },
|
|
||||||
async (ctx) =>
|
|
||||||
await backup(ctx, '', getWorkspaceId(ws.workspace), storage, {
|
|
||||||
skipDomains: [],
|
skipDomains: [],
|
||||||
force: false,
|
force: true,
|
||||||
recheck: false,
|
recheck: false,
|
||||||
timeout: this.config.Timeout * 1000,
|
timeout: this.config.Timeout * 1000,
|
||||||
connectTimeout: 5 * 60 * 1000, // 5 minutes to,
|
connectTimeout: 5 * 60 * 1000, // 5 minutes to,
|
||||||
@ -188,7 +205,7 @@ class BackupWorker {
|
|||||||
return (
|
return (
|
||||||
await txAdapter.rawFindAll<Tx>(
|
await txAdapter.rawFindAll<Tx>(
|
||||||
DOMAIN_TX,
|
DOMAIN_TX,
|
||||||
{},
|
{ objectSpace: { $ne: core.space.Model } },
|
||||||
{ limit: 1, sort: { modifiedOn: SortingOrder.Descending } }
|
{ limit: 1, sort: { modifiedOn: SortingOrder.Descending } }
|
||||||
)
|
)
|
||||||
).shift()
|
).shift()
|
||||||
@ -203,9 +220,29 @@ class BackupWorker {
|
|||||||
return this.wrapPipeline(ctx, pipeline, wsUrl)
|
return this.wrapPipeline(ctx, pipeline, wsUrl)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
))
|
)
|
||||||
? 1
|
|
||||||
: 0
|
if (result.result) {
|
||||||
|
const backupInfo: BackupStatus = {
|
||||||
|
backups: (ws.backupInfo?.backups ?? 0) + 1,
|
||||||
|
lastBackup: Date.now(),
|
||||||
|
backupSize: Math.round((result.backupSize * 100) / (1024 * 1024)) / 100,
|
||||||
|
dataSize: Math.round((result.dataSize * 100) / (1024 * 1024)) / 100,
|
||||||
|
blobsSize: Math.round((result.blobsSize * 100) / (1024 * 1024)) / 100
|
||||||
|
}
|
||||||
|
rootCtx.warn('\n\nBACKUP STATS ', {
|
||||||
|
workspace: ws.workspace,
|
||||||
|
index,
|
||||||
|
...backupInfo,
|
||||||
|
time: Math.round((Date.now() - st) / 1000),
|
||||||
|
total: workspaces.length
|
||||||
|
})
|
||||||
|
// We need to report update for stats to account service
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
const token = generateToken(systemAccountEmail, { name: ws.workspace }, { service: 'backup' })
|
||||||
|
await updateBackupInfo(token, backupInfo)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
rootCtx.error('\n\nFAILED to BACKUP', { workspace: ws.workspace, err })
|
rootCtx.error('\n\nFAILED to BACKUP', { workspace: ws.workspace, err })
|
||||||
failedWorkspaces.push(ws)
|
failedWorkspaces.push(ws)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { MeasureContext, WorkspaceId } from '@hcengineering/core'
|
import { MeasureContext, WorkspaceId } from '@hcengineering/core'
|
||||||
import { StorageAdapter } from '@hcengineering/server-core'
|
import { StorageAdapter } from '@hcengineering/server-core'
|
||||||
import { createReadStream, createWriteStream, existsSync } from 'fs'
|
import { createReadStream, createWriteStream, existsSync, statSync } from 'fs'
|
||||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { PassThrough, Readable, Writable } from 'stream'
|
import { PassThrough, Readable, Writable } from 'stream'
|
||||||
@ -14,6 +14,8 @@ export interface BackupStorage {
|
|||||||
write: (name: string) => Promise<Writable>
|
write: (name: string) => Promise<Writable>
|
||||||
writeFile: (name: string, data: string | Buffer) => Promise<void>
|
writeFile: (name: string, data: string | Buffer) => Promise<void>
|
||||||
exists: (name: string) => Promise<boolean>
|
exists: (name: string) => Promise<boolean>
|
||||||
|
|
||||||
|
stat: (name: string) => Promise<number>
|
||||||
delete: (name: string) => Promise<void>
|
delete: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +43,10 @@ class FileStorage implements BackupStorage {
|
|||||||
return existsSync(join(this.root, name))
|
return existsSync(join(this.root, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stat (name: string): Promise<number> {
|
||||||
|
return statSync(join(this.root, name)).size
|
||||||
|
}
|
||||||
|
|
||||||
async delete (name: string): Promise<void> {
|
async delete (name: string): Promise<void> {
|
||||||
await rm(join(this.root, name))
|
await rm(join(this.root, name))
|
||||||
}
|
}
|
||||||
@ -87,6 +93,15 @@ class AdapterStorage implements BackupStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stat (name: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const st = await this.client.stat(this.ctx, this.workspaceId, join(this.root, name))
|
||||||
|
return st?.size ?? 0
|
||||||
|
} catch (err: any) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async delete (name: string): Promise<void> {
|
async delete (name: string): Promise<void> {
|
||||||
await this.client.remove(this.ctx, this.workspaceId, [join(this.root, name)])
|
await this.client.remove(this.ctx, this.workspaceId, [join(this.root, name)])
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { type BaseWorkspaceInfo, type Data, type Version } from '@hcengineering/core'
|
import { type BaseWorkspaceInfo, type Data, type Version, BackupStatus } from '@hcengineering/core'
|
||||||
import { getMetadata, PlatformError, unknownError } from '@hcengineering/platform'
|
import { getMetadata, PlatformError, unknownError } from '@hcengineering/platform'
|
||||||
|
|
||||||
import plugin from './plugin'
|
import plugin from './plugin'
|
||||||
@ -47,6 +47,24 @@ export async function listAccountWorkspaces (token: string): Promise<BaseWorkspa
|
|||||||
return (workspaces.result as BaseWorkspaceInfo[]) ?? []
|
return (workspaces.result as BaseWorkspaceInfo[]) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateBackupInfo (token: string, info: BackupStatus): Promise<BaseWorkspaceInfo[]> {
|
||||||
|
const accountsUrl = getAccoutsUrlOrFail()
|
||||||
|
const workspaces = await (
|
||||||
|
await fetch(accountsUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'updateBackupInfo',
|
||||||
|
params: [token, info]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return (workspaces.result as BaseWorkspaceInfo[]) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTransactorEndpoint (
|
export async function getTransactorEndpoint (
|
||||||
token: string,
|
token: string,
|
||||||
kind: 'internal' | 'external' = 'internal',
|
kind: 'internal' | 'external' = 'internal',
|
||||||
@ -149,7 +167,10 @@ export async function workerHandshake (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkspaceInfo (token: string): Promise<BaseWorkspaceInfo | undefined> {
|
export async function getWorkspaceInfo (
|
||||||
|
token: string,
|
||||||
|
updateLastAccess = false
|
||||||
|
): Promise<BaseWorkspaceInfo | undefined> {
|
||||||
const accountsUrl = getAccoutsUrlOrFail()
|
const accountsUrl = getAccoutsUrlOrFail()
|
||||||
const workspaceInfo = await (
|
const workspaceInfo = await (
|
||||||
await fetch(accountsUrl, {
|
await fetch(accountsUrl, {
|
||||||
@ -160,7 +181,7 @@ export async function getWorkspaceInfo (token: string): Promise<BaseWorkspaceInf
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: 'getWorkspaceInfo',
|
method: 'getWorkspaceInfo',
|
||||||
params: []
|
params: updateLastAccess ? [true] : []
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
|
@ -27,7 +27,7 @@ export async function getWorkspaceInfo (token: string): Promise<ClientWorkspaceI
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: 'getWorkspaceInfo',
|
method: 'getWorkspaceInfo',
|
||||||
params: []
|
params: [true] // Collaborator should update last used time.
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
type UploadedObjectInfo
|
type UploadedObjectInfo
|
||||||
} from '@hcengineering/storage'
|
} from '@hcengineering/storage'
|
||||||
|
|
||||||
|
import { Analytics } from '@hcengineering/analytics'
|
||||||
import { type RawDBAdapter } from '../adapter'
|
import { type RawDBAdapter } from '../adapter'
|
||||||
import serverCore from '../plugin'
|
import serverCore from '../plugin'
|
||||||
import { type StorageConfig, type StorageConfiguration } from '../types'
|
import { type StorageConfig, type StorageConfiguration } from '../types'
|
||||||
@ -166,10 +167,20 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
|
|||||||
|
|
||||||
@withContext('aggregator-make', {})
|
@withContext('aggregator-make', {})
|
||||||
async make (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {
|
async make (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {
|
||||||
for (const a of this.adapters.values()) {
|
for (const [k, a] of this.adapters.entries()) {
|
||||||
|
try {
|
||||||
if (!(await a.exists(ctx, workspaceId))) {
|
if (!(await a.exists(ctx, workspaceId))) {
|
||||||
await a.make(ctx, workspaceId)
|
await a.make(ctx, workspaceId)
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.error('failed to init adapter', { adapter: k, workspaceId, error: err })
|
||||||
|
// Do not throw error in case default adapter is ok
|
||||||
|
Analytics.handleError(err)
|
||||||
|
if (k === this.defaultAdapter) {
|
||||||
|
// We should throw in case default one is not valid
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1664,9 +1664,8 @@ class MongoTxAdapter extends MongoAdapterBase implements TxAdapter {
|
|||||||
@withContext('get-model')
|
@withContext('get-model')
|
||||||
async getModel (ctx: MeasureContext): Promise<Tx[]> {
|
async getModel (ctx: MeasureContext): Promise<Tx[]> {
|
||||||
const txCollection = this.db.collection<Tx>(DOMAIN_TX)
|
const txCollection = this.db.collection<Tx>(DOMAIN_TX)
|
||||||
const exists = await txCollection.indexExists('objectSpace_fi_1__id_fi_1_modifiedOn_fi_1')
|
|
||||||
const cursor = await ctx.with('find', {}, async () => {
|
const cursor = await ctx.with('find', {}, async () => {
|
||||||
let c = txCollection.find(
|
const c = txCollection.find(
|
||||||
{ objectSpace: core.space.Model },
|
{ objectSpace: core.space.Model },
|
||||||
{
|
{
|
||||||
sort: {
|
sort: {
|
||||||
@ -1675,9 +1674,6 @@ class MongoTxAdapter extends MongoAdapterBase implements TxAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (exists) {
|
|
||||||
c = c.hint({ objectSpace: 1, _id: 1, modifiedOn: 1 })
|
|
||||||
}
|
|
||||||
return c
|
return c
|
||||||
})
|
})
|
||||||
const model = await ctx.with('to-array', {}, async () => await toArray<Tx>(cursor))
|
const model = await ctx.with('to-array', {}, async () => await toArray<Tx>(cursor))
|
||||||
|
@ -365,7 +365,7 @@ export async function upgradeModel (
|
|||||||
|
|
||||||
await tryMigrate(migrateClient, coreId, [
|
await tryMigrate(migrateClient, coreId, [
|
||||||
{
|
{
|
||||||
state: 'indexes-v4',
|
state: 'indexes-v5',
|
||||||
func: upgradeIndexes
|
func: upgradeIndexes
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -311,7 +311,6 @@ export function startHttpServer (
|
|||||||
: false,
|
: false,
|
||||||
skipUTF8Validation: true,
|
skipUTF8Validation: true,
|
||||||
maxPayload: 250 * 1024 * 1024,
|
maxPayload: 250 * 1024 * 1024,
|
||||||
backlog: 1000,
|
|
||||||
clientTracking: false // We do not need to track clients inside clients.
|
clientTracking: false // We do not need to track clients inside clients.
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
|
@ -4,7 +4,7 @@ import config from './config'
|
|||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export async function getWorkspaceInfo (token: string): Promise<ClientWorkspaceInfo> {
|
export async function getWorkspaceInfo (token: string, updateLastModified = false): Promise<ClientWorkspaceInfo> {
|
||||||
const accountsUrl = config.AccountsURL
|
const accountsUrl = config.AccountsURL
|
||||||
const workspaceInfo = await (
|
const workspaceInfo = await (
|
||||||
await fetch(accountsUrl, {
|
await fetch(accountsUrl, {
|
||||||
@ -15,7 +15,7 @@ export async function getWorkspaceInfo (token: string): Promise<ClientWorkspaceI
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: 'getWorkspaceInfo',
|
method: 'getWorkspaceInfo',
|
||||||
params: []
|
params: updateLastModified ? [true] : []
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
|
@ -692,7 +692,7 @@ export class PlatformWorker {
|
|||||||
)
|
)
|
||||||
let workspaceInfo: ClientWorkspaceInfo | undefined
|
let workspaceInfo: ClientWorkspaceInfo | undefined
|
||||||
try {
|
try {
|
||||||
workspaceInfo = await getWorkspaceInfo(token)
|
workspaceInfo = await getWorkspaceInfo(token, true)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.ctx.error('Workspace not found:', { workspace })
|
this.ctx.error('Workspace not found:', { workspace })
|
||||||
errors++
|
errors++
|
||||||
|
Loading…
Reference in New Issue
Block a user