Skip to content

Latest commit

 

History

History
106 lines (76 loc) · 22.9 KB

File metadata and controls

106 lines (76 loc) · 22.9 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

Ignition Git Module — a Java module for the Inductive Automation Ignition SCADA platform (8.3.0+) that embeds a Git client into the Ignition Designer. It supports commit, push, fetch (without merge), pull (with merge-conflict resolution), revert, branch management, snapshotting gateway-side resources (tags/themes/images) into the project, and both remote (clone) and local-only repository initialization — all from the Designer's dockable panels and status bar. It also versions the gateway data directory (config-as-code) in a separate repo, surfaced through a dedicated React gateway web page (Platform → System → "Versioning") — config changes are auto-committed as they happen; the page provides history/restore. Originally built by AXONE-IO, maintained by Operametrix.

Build Commands

./gradlew build           # produces build/Git.modl (also builds the web-ui React bundle)
./gradlew :web-ui:webpack # build only the gateway web page bundle

The :web-ui subproject uses the com.github.node-gradle.node plugin to download Node 18 and run yarn/webpack; the first build needs network access (IA Nexus node-packages registry + nodejs.org). To auto-fix ESLint/Prettier formatting (the webpack build fails on Prettier violations), run node_modules/.bin/prettier --write "src/**/*.{ts,tsx}" from web-ui/.

No automated tests. Testing is manual: install the .modl on an Ignition gateway and exercise the Designer UI and the gateway Versioning page.

Architecture

Gradle multi-module project following the Ignition Module SDK pattern:

common    (scope: DG)   — RPC interface + abstract delegation base
designer  (scope: D)    — Designer UI: dockable panels, popups, status bar
gateway   (scope: G)    — all git operations + persistence + config-as-code REST routes
web-ui    (no scope)    — React gateway page (config-as-code), bundled into the gateway jar

Scopes: D = Designer, G = Gateway. The Vision client scope is unused — there is no system.git.* script module on Vision clients. The root build.gradle.kts (io.ia.sdk.modl plugin) assembles the .modl.

Key design patterns

Hooks are the per-scope entry points (Ignition setup/startup/shutdown):

  • DesignerHook — builds the status bar, the dockable Commit/History panels, and a user-verification timer; talks to the gateway via GatewayConnection.getRpcInterface(...). If isProjectRegistered() is false it shows a minimal "Configure" + user-button bar so credentials can be added before init; after init via InitRepoPopup it calls reinitializeAfterSetup() to build the full bar. A 1-second panelVisibilityTimer re-shows the Commit/History panels across workspace switches (checks hidden / null-in-DockingManager / !isDisplayable()). Exposes a static instance for GitActionManager callbacks.
  • GatewayHook — registers the six resource types and starts their NamedResourceHandlers, runs the one-time legacy SimpleORM→resource importer, and registers the gateway RPC implementation (getRpcImplementation()). Also wires the config-as-code feature: registers the React page in the NavigationModel (Platform → System → "Versioning"), overrides getMountPathAlias() (git-config) / getMountedResourceFolder() (mounted), and mounts the REST routes in mountRouteHandlers(RouteGroup).

RPC pattern (8.3 module RPC): GitScriptInterface (common) is the contract, annotated @RpcInterface(packageId="com.operametrix.ignition.git") and exposing a shared SERIALIZER. The serializer is a custom ProtoRpcSerializer.newBuilder() instance — DEFAULT_INSTANCE has no Dataset support, so it registers a Java-serialization BinaryAdapter for Dataset/BasicDataset (ObjectSerializers.forUnsafeObject); without it every Dataset-returning RPC round-trips empty with no error. AbstractScriptModule (common) is a plain abstract base that delegates each interface method to a …Impl abstract method, supplied by GatewayScriptModule (gateway) — which must implements GitScriptInterface directly (8.3's RpcDelegate discovers @RpcInterface only on the concrete class's direct interfaces, no superclass walk). The gateway registers it via GatewayHook.getRpcImplementation()GatewayRpcImplementation.of(SERIALIZER, scriptModule); the Designer obtains a proxy via GatewayConnection.getRpcInterface(SERIALIZER, "com.operametrix.ignition.git", GitScriptInterface.class).

