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:
commit
358ac66a2a
2 changed files with 247 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue