API Reference Mishka CMS Migration v#0.0.1

Copy Markdown View Source

Modules

The migration extension's Ash domain. The extension moves a site from another CMS into a Mishka CMS site. No source ships yet: WordPress is planned as the first, and Joomla and others come later as plugins that reuse the same mapping, dry run, import job and report.

The runs' log (MishkaCmsMigration.MigrationLog) as a source of the core's activity log, so a run's steps show where the CMS shows what happened, each in the sentence the bell says (MishkaCmsMigration.Resources.Notifiers.Steps.sentence/2).

The migration extension's OTP application. It supervises every mishka_installer plugin the extension ships (plugins/0). A plugin registers on the event it listens on once it is supervised here.

The three mishka_installer events an import fires, through MishkaInstaller.Event.Hook.call/3. Each carries the run it belongs to, and meta: %{actor: actor, site_id: site_id} for a plugin that takes call/2. A dry run fires none of them: it writes nothing in the CMS and asks no plugin.

What the migration remembers about one item of a source on a site: what the last dry run would do with it (plan, plan_reason), the CMS record the last import wrote (target_resource, target_id), when that record last changed as the import left it (target_updated_at), what the source said about the item then (title, source_slug, source_modified_at, content_hash, and the fields the CMS has no home for, extra), the SHA-256 of the file an import downloaded for a media item (file_hash, which finds a second attachment of the same bytes), and what the last import that met it did (outcome, reason, run_id). outcome is nil for an item no import has met yet, so a run's counts (MishkaCmsMigration.MigrationRun's created_count and the rest) count what its import did alone. The item's content and its other fields, such as a post's status and author, are not kept: every run reads them from the source again.

One entry of a run's log: a step of its life (step), who took it (user_id, none for a job that failed on its own or that Oban discarded), and what it said: the host the run reads from, a finished step's counts and a failed step's reason. Nothing else of the run is kept, so an entry never holds its connection or its secret.

One move of a site's content from another CMS: which source it reads (source, a MishkaCmsMigration.Source id), which host it reads from (source_host, so a site may take content from several sources, each run showing its own), how it connects (connection, and the secret sealed in connection_secret), what the admin chose (options) and how far it got.

What setting the migration up on a site gives it: the permission names its runs are gated by, admin:dashboard:migration and admin:api:migration, so a site's admin can be granted them. The names are shared by every site, so the first site's install creates them and every later one finds them; the system writes them, whoever asked, through MishkaCmsCore.Install.Bootstrap.ensure_permissions/1, as MishkaCmsCore.Runtime.ExtensionProvision does for every extension. The extension ships no components, pages or snippets.

The Postgres repo the extension's resources write through when it runs on its own. Inside the CMS the resources write through the CMS's repo, which config :mishka_cms_core, repo: … names.

What a dry run or an import did with each item, saved on the run (MishkaCmsMigration.MigrationRun's report) and handed to migration:import:finished.

The items above each item at the source, for the item's :ancestors calculation: its parent's item (source_parent, of its kind and source host on its site), that one's parent, and so on, the top one first, as the Items page draws an item's place (Where it sits) and the Map view places an item under its parents. nil for an item with no parent.

What waits for a decision now, and what an admin decided, among the items of each run's source host, for the run's :decisions calculation: %{waiting: waiting, decided: decided}.

Where the CMS record of each item is, for the item's :record_paths calculation: the answer of MishkaCmsMigration.MigrationRun.record_paths/2 for its kind and the record it was written to, with the record's name (its name, or its title), or nil for an item with no record, a kind with no pages, and a record that is gone or that the reader may not read.

Cancels a run's jobs that are not done — waiting, scheduled, waiting to be tried again or running — with Oban.cancel_all_jobs/1, in the same transaction as the action, once the run is written. A job AshOban queued for one of the run's triggers would find the run out of the trigger's where when it ran and stop there; cancelling it now leaves nothing waiting in the queue for a run that is over.

Connects to a new run's source before the run is saved (MishkaCmsMigration.Source.connect/4, with the run's connection and its sealed secret opened), so a run is only kept for a source it can reach; a refusal is also told on telemetry. What the source says of itself (MishkaCmsMigration.Source.extra/1) becomes the run's extra.

