feat(dashboard): add RestoreJob list, add page, and sidebar link (#2437)

## What this PR does

- Introduce list, details, create, and sidebar views for `RestoreJob` in
the dashboard controller.
- Auto-generate stable component IDs for sidebar entries and detail
sub-blocks so upserts remain consistent across reconciles.
- Render a single "Same as backup" fallback for an omitted
`spec.targetApplicationRef` on the details view instead of concatenating
three missing-path fallbacks.
- Mark non-CRD-backed `stock-project-factory-*-details` sidebars
(`kube-*`, `plan`, `backupjob`, `backup`, `restorejob`) as static so
they pick up consistent managed-by labels.

### Release note

```release-note
[dashboard] Add RestoreJob views (list, details, create, sidebar link) to the dashboard controller.
```

## Test plan

- [x] Deploy controller to dev stand and open the restore pages in the
dashboard
- [x] Create a RestoreJob through the UI against an existing Backup;
reconcile succeeds and the details view renders
- [ ] CI green

## Previous PR
https://github.com/cozystack/cozystack/pull/2387


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Introduced comprehensive RestoreJobs dashboard interface enabling
users to view and manage backup restoration operations.
* New RestoreJobs section added to the Backups menu with dedicated
details page and navigation.
* Dashboard displays restore job status, phase, backup references,
target applications, and operation timestamps for easy monitoring.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
myasnikovdaniil 2026-04-21 20:28:38 +05:00 committed by GitHub
commit 358ac66a2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 247 additions and 7 deletions

View file

@ -144,6 +144,9 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati
// Add sidebar for backups.cozystack.io Backup resource
keysAndTags["backups"] = []any{"backup-sidebar"}
// Add sidebar for backups.cozystack.io RestoreJob resource
keysAndTags["restorejobs"] = []any{"restorejob-sidebar"}
// 3) Sort items within each category by Weight (desc), then Label (A→Z)
for cat := range categories {
sort.Slice(categories[cat], func(i, j int) bool {
@ -215,6 +218,11 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati
"label": "Backups",
"link": "/openapi-ui/{cluster}/{namespace}/api-table/backups.cozystack.io/v1alpha1/backups",
},
map[string]any{
"key": "restorejobs",
"label": "RestoreJobs",
"link": "/openapi-ui/{cluster}/{namespace}/api-table/backups.cozystack.io/v1alpha1/restorejobs",
},
},
})
@ -258,6 +266,7 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati
"stock-project-factory-plan-details",
"stock-project-factory-backupjob-details",
"stock-project-factory-backup-details",
"stock-project-factory-restorejob-details",
"stock-project-factory-external-ips",
"stock-project-api-form",
"stock-project-api-table",
@ -272,7 +281,12 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati
"stock-instance-builtin-table",
}
// Add details sidebars for all CRDs with dashboard config
// Add details sidebars for all CRDs with dashboard config, and collect
// the set of IDs that are genuinely CRD-backed (dynamic). The hardcoded
// `-details` IDs above (e.g. kube-* and backup/backupjob/plan/restorejob)
// are not tied to an ApplicationDefinition and must be treated as static
// so they receive consistent labels via upsertMultipleSidebars().
dynamicDetailsIDs := map[string]bool{}
for i := range all {
def := &all[i]
if def.Spec.Dashboard == nil {
@ -282,17 +296,22 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati
lowerKind := strings.ToLower(kind)
detailsID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
targetIDs = append(targetIDs, detailsID)
dynamicDetailsIDs[detailsID] = true
}
// 7) Upsert all target sidebars with identical menuItems and keysAndTags
return m.upsertMultipleSidebars(ctx, crd, targetIDs, keysAndTags, menuItems)
return m.upsertMultipleSidebars(ctx, crd, targetIDs, dynamicDetailsIDs, keysAndTags, menuItems)
}
// upsertMultipleSidebars creates/updates several Sidebar resources with the same menu spec.
// dynamicDetailsIDs identifies `stock-project-factory-<kind>-details` sidebars that are
// backed by an ApplicationDefinition and should therefore be owned by that CRD.
// Any other ID is treated as a static sidebar (managed-by labels, no owner ref).
func (m *Manager) upsertMultipleSidebars(
ctx context.Context,
crd *cozyv1alpha1.ApplicationDefinition,
ids []string,
dynamicDetailsIDs map[string]bool,
keysAndTags map[string]any,
menuItems []any,
) error {
@ -308,8 +327,10 @@ func (m *Manager) upsertMultipleSidebars(
if _, err := controllerutil.CreateOrUpdate(ctx, m.Client, obj, func() error {
// Only set owner reference for dynamic sidebars (stock-project-factory-{kind}-details)
// Static sidebars (stock-instance-*, stock-project-*) should not have owner references
if strings.HasPrefix(id, "stock-project-factory-") && strings.HasSuffix(id, "-details") {
// that are actually backed by an ApplicationDefinition. Static sidebars — including
// hardcoded details sidebars for built-in/backup resources — must fall through to the
// static-label branch so they're managed consistently.
if strings.HasPrefix(id, "stock-project-factory-") && strings.HasSuffix(id, "-details") && dynamicDetailsIDs[id] {
// This is a dynamic sidebar, set owner reference only if it matches the current CRD
_, _, kind := pickGVK(crd)
lowerKind := strings.ToLower(kind)

View file

@ -425,6 +425,14 @@ func CreateAllCustomColumnsOverrides() []*dashboardv1alpha1.CustomColumnsOverrid
createTimestampColumn("Taken At", ".spec.takenAt"),
createTimestampColumn("Created", ".metadata.creationTimestamp"),
}),
// Stock namespace backups cozystack io v1alpha1 restorejobs
createCustomColumnsOverride("stock-namespace-/backups.cozystack.io/v1alpha1/restorejobs", []any{
createCustomColumnWithJsonPath("Name", ".metadata.name", "RestoreJob", "", "/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/restorejob-details/{reqsJsonPath[0]['.metadata.name']['-']}"),
createStringColumn("Phase", ".status.phase"),
createStringColumn("Backup", ".spec.backupRef.name"),
createTimestampColumn("Created", ".metadata.creationTimestamp"),
}),
}
}
@ -547,6 +555,31 @@ func CreateAllCustomFormsOverrides() []*dashboardv1alpha1.CustomFormsOverride {
"backupClassName": listInputScemaItemBackupClass(),
}),
}),
// RestoreJobs form override - backups.cozystack.io/v1alpha1
createCustomFormsOverride("default-/backups.cozystack.io/v1alpha1/restorejobs", map[string]any{
"formItems": []any{
createFormItem("metadata.name", "Name", "text"),
createFormItem("metadata.namespace", "Namespace", "text"),
createFormItem("spec.backupRef.name", "Backup", "text"),
// Target application: leave empty to restore into the same application
// as referenced by the selected Backup. Fill all three to restore into
// a different application (e.g. rename, or restore into a new target).
createFormItem("spec.targetApplicationRef.apiGroup", "Target Application API Group (optional, used to restore into a different application)", "text"),
createFormItem("spec.targetApplicationRef.kind", "Target Application Kind (optional, used to restore into a different application)", "text"),
createFormItem("spec.targetApplicationRef.name", "Target Application Name (optional, used to restore into a different application)", "text"),
// Driver-specific options (key-value editor). Refer to the backup driver
// documentation for supported keys.
createFormItem("spec.options", "Options (driver-specific key/value pairs)", "object"),
},
"schema": createSchema(map[string]any{
"backupRef": map[string]any{
"properties": map[string]any{
"name": listInputSchemaItemBackup(),
},
},
}),
}),
}
}
@ -1937,6 +1970,177 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory {
}
backupSpec := createUnifiedFactory(backupConfig, backupTabs, []any{"/api/clusters/{2}/k8s/apis/backups.cozystack.io/v1alpha1/namespaces/{3}/backups/{6}"})
// RestoreJob details factory using unified approach
restoreJobConfig := UnifiedResourceConfig{
Name: "restorejob-details",
ResourceType: "factory",
Kind: "RestoreJob",
Plural: "restorejobs",
Title: "restorejob",
}
restoreJobTabs := []any{
map[string]any{
"key": "details",
"label": "Details",
"children": []any{
contentCard("details-card", map[string]any{
"marginBottom": "24px",
}, []any{
antdText("details-title", true, "RestoreJob details", map[string]any{
"fontSize": 20,
"marginBottom": "12px",
}),
spacer("details-spacer", 16),
antdRow("details-grid", []any{48, 12}, []any{
antdCol("col-left", 12, []any{
antdFlexVertical("col-left-stack", 24, []any{
antdFlexVertical("meta-name-block", 4, []any{
antdText("meta-name-label", true, "Name", nil),
parsedText("meta-name-value", "{reqsJsonPath[0]['.metadata.name']['-']}", nil),
}),
antdFlexVertical("meta-namespace-block", 8, []any{
antdText("meta-namespace-label", true, "Namespace", nil),
antdFlex("header-row", 6, []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "header-badge",
"text": "NS",
"title": "namespace",
"style": map[string]any{
"backgroundColor": "#a25792ff",
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": "15px",
"fontWeight": 400,
"lineHeight": "24px",
"minWidth": 24,
"padding": "0 9px",
"textAlign": "center",
"whiteSpace": "nowrap",
},
},
},
map[string]any{
"type": "antdLink",
"data": map[string]any{
"id": "namespace-link",
"text": "{reqsJsonPath[0]['.metadata.namespace']['-']}",
"href": "/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/marketplace",
},
},
}),
}),
antdFlexVertical("meta-created-block", 4, []any{
antdText("created-time-label", true, "Created", nil),
antdFlex("created-time-block", 6, []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "created-time-icon",
"text": "🌐",
},
},
map[string]any{
"type": "parsedText",
"data": map[string]any{
"formatter": "timestamp",
"id": "created-time-value",
"text": "{reqsJsonPath[0]['.metadata.creationTimestamp']['-']}",
},
},
}),
}),
}),
}),
antdCol("col-right", 12, []any{
antdFlexVertical("col-right-stack", 24, []any{
antdFlexVertical("status-phase-block", 4, []any{
antdText("phase-label", true, "Phase", nil),
parsedText("phase-value", "{reqsJsonPath[0]['.status.phase']['-']}", nil),
}),
antdFlexVertical("spec-backup-ref-block", 4, []any{
antdText("backup-ref-label", true, "Backup Ref", nil),
parsedText("backup-ref-value", "{reqsJsonPath[0]['.spec.backupRef.name']['-']}", nil),
}),
antdFlexVertical("spec-target-application-ref-block", 4, []any{
antdText("target-application-ref-label", true, "Target Application", nil),
// targetApplicationRef is optional — when absent, the restore targets
// the same application as the selected Backup. Show a single friendly
// fallback in that case instead of rendering "-.-/-".
parsedText("target-application-ref-value", "{reqsJsonPath[0]['.spec.targetApplicationRef.name']['Same as backup']}", nil),
}),
antdFlexVertical("spec-options-target-namespace-block", 4, []any{
antdText("options-target-namespace-label", true, "Target Namespace", nil),
parsedText("options-target-namespace-value", "{reqsJsonPath[0]['.spec.options.targetNamespace']['-']}", nil),
}),
antdFlexVertical("spec-options-fail-if-target-exists-block", 4, []any{
antdText("options-fail-if-target-exists-label", true, "Fail If Target Exists", nil),
parsedText("options-fail-if-target-exists-value", "{reqsJsonPath[0]['.spec.options.failIfTargetExists']['-']}", nil),
}),
antdFlexVertical("spec-options-keep-original-pvc-block", 4, []any{
antdText("options-keep-original-pvc-label", true, "Keep Original PVC", nil),
parsedText("options-keep-original-pvc-value", "{reqsJsonPath[0]['.spec.options.keepOriginalPVC']['-']}", nil),
}),
antdFlexVertical("spec-options-keep-original-ip-mac-block", 4, []any{
antdText("options-keep-original-ip-mac-label", true, "Keep Original IP/MAC", nil),
parsedText("options-keep-original-ip-mac-value", "{reqsJsonPath[0]['.spec.options.keepOriginalIpAndMac']['-']}", nil),
}),
antdFlexVertical("status-started-at-block", 4, []any{
antdText("started-at-label", true, "Started At", nil),
antdFlex("started-at-time-block", 6, []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "started-at-time-icon",
"text": "🌐",
},
},
map[string]any{
"type": "parsedText",
"data": map[string]any{
"formatter": "timestamp",
"id": "started-at-time-value",
"text": "{reqsJsonPath[0]['.status.startedAt']['-']}",
},
},
}),
}),
antdFlexVertical("status-completed-at-block", 4, []any{
antdText("completed-at-label", true, "Completed At", nil),
antdFlex("completed-at-time-block", 6, []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "completed-at-time-icon",
"text": "🌐",
},
},
map[string]any{
"type": "parsedText",
"data": map[string]any{
"formatter": "timestamp",
"id": "completed-at-time-value",
"text": "{reqsJsonPath[0]['.status.completedAt']['-']}",
},
},
}),
}),
antdFlexVertical("status-message-block", 4, []any{
antdText("message-label", true, "Message", nil),
parsedText("message-value", "{reqsJsonPath[0]['.status.message']['-']}", nil),
}),
}),
}),
}),
}),
},
},
}
restoreJobSpec := createUnifiedFactory(restoreJobConfig, restoreJobTabs, []any{"/api/clusters/{2}/k8s/apis/backups.cozystack.io/v1alpha1/namespaces/{3}/restorejobs/{6}"})
// External IPs factory (filtered services)
externalIPsTabs := []any{
map[string]any{
@ -1989,6 +2193,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory {
createFactory("plan-details", planSpec),
createFactory("backupjob-details", backupJobSpec),
createFactory("backup-details", backupSpec),
createFactory("restorejob-details", restoreJobSpec),
createFactory("external-ips", externalIPsSpec),
}
}
@ -2008,9 +2213,10 @@ func CreateAllNavigations() []*dashboardv1alpha1.Navigation {
"base-factory-namespaced-api-networking.k8s.io-v1-ingresses": "kube-ingress-details",
"base-factory-namespaced-api-cozystack.io-v1alpha1-workloadmonitors": "workloadmonitor-details",
// Backup resources (not ApplicationDefinitions, so ensureNavigation doesn't cover them)
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-plans": "plan-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-backupjobs": "backupjob-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-backups": "backup-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-plans": "plan-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-backupjobs": "backupjob-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-backups": "backup-details",
"base-factory-namespaced-api-backups.cozystack.io-v1alpha1-restorejobs": "restorejob-details",
}
return []*dashboardv1alpha1.Navigation{
@ -2135,6 +2341,19 @@ func listInputScemaItemBackupClass() map[string]any {
}
}
// listInputSchemaItemBackup returns a listInput schema overlay for selecting a Backup
// from the current namespace (used by RestoreJob form for spec.backupRef.name).
func listInputSchemaItemBackup() map[string]any {
return map[string]any{
"type": "listInput",
"customProps": map[string]any{
"valueUri": "/api/clusters/{cluster}/k8s/apis/backups.cozystack.io/v1alpha1/namespaces/{namespace}/backups",
"keysToValue": []any{"metadata", "name"},
"keysToLabel": []any{"metadata", "name"},
},
}
}
// backupClassSchema returns the schema for spec.backupClassName as listInput (BackupJob/Plan).
func createSchema(customProps map[string]any) map[string]any {
return map[string]any{