Designer project refresh: after any gateway-side operation that mutates the Ignition project (pull, checkout, init, snapshot), the Designer must call GitBaseAction.pullProjectFromGateway(). Under 8.3's resource model this (a) discards stale local edits per ChangeOperation via typed DesignableProject.discardChanges(ResourcePath) — so a deliberate checkout doesn't open the Resolve-Conflicts dialog (the gateway is authoritative; uncommitted work is preserved by gateway-side git stash/restore) — then (b) reflectively calls the public IgnitionDesigner.updateProject(), the 8.3 successor to the removed private pullAndResolve(). closeAllEditorTabs() runs first and looks up TabbedResourceWorkspace.close(common.resourcecollection.ResourcePath, boolean) (the 8.3 resource overhaul moved ResourcePath from common.project.resource.* to common.resourcecollection.*). Without this refresh the gateway changes (via GitProjectManager.importProject()) won't show in the Designer.

Gateway data-directory versioning (config-as-code) — a separate git repo from the per-project repos, rooted at the data directory itself (<dataDir>/.git, via GitManager.getDataFolderPath()). It tracks gateway config (primarily <dataDir>/config/) using a .gitignore based on IA's version-control-guide template plus projects/ excluded — so the per-project repos under projects/ are untouched (no nesting/submodules). Orchestrated by DataDirGitManager (gateway), a thin static layer over the working-dir-agnostic GitManager primitives:

  • isInitialized() = <dataDir>/.git exists (no persistence record; repo state lives in .git). initRepo is explicit (never auto-run at startup) — git init + write .gitignore + baseline commit.
  • getStatus() is a plain porcelain listing scoped to config/.gitignore (the project-resource hasActor/getActor/filterMetadataOnlyChanges helpers must not be applied to config files); JSON key-ordering noise is suppressed via GitManager.filterJsonOrderingChanges.
  • Auto-commit: ConfigAutoCommitter, a ResourceCollectionListener registered via ConfigurationManager.addListener in GatewayHook.startup — the manager-level listener is the only live notification surface; per-resource ResourceListeners on getConfigCollection() are never notified (that call builds a snapshot per invocation). Events within a 2s quiesce window coalesce, so one gateway operation plus its follow-up resources (e.g. a device's System tag definitions) lands as one commit via commitAllIfDirty (no-op on a clean tree, so the post-restore scan doesn't double-commit; gitignored local collection skipped). Manager events carry no resource detail, so the message (changed files grouped into resource dirs) comes from the git status; the author is the gateway system name. commitLeftovers() runs 10s after startup to sweep changes made while the gateway/module was offline (the one case the listener can't see — even IaC disk edits go through Scan File System, which fires the listener). There is no manual commit: no /commit route, and the page is history + restore only (no Uncommitted Changes panel).
  • Restore = GitManager.restoreTree(path, hash) (resets the working tree/index to exactly the target tree — overwrites dirty tracked files, removes tracked files added since, and git cleans untracked non-ignored files so uncommitted changes are discarded; keeps HEAD on the branch — unlike the detaching checkoutCommit) then a forward "Restore config to " commit, then applyConfigToRunningGateway() = GatewayContext.getConfigurationManager().requestScan().join(), which applies the on-disk config to the running gateway with no restart. A single static DATA_DIR_LOCK serializes commit/restore/status against concurrent gateway config writes.
  • Remote (manual sync only): GitConfigRemoteRecord is a gateway-level singleton (URI, branch — default main, credential FK). DataDirGitManager.push() pushes HEAD:refs/heads/<branch> and advances the local refs/remotes/origin/<branch> tracking ref; no auto-push — only the header's Remote Sync button (Refresh icon). GET /remote returns ahead = DataDirGitManager.aheadCount() (commits on HEAD not reachable from the tracking ref); the header shows a bold "N not synced" (var(--warning)) / "✓ Up to date" indicator and the Sync button is primary only when diverged (the query polls 15s so the count tracks auto-commits). The History list badges only the two branch-tip commits — Local (HEAD, alt chip) / Remote (tracking ref, neutral info chip) — like git ref pointers.
  • Config UI = a lateral drawer (RemoteSync header + ConfigDrawer), styled like the platform Redundancy page: a primary "Configure Versioning" button (trailing SettingsGw cog) opens a right-anchored Drawer + DrawerTemplate (theme=GREY, size=SMALL) whose body is Form + FormControlInput + Card sections (react-hook-form; react-hook-form is a shared webpack external). The remote's secret is entered inline (no credential dropdown, no popup): an Embedded/Referenced Radio, then either the typed secret or provider/secret TextAutocompletes (disabled + red "No Providers Exist" when none). No credential name is asked — handleSaveRemote creates/updates one dedicated credential record in place (auto-named Config repository (<host>)) from the inline secret and links it; handleGetRemote returns mode/username/referenced-provider/secret for prefill (never the embedded secret); handleTestRemote tests with the inline secret via GitManager.setAuthenticationRaw (referenced secrets resolved through Secret.create), falling back to the saved credential when editing without re-entering. A Danger Zone card (red header) holds Delete versioningPOST /deinitDataDirGitManager.deleteRepo() (removes <dataDir>/.git + .gitignore + remote record, keeps credentials). WebUI confirm modals must use modalConfig.confirmationText (NOT descriptionText, which only renders for type:"primary"); FormControlInput's disabled only greys the label — disable the input via otherProps={{disabled}}.
  • Caveat: inline encrypted secrets in config/ are tied to the gateway's encryption key (keystore/certs are gitignored) — same-gateway restore only; the web page's Restore dialog warns about this and about live-resource reconfiguration.

The page is React (8.3 gateway pages are React-only via NavigationModel; Wicket config pages are gone, and there is no module-accessible API for a global banner-on-all-pages or a dynamic nav badge — verified against gateway-api-8.3.6). It talks to the gateway via REST routes mounted in GatewayHook.mountRouteHandlers (NOT the RPC interface), under /data/git-config/…: GET /status|/history|/commit-files|/file-diff|/remote|/secret-providers, POST /restore|/init|/deinit|/remote|/remote-remove|/remote-test|/push. Reads require PermissionType.READ, mutations WRITE; the acting author is RequestContext.getActor(). The frontend lives in web-ui/ and is built from the standard @inductiveautomation/ignition-web-ui components (DataGrid, Chip, Button, PageHeader, Modal, Loading, Tooltip, and the drawer/form set Drawer/DrawerTemplate/Card/Form/FormControlInput/Radio/SelectInput/TextInput/TextArea/TextAutocomplete) — imported through src/webui.ts, a one-line shim that re-exports them cast to any (the package publishes strict internal prop types, e.g. DataGrid requires paginationParams/setTableQueryParams that have runtime defaults; the shim lets us pass only the props we need, mirroring the storybook examples). RTK Query targets a single BASE constant in src/config.ts (adjust if the live route prefix differs from /data/git-config); the base query lazily fetches /csrf and attaches the X-CSRF-Token header on mutations (the gateway's web-session access control rejects unsafe methods without it). Webpack emits a UMD bundle to mounted/gitConfig.js, packed into the gateway jar via modlImplementation(project(":web-ui")), served at /res/git-config/gitConfig.js, and mounted as component GitConfigPage.

Designer popups are Swing JDialogs parented to the Designer frame (SwingUtilities.getWindowAncestor(parent) so they overlay correctly on macOS fullscreen); abstract callbacks are overridden in anonymous subclasses inside GitActionManager. Concrete RPC signatures and callback names live in the code — don't duplicate them here.

  • CommitPopup — pick changes + message; "Amend last commit" pre-fills the last message and allows message-only amend; double-click a row → diff.
  • DiffViewerPopup — side-by-side LCS line diff (green added / red removed, synced scroll); default headers HEAD/Working Tree, overridable (used by MergeConflictPopup and CommitDetailPopup).
  • CommitDetailPopup — files in one commit + Checkout / Revert Commit; double-click a file → historical diff.
  • MergeConflictPopup — per-file Accept Ours/Theirs, conflict diff, global Accept-All/Abort/Complete; window-close confirms abort so the repo can't be left conflicted.
  • PullPopup / PushPopup / FetchPopup — remote selector; the lightweight Push/Fetch popups only appear with 2+ remotes (single-remote acts immediately).
  • BranchPopup — local/remote lists, header icon buttons (create / refresh / refresh-from-remote), context-menu checkout/delete, current branch highlighted.
  • CreateBranchPopup — branch name + Create (always from HEAD).
  • InitRepoPopupCardLayout wizard: Choose → Remote (URI + credential dropdown filtered by URI scheme + Configure…) or Local (no fields; commit email comes from the Ignition user profile). Only a credential FK is passed, never inline credentials.
  • RemotesPopupCardLayout list/form for named remotes; the form picks a saved credential (stores SshKeyId/HttpsCredentialId FK) and can open UserCredentialsPopup inline. Reached from the status-bar remotes button; the status-bar user icon opens UserCredentialsPopup directly (no per-project email popup).
  • UserCredentialsPopup — user-level SSH keys + per-host HTTPS credentials, with provider hint text (GitHub/GitLab PAT, Azure PAT/empty-username, Bitbucket App Password).
  • InitProgressDialog — modal indeterminate progress used for long ops (init/push/pull/fetch/snapshot/branch-refresh) so the EDT stays responsive.

Dockable Commit panel (CommitPanel.java, JIDE DockableFrame, tabbed by Project Browser): inline commit (message + amend), a Changes table (checkbox / Resource / Type with color-coded A/M/D/U badges and a SelectAllHeader guarded against O(n²) cascades), double-click diff, right-click View Diff / Discard. The Changes header has three snapshot buttons ("Tags"/"Themes"/"Images", VectorIcons.get("project-update") glyph) plus a refresh button; the snapshot buttons call rpc.snapshotTags/Themes/Images via GitActionManager.runSnapshot(...) on a SwingWorker+InitProgressDialog, then refresh — gateway-side edits then appear as normal file changes for per-file commit selection. Auto-refreshes every 15s and after each git op; setChangesData posts to the EDT.

Dockable History panel (HistoryPanel.java): commit log for the current branch plus the upstream tracking branch (so fetched commits show with remote ref badges before merge); borderless toolbar Refresh/Push/Fetch/Pull; Refs column rendered as colored badges; double-click → CommitDetailPopup; right-click → Checkout/Revert; "Load More" pagination; thread-safe setData.

Manager classes (gateway):

  • GitManager — core JGit operations:
    • clone; fetch (remote-tracking refs only, setUnshallow(true) to backfill history on depth-1 repos); pull; push (current branch only by default, with pushAllBranches/pushTags/forcePush flags; non-fast-forward rejection → force-push confirmation in the Designer); commit (amend); status; branch list/create/checkout/delete with per-branch stash/restore; checkout commit (detached HEAD; getCurrentBranch() returns short-hash + "(detached)"); diff extraction; history log with ref decorations; commit file list/diff; discard; revert (aborts cleanly on conflict); remote list/add/remove/setUrl.
    • Lean init fetch: materializeRepo (the registration-time clone, given the URL as a parameter) does lsRemote().setHeads(true) to detect the default branch with no object download, a targeted single-branch shallow fetch for a fast checkout, then an unshallow fetch for full history. detectDefaultBranchFromRefs prefers main/master/develop, else the first head ref. setupLocalRepoImpl is now just an idempotent startup guard (no persisted URL to re-clone from).
    • Auth (setAuthentication, the sole auth path): every remote must have an explicit credential FK — GitRemoteCredentialsRecord.SshKeyId or HttpsCredentialId referencing a user-level GitUserSshKeyRecord / GitUserHttpsCredentialRecord. Throws with a clear "pick a credential in the Remotes popup" message if no FK is set or the referenced credential is gone. Auth type (SSH vs HTTPS) is read from the remote URL in .git/config. Push/pull take a remoteName used for both the JGit target and credential lookup. Remote-dependent ops are guarded by projectHasRemote() (backed by GitManager.listRemotes() reading .git/config) so local-only repos degrade gracefully and user-named (non-origin) remotes are recognized.
    • Pull conflict sentinel: pullImpl throws RuntimeException("MERGE_CONFLICT:" + files) on MergeStatus.CONFLICTING, caught by the Designer's handlePullAction to open MergeConflictPopup.
    • Metadata noise suppression: resource.json/thumbnail.png are filtered from the changes list and commit file list when no sibling source file in the same resource dir also changed; applied in getUncommitedChangesImpl/getCommitFilesImpl.
  • GitProjectManager / GitTagManager / GitThemeManager / GitImageManager — project resource import, and gateway-resource snapshot (tags/themes/images) into the project tree. The theme snapshot stages into a system temp dir and only swaps into themes/ on full success (a mid-copy failure can't destroy committed theme files); tag snapshot bounds the provider read with a 30s timeout.

Persistence — six resource types on the 8.3 resource/config system. Each *Record class is now a mutable DTO façade over a nested NamedResourceHandler: the config is an inner Java record, and the resource name is String.valueOf(numeric id) so the RPC contract and Designer UI are unchanged (numeric long ids preserved). GatewayHook registers a ResourceTypeMeta per type and starts the handlers. A one-time records.legacy.GitLegacyImporter runs on first 8.3 startup: it registers the old SimpleORM tables' metas via SchemaUpdater (using minimal public top-level Legacy* PersistentRecord classes), reads each row, writes it as a resource, deletes the legacy row, and is idempotent (skips if the resource already exists; absent legacy tables on a fresh install are skipped). The six types:

  • GitProjectsConfigRecord — project registration marker (id + projectName). Holds no remote/URI data: .git/config is the sole source of truth for remotes. The clone URL is passed as a parameter to initializeProject and consumed by materializeRepo at registration time; it is never persisted. (Legacy 8.1 rows migrate identity only; their URI is dropped.)
  • GitReposUsersRecord — project↔user registration marker only (commit email comes from the Ignition user profile; auth is via the credential records).
  • GitRemoteCredentialsRecord — per (project, user, remote); holds SshKeyId/HttpsCredentialId FKs into the user-level credential tables.
  • GitUserSshKeyRecord — user-level SSH key (IgnitionUser, KeyName); the key is a SecretConfig (sshKeySecret) — embedded-encrypted or a Secret-Provider reference, same as the HTTPS password — with the legacy plaintext sshKey kept nullable so pre-migration rows still deserialize and upgrade on next save; getSSHKey() decrypts/falls-back unchanged for Designer callers. Shared across projects/remotes.
  • GitConfigRemoteRecord — gateway-level singleton: the data-dir config repo's remote (URI, branch, credential FK). Manual push only.
  • GitUserHttpsCredentialRecord — user-level HTTPS credential (IgnitionUser, HostPattern, UserName, Password). The password is held as a SecretConfig.embedded(...): encrypted on setPassword via GatewayContext.getSystemEncryptionService().encryptToJson(Plaintext) and decrypted on getPassword via Secret.create(ctx, secretConfig).getPlaintext(). HostPattern is purely an organizational label / disambiguator in the credential picker — auth never matches on it; remotes resolve credentials only via their explicit FK.

Key libraries

  • Eclipse JGit 6.10.1 — all git operations
  • Apache MINA sshd (org.eclipse.jgit.ssh.apache) — SSH transport (replaced the deprecated JSch backend)
  • Lombok 1.18.42 — annotation processing (designer)
  • IntelliJ forms_rt 7.0.3 — Swing form support for popups
  • React 18 + RTK Query + @inductiveautomation/ignition-web-ui — the web-ui/ gateway page (externals provided by the gateway; not bundled), built via com.github.node-gradle.node + webpack into a UMD bundle

Module Packaging

io.ia.sdk.modl plugin (v0.4.1). Module ID com.operametrix.ignition.git; version is 2.0.0.<yyyyMMddHH> (the build appends a timestamp). The compile SDK (sdk_version = 8.3.6, latest stable) is deliberately decoupled from requiredIgnitionVersion (min_ignition_version = 8.3.0) so the .modl installs on any 8.3.x gateway — don't recouple them. 8.3 declares module deps via moduleDependencySpecs { } (empty here), replacing the old moduleDependencies. The :web-ui subproject has no module scope (it's not in projectScopes); its built bundle is pulled into the gateway jar via modlImplementation(project(":web-ui")) in gateway/build.gradle.kts, and settings.gradle adds the Node.js ivy repo so the node-gradle plugin can fetch the Node runtime. skipModlSigning is false (signing enabled) — copy gradle.template.properties to gradle.properties (gitignored) and fill in signing credentials, or flip skipModlSigning to true locally for unsigned dev builds.

Java Version

Java 17 source/target, set via the toolchain in each subproject's build.gradle.kts.

Dependency Repositories

Resolved from Inductive Automation's Nexus and Maven Central, configured in settings.gradle.