Folds what the New import window's steps ask apart from the run's Advanced options into the run's options: the bring argument (the switches of what to bring, such as "bring_posts" and "comments") and the language argument (the one language to put every item of an unknown or unpublished language in, blank to keep the source's languages), as the run's "one_language".

Keeps an admin's decision on the conflict an item waits on (MishkaCmsMigration.MigrationItem.conflict/1): the choice is put in the item's decisions under that conflict and brings the item back, so every later dry run and import follows it; "leave_out" leaves the item out instead.

Queues the job of a run's AshOban trigger (:trigger) with AshOban.run_trigger/3, in the same transaction as the action, once the run is written, as the actor who asked.

Turns the items argument of a run's :dry_run_items or :import_items, the ids of the items an admin picked on the Items page (MishkaCmsMigration.MigrationItem), into what the run's job is handed (MishkaCmsMigration.Resources.Changes.EnqueueJob): their source ids by kind, and the status the run goes back to once the job is done, the one it is in now.

Keeps why a run failed: the action's error argument in words (MishkaCmsMigration.Report.reason/1) as the run's reason. The error is what AshOban hands its trigger's on_error action after the last attempt, or what Oban says of a job it discarded.

The file a run of a source that reads one (MishkaCmsMigration.Source.file?/0) is handed, kept from the run's creation until it ends (see "The source's file" in MishkaCmsMigration.MigrationRun).

What a run's steps tell the admins, an Ash notifier on MishkaCmsMigration.MigrationRun, run once a step is saved.

Holds a step that queues a job, Run again (:start_dry_run) and the jobs of picked items (:dry_run_items, :import_items), on a run that still holds what its source connects with (MishkaCmsMigration.MigrationRun.connectable?/1): its sealed password, or the file it was handed. A run an earlier release ended cleared both, and its job could only fail on the source's refusal, so it is refused before anything is queued, in words: "This run no longer holds its WordPress password. Start a New import.", or its file, for a source that reads one.

Holds an item's mapped_id (MishkaCmsMigration.MigrationItem's :choose) to a record the item may be mapped to: one of the CMS resource its kind is written to (MishkaCmsMigration.Target), on the item's site, that the admin may read. nil, importing the item as its own, is always taken.

Holds an admin's choice on an item (MishkaCmsMigration.MigrationItem's :leave_out, :bring_back, :choose, :decide_keep and :decide_import, and their master actions) while one of the item's site's runs is dry running or importing: the job reads the choices as it walks, so one made halfway would hold for part of the run alone.

Refuses a run whose source names no installed source plugin (MishkaCmsMigration.Source.fetch/1), on its source field, so a run is never created for a source no job could read.

Holds a run's move to the statuses it may come from: the status the run is in before the action, not the one the action sets.

A CMS a site is moved from: one module that is a mishka_installer plugin on the "migration:sources" event and implements the callbacks below. WordPress is the first (MishkaCmsMigration.Sources.WordPress); Joomla and others come the same way and reuse the mapping, the dry run, the import job and the report.

One item of a source, as every source hands it over: the normalized model the dry run decides on and a target writes from. A source's MishkaCmsMigration.Source.normalize/3 returns the map build/1 turns into this struct; the walk puts :kind in, so a source never names it.

The WordPress source ("wordpress"): a site read over its REST API, signed in to with a user name and an application password (WordPress 5.6+, Users ▸ Profile ▸ Application Passwords). WordPress offers application passwords over HTTPS only, or on a site whose WP_ENVIRONMENT_TYPE is local.

A WordPress post's or page's content.raw as its item's content pieces (MishkaCmsMigration.Source.Item): %{html: ...} for what is converted here, and %{name: ..., attrs: ..., raw: ...} for a block or shortcode left to migration:content:convert (MishkaCmsMigration.Events), in the order they come. call/2 reads the content with LazyHTML, an HTML5 parser, so its markup is never edited as text; only a shortcode, which is text, is read with a pattern.

A WordPress page or post laid out with Elementor as its item's content pieces (MishkaCmsMigration.Source.Item), and the widgets in it the CMS has no equivalent for.

Elementor's Site Settings, the kit a site's Elementor pages are drawn with: its global colours and fonts, which a widget names by id (__globals__), and the theme tokens of the CMS they become.

