diff --git a/README.md b/README.md index c01a2bc58d..1d2b32ba43 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ > > The hosted Huly service is being discontinued because its hosting is no longer being funded. If you keep important data on the hosted platform, export and back it up, and migrate as soon as possible — we can help you move to either a [self-hosted setup](https://github.com/hcengineering/huly-selfhost) or a hosted option. > +> Not sure how? Follow the [backup & restore guide](docs/guides/backup-restore.en.md) for step-by-step instructions on downloading your backup and restoring it elsewhere. +> > The service shutdown is expected on **July 20**. Please make sure to export and migrate your data before then rather than wait until the last day. > > Have questions or want updates? Join the [Huly community](https://link.huly.io/slack) to discuss migration and stay informed, or email us at [artem@hardcoreeng.com](mailto:artem@hardcoreeng.com) with any questions. This affects only the hosted **Huly** service — self-hosted deployments are not affected. diff --git a/docs/guides/backup-restore.en.md b/docs/guides/backup-restore.en.md new file mode 100644 index 0000000000..b3c272376e --- /dev/null +++ b/docs/guides/backup-restore.en.md @@ -0,0 +1,136 @@ +# Backup & Restore Guide + +Download a workspace backup and restore it into another Huly instance — +whether that's a self-hosted deployment or one of the hosted options. This is +the recommended way to move your data off a hosted workspace before it +becomes unavailable. + +> [!TIP] +> Don't want to run your own server? You can also move to one of the +> existing hosted Huly forks instead of self-hosting. We're glad to help +> with the data migration either way — just keep in mind that none of these +> hosting options are free, since keeping servers running costs real money. +> See [Need help?](#need-help) below to get in touch. + +## Overview + +1. **Download** — from *Settings → Backup* in the workspace you want to move, + grab a full copy of your data. +2. **Restore** — replay that backup into the destination workspace using the + platform's admin tool (`dev/tool`). + +## Step 1. Download your backup + +Open the workspace and go to: + +``` +/setting/setting/backup +``` + +(*Settings → Backup* in the left sidebar.) The page shows when the backup was +last taken, how many snapshots and files it contains, and its total size. + +> [!NOTE] +> Backups are taken periodically, not continuously, so the latest backup can +> lag behind your live data by some time. Check **Last backup** on the page +> before you rely on it, and give it time to catch up if you just made +> important changes. + +You have two ways to get the files: + +- **Download full backup** — bundles every backup file into a single `.zip` + you can keep on your computer. A `RESTORE.md` with restore instructions is + included inside the archive. +- **Copy download script** (+ **Copy token**) — copies a small shell script + that downloads every file with `curl`. The script is safe to save or share: + it does **not** contain your token — it reads it from the + `HULY_BACKUP_TOKEN` environment variable, or prompts for it when you run it. + +Either option produces a folder that the restore tool can consume directly +(see Step 2). + +> [!TIP] +> **Large workspace?** Prefer the download script over the button. "Download +> full backup" assembles the whole archive in your browser's memory before +> saving it, which can be slow or fail outright once a workspace has a lot of +> data. The script downloads files one by one with `curl`, so it handles +> large backups more reliably. + +You can also expand **Backup Snapshots** or **Backup Files** to download +individual files, e.g. to verify access or fetch a single snapshot. + +> [!IMPORTANT] +> The **Not backed up** section lists blobs that are intentionally excluded +> from the regular backup (video, audio, and any file larger than the +> server's blob-size limit). Their content isn't in the archive or script +> above — if you need them on the destination, download each one individually +> from that list before you migrate. + +## Step 2. Restore into a Huly instance + +You'll need a Huly platform you control to restore into — either +[huly-selfhost](https://github.com/hcengineering/huly-selfhost), a +self-hosted deployment built from this monorepo, or another hosted instance +where you have admin access. Create the destination workspace first if it +doesn't exist yet. + +Restoring is done with the `backup-restore` command of the platform admin +tool (`@hcengineering/tool`, `dev/tool` in this repo). Exactly how you invoke +it depends on how the destination platform is run: + +- **Docker / self-hosted setup** — run it inside your `tool` container + (e.g. `docker compose run --rm tool backup-restore ...` or + `docker compose exec tool ...`, adjusted to your compose service name). +- **Monorepo checkout with Rush** — from `dev/tool`, run it against your + configured environment, e.g. `rushx run-local backup-restore ...` for a + local dev stack, or your own script that sets the same environment + variables against your real databases. + +In both cases the command and its arguments are the same: + +``` +backup-restore [date] --accounts +``` + +- `` — the folder you downloaded/unzipped in Step 1. +- `` — the destination workspace's identifier. +- `--accounts` — also restores the original users (their profile and social + identities) into the target workspace. Without this flag only documents are + restored, and every member has to be invited again by email. +- `--upgrade` — add this if the destination runs a newer model version than + the backup. +- `-m/--merge` — don't delete documents that are missing from the backup + (useful when merging into a workspace that already has data). + +> [!NOTE] +> Restoring accounts (`--accounts`) needs its own connection to the account +> database, configured via the `ACCOUNT_DB_URL` (and optional +> `ACCOUNT_DB_NS`) environment variables. See +> [`server/backup/README.md`](../../server/backup/README.md) for the full +> reference of flags and per-entry-point environment variables. + +Once the restore finishes, users sign in with their original email via a +one-time code (OTP) — make sure the destination platform can send mail. + +## Need help? + +Our team is happy to help with migration. Join the [Huly community](https://link.huly.io/slack) to ask questions and get updates, or email us directly at [artem@hardcoreeng.com](mailto:artem@hardcoreeng.com). + +## Troubleshooting + +- **401 Unauthorized while downloading** — your token expired, or you're not + an owner/admin of the workspace. Get a fresh token from the Backup page. +- **Members are missing after restore** — re-run with `--accounts` and a + valid `ACCOUNT_DB_URL`; without both, only documents are restored. +- **A file/video is missing on the destination** — check the **Not backed + up** list on the Backup page and download it individually; it isn't part + of the regular backup. +- **Restore fails with a model/version error** — add `--upgrade` if the + destination runs a newer platform version than the source. + +## See also + +- [`server/backup/README.md`](../../server/backup/README.md) — full CLI + reference for `backup-restore`, including environment variables. +- [huly-selfhost](https://github.com/hcengineering/huly-selfhost) — official + self-hosted distribution. diff --git a/foundations/core/packages/text/src/nodes/reference.ts b/foundations/core/packages/text/src/nodes/reference.ts index 613a51eca1..8a9bbf685a 100644 --- a/foundations/core/packages/text/src/nodes/reference.ts +++ b/foundations/core/packages/text/src/nodes/reference.ts @@ -22,6 +22,7 @@ export interface ReferenceNodeProps { id: Ref objectclass: Ref> label: string + fixed?: boolean } export interface ReferenceOptions { @@ -42,7 +43,19 @@ export const ReferenceNode = Node.create({ return { id: getDataAttribute('id'), objectclass: getDataAttribute('objectclass'), - label: getDataAttribute('label') + label: getDataAttribute('label'), + fixed: { + default: null, + parseHTML: (element) => element.getAttribute('data-fixed') === 'true', + renderHTML: (attributes) => { + if (attributes.fixed !== true) { + return {} + } + return { + 'data-fixed': 'true' + } + } + } } }, @@ -91,6 +104,7 @@ function getAttrs (el: HTMLSpanElement): Attrs | false { const id = el.dataset.id?.trim() const label = el.dataset.label?.trim() const objectclass = el.dataset.objectclass?.trim() + const fixed = el.getAttribute('data-fixed') === 'true' if (id == null || label == null || objectclass == null) { return false @@ -99,6 +113,7 @@ function getAttrs (el: HTMLSpanElement): Attrs | false { return { id, label, - objectclass + objectclass, + fixed: fixed || null } } diff --git a/models/card/src/index.ts b/models/card/src/index.ts index a8572e163d..8726893bb5 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -963,6 +963,10 @@ export function createModel (builder: Builder): void { titleProvider: card.function.CardTitleProvider }) + builder.mixin(card.class.Card, core.class.Class, view.mixin.ReferenceObjectProvider, { + provider: card.function.CardReferenceObjectProvider + }) + builder.mixin(card.class.Card, core.class.Class, view.mixin.LinkProvider, { encode: card.function.GetCardLink }) diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 168d30159a..e699731090 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -471,6 +471,10 @@ export function createModel (builder: Builder): void { titleProvider: documents.function.DocumentMetaTitleProvider }) + builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ReferenceVersionsProvider, { + provider: documents.function.DocumentMetaReferenceVersionsProvider + }) + builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.LinkProvider, { encode: documents.function.GetDocumentMetaLinkFragment }) diff --git a/models/server-process/src/index.ts b/models/server-process/src/index.ts index de68b7c1e3..a1179e8160 100644 --- a/models/server-process/src/index.ts +++ b/models/server-process/src/index.ts @@ -419,6 +419,17 @@ export function createModel (builder: Builder): void { }, isAsync: true }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverProcess.trigger.OnExecutionDone, + txMatch: { + _class: core.class.TxUpdateDoc, + objectClass: process.class.Execution, + 'operations.status': ExecutionStatus.Done + }, + isAsync: true + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverProcess.trigger.OnExecutionCreate, txMatch: { diff --git a/models/view/src/index.ts b/models/view/src/index.ts index d8670ffb18..b5b501624c 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -92,6 +92,8 @@ import { type ObjectValidator, type PreviewPresenter, type ReferenceObjectProvider, + type ReferenceVersion, + type ReferenceVersionsProvider, type SortFunc, type SpaceHeader, type SpaceName, @@ -275,6 +277,11 @@ export class TReferenceObjectProvider extends TClass implements ReferenceObjectP provider!: Resource<(client: Client, ref: Ref, doc?: T) => Promise> } +@Mixin(view.mixin.ReferenceVersionsProvider, core.class.Class) +export class TReferenceVersionsProvider extends TClass implements ReferenceVersionsProvider { + provider!: Resource<(client: Client, ref: Ref, doc?: T) => Promise> +} + @Mixin(view.mixin.ObjectTooltip, core.class.Class) export class TObjectTooltip extends TClass implements ObjectTooltip { provider!: Resource<(client: Client, doc?: Doc | null) => Promise> @@ -523,6 +530,7 @@ export function createModel (builder: Builder): void { TGroupping, TObjectIdentifier, TReferenceObjectProvider, + TReferenceVersionsProvider, TObjectTooltip, TObjectIcon, TAttrPresenter, diff --git a/plugins/card-resources/src/components/sections/AttachmentsSection.svelte b/plugins/card-resources/src/components/sections/AttachmentsSection.svelte index 6147dc81c1..17b6e921b4 100644 --- a/plugins/card-resources/src/components/sections/AttachmentsSection.svelte +++ b/plugins/card-resources/src/components/sections/AttachmentsSection.svelte @@ -17,6 +17,9 @@ import { Card } from '@hcengineering/card' import { Attachments } from '@hcengineering/attachment-resources' import { createEventDispatcher, onMount } from 'svelte' + import { getClient } from '@hcengineering/presentation' + import { canChangeDoc } from '@hcengineering/view-resources' + import { permissionsStore } from '@hcengineering/contact-resources' export let readonly: boolean = false export let doc: Card @@ -29,6 +32,11 @@ dispatch('loaded') } }) + + const client = getClient() + const h = client.getHierarchy() + + $: updatePermissionForbidden = doc && !canChangeDoc(doc?._class, doc?.space, $permissionsStore) {#if !hidden} @@ -38,7 +46,9 @@ _class={doc._class} space={doc.space} attachments={doc.attachments ?? 0} - {readonly} + readonly={readonly || + updatePermissionForbidden || + h.getAncestors(doc._class).some((p) => doc.readonlySections?.includes(p))} on:attachments={() => dispatch('loaded')} /> diff --git a/plugins/card-resources/src/index.ts b/plugins/card-resources/src/index.ts index c527522011..95b4d58fa1 100644 --- a/plugins/card-resources/src/index.ts +++ b/plugins/card-resources/src/index.ts @@ -28,6 +28,7 @@ import { editSpace, cardCustomLinkEncode, cardCustomLinkMatch, + cardReferenceObjectProvider, openCardInSidebar, checkRelationsSectionVisibility, checkOldMessagesSectionVisibility, @@ -169,6 +170,7 @@ export default async (): Promise => ({ }, function: { CardTitleProvider: getCardTitle, + CardReferenceObjectProvider: cardReferenceObjectProvider, GetCardLink: getCardLink, CardCustomLinkMatch: cardCustomLinkMatch, CardCustomLinkEncode: cardCustomLinkEncode, diff --git a/plugins/card-resources/src/plugin.ts b/plugins/card-resources/src/plugin.ts index e0b057e5bd..c0dd8e9d35 100644 --- a/plugins/card-resources/src/plugin.ts +++ b/plugins/card-resources/src/plugin.ts @@ -14,7 +14,7 @@ // import card, { type Card, cardId } from '@hcengineering/card' -import { type Permission, type Doc, type Ref } from '@hcengineering/core' +import { type Client, type Permission, type Doc, type Ref } from '@hcengineering/core' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/presentation' import { type AnyComponent } from '@hcengineering/ui/src/types' @@ -62,6 +62,9 @@ export default mergeIds(cardId, card, { }, function: { CardFactory: '' as Resource<(props?: Record) => Promise | undefined>>, + CardReferenceObjectProvider: '' as Resource< + (client: Client, ref: Ref, doc?: T) => Promise + >, FormatCardMarkdownValue: '' as Resource, CheckChildrenSectionVisibility: '' as Resource<(doc: Card) => Promise> }, diff --git a/plugins/card-resources/src/utils.ts b/plugins/card-resources/src/utils.ts index 67867efc0e..154c68f083 100644 --- a/plugins/card-resources/src/utils.ts +++ b/plugins/card-resources/src/utils.ts @@ -486,6 +486,26 @@ export async function getCardTitle (client: TxOperations, ref: Ref, doc?: return ids + ' ' + object.title + ' ' + version } +export async function cardReferenceObjectProvider ( + client: Client, + ref: Ref, + doc?: T +): Promise { + const object = + (doc as unknown as Card | undefined) ?? (await client.findOne(card.class.Card, { _id: ref as any as Ref })) + if (object === undefined) return + + const versioningEnabled = client + .getHierarchy() + .classHierarchyMixin(object._class, core.mixin.VersionableClass)?.enabled + if (versioningEnabled !== true) return object + + const baseId = object.baseId ?? object._id + if (object.isLatest === true) return object + + return (await client.findOne(object._class, { baseId, isLatest: true } as any)) ?? object +} + export async function getCardLink (doc: Card): Promise { const loc = getCurrentResolvedLocation() loc.path.length = 2 diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts index b1953e3ac5..327ba9bf91 100644 --- a/plugins/controlled-documents-resources/src/index.ts +++ b/plugins/controlled-documents-resources/src/index.ts @@ -103,6 +103,7 @@ import { createFolder, createTemplate, deleteFolder, + documentMetaReferenceVersionsProvider, documentIdentifierProvider, controlledDocumentReferenceObjectProvider, projectDocumentReferenceObjectProvider, @@ -442,6 +443,7 @@ export default async (): Promise => ({ DocumentIdentifierProvider: documentIdentifierProvider, ControlledDocumentReferenceObjectProvider: controlledDocumentReferenceObjectProvider, ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider, + DocumentMetaReferenceVersionsProvider: documentMetaReferenceVersionsProvider, ControlledDocumentTitleProvider: getControlledDocumentTitle, DocumentMetaTitleProvider: getDocumentMetaTitle, FormatDocumentMarkdownValue: formatControlledDocumentValue, diff --git a/plugins/controlled-documents-resources/src/plugin.ts b/plugins/controlled-documents-resources/src/plugin.ts index 9b34a5e35f..e3f27f0503 100644 --- a/plugins/controlled-documents-resources/src/plugin.ts +++ b/plugins/controlled-documents-resources/src/plugin.ts @@ -21,7 +21,7 @@ import documents, { import { type Client, type Doc, type Ref, type Space } from '@hcengineering/core' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type AnyComponent, type Location } from '@hcengineering/ui/src/types' -import type { KeyFilter, SortFunc, ViewActionAvailabilityFunction } from '@hcengineering/view' +import type { KeyFilter, ReferenceVersion, SortFunc, ViewActionAvailabilityFunction } from '@hcengineering/view' export default mergeIds(documentsId, documents, { component: { @@ -256,6 +256,9 @@ export default mergeIds(documentsId, documents, { CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise>, - DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise> + DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise>, + DocumentMetaReferenceVersionsProvider: '' as Resource< + (client: Client, ref: Ref, doc?: Doc) => Promise + > } }) diff --git a/plugins/controlled-documents-resources/src/utils.ts b/plugins/controlled-documents-resources/src/utils.ts index 4753141797..d7cc3609d1 100644 --- a/plugins/controlled-documents-resources/src/utils.ts +++ b/plugins/controlled-documents-resources/src/utils.ts @@ -64,7 +64,7 @@ import { createQuery, getClient, MessageBox } from '@hcengineering/presentation' import request, { type Request, RequestStatus } from '@hcengineering/request' import { isEmptyMarkup } from '@hcengineering/text' import { type Location, getUserTimezone, showPopup } from '@hcengineering/ui' -import { type KeyFilter } from '@hcengineering/view' +import { type KeyFilter, type ReferenceVersion } from '@hcengineering/view' import { makeRank } from '@hcengineering/rank' import { getProjectDocumentLink } from './navigation' @@ -842,6 +842,29 @@ export async function getDocumentMetaTitle ( return object.title + ` (${hint})` } +export async function documentMetaReferenceVersionsProvider ( + client: Client, + ref: Ref +): Promise { + const versions = await client.findAll( + documents.class.ControlledDocument, + { attachedTo: ref }, + { + sort: { + major: SortingOrder.Descending, + minor: SortingOrder.Descending + } + } + ) + + return versions.map((doc) => ({ + id: doc._id, + objectclass: doc._class, + label: `${doc.title} (${getDocumentVersionString(doc)})`, + fixed: true + })) +} + export async function controlledDocumentReferenceObjectProvider ( client: Client, ref: Ref, diff --git a/plugins/process-resources/src/components/RunProcessPopup.svelte b/plugins/process-resources/src/components/RunProcessPopup.svelte index 6c11f37da7..9bfcee5e22 100644 --- a/plugins/process-resources/src/components/RunProcessPopup.svelte +++ b/plugins/process-resources/src/components/RunProcessPopup.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> + +