A WordPress export file (WXR, what Tools ▸ Export makes) read as it streams by (MishkaCmsMigration.Sources.WordPressFile): fold/3 hands each child of its <channel>, one at a time, to a function, and never holds more of the file than the child it is reading.

The WordPress export file source ("wordpress_file"): a site read from the file WordPress makes under Tools ▸ Export (WXR), for a site whose REST API cannot be reached or signed in to. It gives the same items as the WordPress source (MishkaCmsMigration.Sources.WordPress), so a site moved from its export lands in the CMS as it does from its REST API, but for what an export does not carry (below), and a run of either finds what a run of the other wrote (both read from the same host).

Where one kind of item goes in the CMS: one module that is a mishka_installer plugin on the "migration:targets" event and names an existing CMS resource and the create and update actions that write it. The extension adds no write path of its own: every record is written by that action, as the admin who pressed Import and under the run's site, so the resource's policies, validations and plugins decide as they do for anything else.

The "user" items of a source, its authors, written as the Blog's bloggers (MishkaBlog.Blogger), each the profile of a user of the run's site, through the :create and :update an admin's own blogger form runs, on the run's site and as the admin who pressed Import: the admin needs the Blog's admin:dashboard:blog:post, and the Blog's own rules decide the rest, such as one slug and one profile per user on a site, and that a profile names a user of its own site.

The "category" items of a source written as the Blog's categories (MishkaBlog.Collection), through the :create and :update an admin's own category form runs, on the run's site and as the admin who pressed Import: the admin needs the Blog's admin:dashboard:blog:post, and the Blog's own rules decide the rest, such as one slug per site and how a slug is written (its slugify).

The "comment" items of a source written as the Blog's comments (MishkaBlog.Comment), through the :create and :update an admin's own comment form runs, on the run's site and as the admin who pressed Import: the admin needs the Blog's admin:dashboard:blog:comment to write them and admin:dashboard:blog:post to find the posts and bloggers they belong to, and the Blog's own rules decide the rest, such as how a comment's text is cleaned (it is plain text). The Blog's :create keeps the author it is given (author_id), or a guest's name, instead of making the acting admin the author.

The "post" items of a source written as the Blog's posts (MishkaBlog.BlogPost), through the :create and :update an admin's own post form runs, on the run's site and as the admin who pressed Import: the admin needs the Blog's admin:dashboard:blog:post, and the Blog's own rules decide the rest, such as how a slug is written (its slugify), how its content is sanitized and its code blocks highlighted.

The "tag" items of a source written as the Blog's tags (MishkaBlog.Tag), through the :create and :update an admin's own tag form runs, on the run's site and as the admin who pressed Import: the admin needs the Blog's admin:dashboard:blog:post, and the Blog's own rules decide the rest, such as one slug per site and how a slug is written (its slugify).

The "menu" items of a source: every menu of the site, classic and block (MishkaCmsMigration.Sources.WordPress.Navigation), written where an admin says it goes. A menu becomes the links of the site's layout, the core's own navigation, through the :update an admin's layout form runs (MishkaCmsCore.Runtime.Layout), on the run's site and as the admin who pressed Import: the admin needs the core's admin:dashboard:runtime:layout, and the core's own rules decide the rest, such as that the layout's template must compile.

The "media" items of a source, its files, written to the CMS's own media library (MishkaCmsCore.Runtime.Media), on the run's site and as the admin who pressed Import: a file is stored and its record made by the core's :upload, the action the media chooser of an admin's forms runs, and written again by the :update an admin's own media form runs. The admin needs the core's admin:dashboard:runtime:media, and the core's own rules decide the rest: which types it keeps and how big, that a file's first bytes are the type its name says (MishkaCmsCore.Runtime.FileSignature), where the file is stored, and that a name is unique within a site.

The "page" items of a source written as the CMS's own pages (MishkaCmsCore.Runtime.Page), through the :create and :update an admin's own page form runs, on the run's site and as the admin who pressed Import: the admin needs the core's admin:dashboard:runtime:page, and the core's own rules decide the rest, such as that a path is unique within a site and that a page's template must compile.

The "setting" items of a source, the site's own name and pictures, written to the run's site's settings (MishkaCmsCore.Runtime.Setting) through the :set the Settings page's save runs (MishkaCmsCore.Settings.put_as/4), as the admin who pressed Import: the admin needs what writing the setting takes (MishkaCmsCore.Settings.write_permission/1, admin:dashboard:setting for these), and the core's own rules decide the rest.

The "stylesheet" items of a source, such as Elementor's global colours and fonts as theme tokens (MishkaCmsMigration.Sources.WordPress.ElementorKit), written as a stylesheet of the run's site (MishkaCmsCore.Runtime.Stylesheet) through the :create and :update the Stylesheets page runs, as the admin who pressed Import: the admin needs admin:dashboard:runtime:stylesheet (or the code permission), and the core's own rules decide the rest. A stylesheet Tailwind will not build is refused (MishkaCmsCore.Runtime.Resources.Changes.StylesheetCompiles), and each write builds the site's CSS again.

The existing/2 (MishkaCmsMigration.Target) of a target whose resource keeps one record per slug on a site, such as the Blog's tags and categories: an item whose slug the run's site already holds a record with, in any language, as the Blog keeps one slug per site, waits for an admin's decision as "slug_taken" (MishkaCmsMigration.MigrationItem's Decide…). Connect to the existing record connects the item to it ("keep"), and Import as new with another slug ("import") writes it as its own, with the slug wanted/2 makes and the report saying so (note/2, "another_slug").

What a post or a page may wait on before it is written, for the targets that write it (MishkaCmsMigration.Targets.CmsPage, MishkaCmsMigration.Targets.BlogPost), asked in this order, the first that waits answering: its language, which the site may not publish in or the source may not say (MishkaCmsMigration.Targets.Helpers.Editions), the widgets of a page builder the CMS has no equivalent for (MishkaCmsMigration.Targets.Helpers.Widgets), its scripts and inline event handlers (MishkaCmsMigration.Targets.Helpers.Scripts), and the video players the site does not allow yet (MishkaCmsMigration.Targets.Helpers.Frames). An admin decides each in turn.

An item's language and the other editions of it, for the targets whose resource keeps one record per language, joined by language_group_id (MishkaCmsCore.LanguageEditions): the core's pages (MishkaCmsMigration.Targets.CmsPage) and the Blog's posts, categories and tags (MishkaCmsMigration.Targets.BlogPost, MishkaCmsMigration.Targets.BlogCollection, MishkaCmsMigration.Targets.BlogTag). A source names them in the item's "language" and "translations" (%{"en" => "121"}, the source id of each other edition by language), as the WordPress source reads them from Polylang or WPML (MishkaCmsMigration.Sources.WordPress, "Languages").

The video players a post's or a page's content frames, allowed on the run's site, for the targets that write the content (MishkaCmsMigration.Targets.BlogPost, MishkaCmsMigration.Targets.CmsPage). The CMS's Content-Security-Policy lets a page frame only the origins of the site's security.frame_origins setting (Sites pages may embed), so an imported YouTube or Vimeo player would show nothing without its origin there.

Whether the links an import writes may not match the site's addresses, for the targets that write links (MishkaCmsMigration.Targets.CmsLayout).

The records a query finds for a target's existing/2 (MishkaCmsMigration.Target), read as the admin running the job under the run's site and the CMS's own policies.

The scripts and inline event handlers a post's or a page's content holds, for the targets that write the content (MishkaCmsMigration.Targets.CmsPage, MishkaCmsMigration.Targets.BlogPost): each <script> element and each on… attribute (onclick, onerror, ...), whatever the source and the editor that made the content.

An item's "seo" (MishkaCmsMigration.Sources.WordPress reads it from Yoast SEO and Rank Math) as the seo_tags a CMS page or a Blog post stores, for the targets that write them (MishkaCmsMigration.Targets.CmsPage, MishkaCmsMigration.Targets.BlogPost). Each tag is written as the core's head reads a stored tag (MishkaCmsCore.Runtime.Seo.stored_tag/3), and replaces the default the head would draw for it

The status of a source item as a target that publishes it reads it (MishkaCmsMigration.Targets.BlogPost, MishkaCmsMigration.Targets.CmsPage): the item's "status", except that an item behind a password (a non-empty "password" in its extra) is "password" whatever its status says, since the CMS has no password-protected record to write it as.

The widgets of a page builder an item was laid out with that the CMS has no equivalent for, such as an Elementor form, slider or map, for the targets that write the content (MishkaCmsMigration.Targets.CmsPage, MishkaCmsMigration.Targets.BlogPost). The source names them in the item's "unsupported_widgets", and puts what the builder drew for each in its place in the content, its fallback HTML (MishkaCmsMigration.Sources.WordPress.Elementor).

The CMS records the same run, or an earlier one, wrote for a source's items of another kind, for a target whose record points at them, such as a post's redirect at its post or a post's image at its file: the items are read by the run's source host, the kind and their source ids (MishkaCmsMigration.MigrationItem), and the records they were written to by their ids, as the admin running the job (MishkaCmsMigration.Targets.Helpers.Read).

The "post_redirect" items of a source, each post's old address, written as the CMS's own redirect rules (MishkaCmsCore.Runtime.Redirect), through the :create and :update an admin's own redirect form runs, on the run's site and as the admin who pressed Import: the admin needs the core's admin:dashboard:runtime:redirect, and the core's own rules decide the rest, such as how an address is stored and that one site redirects an address once.

The migration jobs: AshOban's triggers on MishkaCmsMigration.MigrationRun generate the two workers, MishkaCmsMigration.Workers.DryRunWorker (:run_dry_run) and MishkaCmsMigration.Workers.ImportWorker (:run_import), each three attempts of an hour, run as the admin who started the run (MishkaCmsMigration.Workers.Helpers.Actor). What a job does is MishkaCmsMigration.Workers.Helpers.Pipeline. One run of a site is dry running or importing at a time, held by the run's :one_active_per_site identity. Oban stops an attempt that runs longer than its hour and tries the job again; an import's next attempt skips what the last one wrote and recorded, so a large site goes on where it stopped.

Who a migration job runs as: the admin who started its run. The AshOban.ActorPersister of MishkaCmsMigration.MigrationRun's triggers keeps the user's id in the job (store/1) and loads the user with their permissions when the job runs (lookup/1), so a target writes as that admin under the CMS's own policies, with the permissions they hold then. A user who is gone fails the attempt.

The CMS records an admin archived. A resource with AshArchival.Resource hides an archived record from its reads, so an item that remembers one (MishkaCmsMigration.MigrationItem's target_id or mapped_id) reads as though it were deleted, and a write that points at it, such as a post's tags, is refused as a record "not found".

What a dry run or an import does with each item of a page (call/3), against what earlier runs recorded for the site (MishkaCmsMigration.MigrationItem), found by the run's source host, the item's kind and its source id. An admin's choice on the item comes first, before anything the source or the CMS says

Fails the run of a migration job Oban discarded — its last attempt timed out or crashed past every rescue, so AshOban never ran the trigger's on_error — with Oban's reason (MishkaCmsMigration.MigrationRun's :fail), which an admin can Run again. It runs outside any job, so it acts as the system (authorize?: false), and hands :fail the job as AshOban hands it its trigger's on_error (context.ash_oban.job), so what the run's steps tell says which step failed (MishkaCmsMigration.Resources.Notifiers.Steps). A run no longer dry running or importing is left as it is. Attached to Oban's [:oban, :job, :exception] telemetry by MishkaCmsMigration.Workers.attach/0.

What the items of a page of a job of picked items need that the job was not asked for (MishkaCmsMigration.Workers.Helpers.Pipeline): each item's parent and the items of other kinds its record points at (MishkaCmsMigration.Target's needs/1). A full run walks a parent before its children and a kind before the kinds that point at it; a job of picked items brings nothing else, so what it needs comes from what earlier imports recorded (MishkaCmsMigration.MigrationItem), read in one query for the page, by the run's source host, the kinds and the source ids. What the job was asked for too is never waited on: the walk brings it first.

What a migration job does with its run: the manual update/3 behind MishkaCmsMigration.MigrationRun's :run_dry_run and :run_import, the actions AshOban's triggers of the same names run (MishkaCmsMigration.Workers.DryRunWorker, MishkaCmsMigration.Workers.ImportWorker). It walks the run's source as the admin who started the run and saves what came of it on the run.

What happens to one item once it is decided (MishkaCmsMigration.Workers.Helpers.Decision), and its entry in the report (call/3, answering MishkaCmsMigration.Report.entry/5).

The Import button on the Blog dashboard's header, a "blog:dashboard:actions" plugin (MishkaBlog.Dashboard.Actions). It opens the migration page (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Home.Index), whose New import form offers the Blog's choices; the form is not limited to the Blog's kinds.

The flashes of the migration pages, said by what they mean and drawn with the kinds the core has.

The migration's admin page: a card for each installed source (MishkaCmsMigration.Source.all/0), and under them the runs of the admin's site, every site's for a master, as a Gervaz table (see MishkaCmsMigration.MigrationRun for its columns and row actions). The table follows the runs' PubSub notifications, which this page hands on to it.

A run's expanded row on the migration page (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Home.Index), drawn as the admin dashboard's cards are drawn (MishkaCmsCore.Dashboard.Widget). panel/1 takes the run as the page reads it.

The steps of the New import window (MishkaCmsMigration.MigrationRun's form), for an admin who wants few choices. The form is Gervaz's :wizard layout: every step is in the form and only the current one shows, so an answer stays while the admin moves between them; Next checks the step before it moves on, Enter in a field is Next, and only the review starts the run

Where an item sits at the source, drawn in its expanded row on the Items page (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Item.Index): the item under its parents (MishkaCmsMigration.MigrationItem's :ancestors), the top one first, each with where it stands (MishkaCmsMigration.MigrationItem.standing/1): Needs your decision, Import failed (with why, from its last import), Decided: ready to import, Not imported yet, Imported, Connected, Left out by you or Not in the source. A decided item whose last import failed says why under it too. An item with no parent draws nothing.

The Items page's Decisions panel (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Item.Index): what waits for a decision no admin has made among the items of the run's source host, grouped, so a whole group is decided in one step instead of item by item. It reads the groups in one query (MishkaCmsMigration.MigrationItem.waiting_groups/3) once the page is connected, and again once after any change to the items it is told of (refresh: true, which the page sends for every item's broadcast): a refresh asked for while one is read, or while a decision is applied, is read once that is done, so a decision over a thousand items reads the groups once more, not a thousand times. Nothing waits, nothing is drawn.

A run's Items page, /admin/dashboard/migration/runs/:run/items: the items the migration keeps of the run's source host on the run's site (MishkaCmsMigration.MigrationItem), as the Gervaz table that resource defines. It is every item of that host, whichever run met it last, each with what the last dry run would do with it and what the last import did, and its "Imported by this run" filter narrows them to the ones this run's import met last.

What came of an import of several items, a branch imported parents first or the rows picked together, in the one flash the Items page shows once its job settled them (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Item.Index), each item named with its outcome in its row's words (MishkaCmsMigration.MigrationRun.reason_label/2), what failed first

The Items table's Map view (MishkaCmsMigration.MigrationItem's switchable_templates): a Gervaz table template that draws the items as the source nests them, for deciding where each part of a site goes before it is imported. The List view is the stock MishkaGervaz.Table.Templates.Table, and the table's template switcher moves between them.

The warning a run shows when the links an import writes may not match the site's language addresses (MishkaCmsMigration.Targets.Helpers.LanguageUrls.cause/2): the source runs Polylang or WPML, or the site gives every language a prefix. warning/1 draws it as the admin's own warning (Gervaz's :warning alert, MishkaGervaz.UIAdapters.Tailwind.alert/1) in a run's expanded row (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Home.RunDetails) and on the Items page's head (MishkaCmsMigrationWeb.Live.Admin.Dashboard.Item.Index): what the cause is, what is affected, a tip and a suggestion. A run with no cause draws nothing. The cause is read from the run's extra and the site's setting, which the settings cache answers, so it adds no query.

The warning for the users an import created (MishkaCmsMigration.Targets.BlogBlogger): a source's password hashes cannot be carried over, so such a user signs in with Forgot password or a social sign-in. Both parts draw it as the admin's own warning (Gervaz's :warning alert, MishkaGervaz.UIAdapters.Tailwind.alert/1), as MishkaCmsMigrationWeb.Live.Admin.Dashboard.LanguageNotice does

The migration's routes: its admin page, /admin/dashboard/migration, and a run's Items page, /admin/dashboard/migration/runs/:run/items.

The migration's admin page and its sidebar entry, a "sidebar:menus" plugin (MishkaCmsCore.Sidebar). It lands in the core's Plugins section, and the core never names it: removing this extension removes the entry.