@adobedjangir/commerce-admin-snapshots 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/actions/ext.config.yaml +50 -0
- package/actions/system-config-snapshot-create/index.js +108 -0
- package/actions/system-config-snapshot-list/index.js +47 -0
- package/actions/system-config-snapshot-restore/index.js +154 -0
- package/package.json +22 -0
- package/scripts/setup.js +168 -0
- package/src/index.js +12 -0
- package/web/index.js +26 -0
- package/web/src/SnapshotHistory.js +228 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Action fragment for the snapshots add-on. $included by app.config.yaml
|
|
2
|
+
# under application.runtimeManifest.packages.Snapshots.
|
|
3
|
+
license: Apache-2.0
|
|
4
|
+
actions:
|
|
5
|
+
system-config-snapshot-create:
|
|
6
|
+
function: system-config-snapshot-create/index.js
|
|
7
|
+
web: 'yes'
|
|
8
|
+
runtime: 'nodejs:20'
|
|
9
|
+
inputs:
|
|
10
|
+
LOG_LEVEL: debug
|
|
11
|
+
OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
|
|
12
|
+
OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
|
|
13
|
+
OAUTH_ORG_ID: $OAUTH_ORG_ID
|
|
14
|
+
OAUTH_SCOPES: $OAUTH_SCOPES
|
|
15
|
+
AIO_DB_REGION: $AIO_DB_REGION
|
|
16
|
+
annotations:
|
|
17
|
+
require-adobe-auth: false
|
|
18
|
+
include-ims-credentials: true
|
|
19
|
+
final: true
|
|
20
|
+
system-config-snapshot-list:
|
|
21
|
+
function: system-config-snapshot-list/index.js
|
|
22
|
+
web: 'yes'
|
|
23
|
+
runtime: 'nodejs:20'
|
|
24
|
+
inputs:
|
|
25
|
+
LOG_LEVEL: debug
|
|
26
|
+
OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
|
|
27
|
+
OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
|
|
28
|
+
OAUTH_ORG_ID: $OAUTH_ORG_ID
|
|
29
|
+
OAUTH_SCOPES: $OAUTH_SCOPES
|
|
30
|
+
AIO_DB_REGION: $AIO_DB_REGION
|
|
31
|
+
annotations:
|
|
32
|
+
require-adobe-auth: false
|
|
33
|
+
include-ims-credentials: true
|
|
34
|
+
final: true
|
|
35
|
+
system-config-snapshot-restore:
|
|
36
|
+
function: system-config-snapshot-restore/index.js
|
|
37
|
+
web: 'yes'
|
|
38
|
+
runtime: 'nodejs:20'
|
|
39
|
+
inputs:
|
|
40
|
+
LOG_LEVEL: debug
|
|
41
|
+
SYSTEM_CONFIG_CRYPT_KEY: $SYSTEM_CONFIG_CRYPT_KEY
|
|
42
|
+
OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
|
|
43
|
+
OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
|
|
44
|
+
OAUTH_ORG_ID: $OAUTH_ORG_ID
|
|
45
|
+
OAUTH_SCOPES: $OAUTH_SCOPES
|
|
46
|
+
AIO_DB_REGION: $AIO_DB_REGION
|
|
47
|
+
annotations:
|
|
48
|
+
require-adobe-auth: false
|
|
49
|
+
include-ims-credentials: true
|
|
50
|
+
final: true
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Capture a point-in-time snapshot of system_config_data into the
|
|
7
|
+
// system_config_snapshots collection. The snapshot is a single document
|
|
8
|
+
// that contains the entire schema + all value rows, so restore is a clean
|
|
9
|
+
// replay (no merge logic). Sensitive values stay encrypted on the way in.
|
|
10
|
+
|
|
11
|
+
const { Core } = require('@adobe/aio-sdk')
|
|
12
|
+
const { errorResponse } = require('@adobedjangir/commerce-admin-management/actions/utils')
|
|
13
|
+
const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
|
|
14
|
+
|
|
15
|
+
const DATA_COLLECTION = 'system_config_data'
|
|
16
|
+
const SCHEMA_COLLECTION = 'system_config_schema'
|
|
17
|
+
const SCHEMA_DOC_ID = 'v1'
|
|
18
|
+
const SNAPSHOT_COLLECTION = 'system_config_snapshots'
|
|
19
|
+
const SNAPSHOT_MAX = 100
|
|
20
|
+
|
|
21
|
+
async function ensureCollection (client, name) {
|
|
22
|
+
try { await client.createCollection(name) } catch (err) {
|
|
23
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
24
|
+
if (!/exist|already|duplicate/i.test(msg)) throw err
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function tryFindOne (col, query) {
|
|
29
|
+
try {
|
|
30
|
+
const arr = await col.find(query).limit(1).toArray()
|
|
31
|
+
return arr && arr.length ? arr[0] : null
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const msg = err && err.message ? String(err.message) : String(err)
|
|
34
|
+
if (/not found/i.test(msg)) return null
|
|
35
|
+
throw err
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main (params) {
|
|
40
|
+
const logger = Core.Logger('system-config-snapshot-create', { level: params.LOG_LEVEL || 'info' })
|
|
41
|
+
const label = (params.label && String(params.label).trim()) || `Snapshot ${new Date().toISOString()}`
|
|
42
|
+
const createdBy = (params.actor && String(params.actor)) ||
|
|
43
|
+
(params.__ow_headers && (params.__ow_headers['x-gw-ims-org-id'] || params.__ow_headers['x-ims-org-id'])) ||
|
|
44
|
+
'system'
|
|
45
|
+
|
|
46
|
+
let handle
|
|
47
|
+
try {
|
|
48
|
+
handle = await getClient(params)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
51
|
+
}
|
|
52
|
+
const { client, close } = handle
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await ensureCollection(client, SNAPSHOT_COLLECTION)
|
|
56
|
+
const dataCol = await client.collection(DATA_COLLECTION)
|
|
57
|
+
const schemaCol = await client.collection(SCHEMA_COLLECTION)
|
|
58
|
+
const snapCol = await client.collection(SNAPSHOT_COLLECTION)
|
|
59
|
+
|
|
60
|
+
const [valueDocs, schemaDoc] = await Promise.all([
|
|
61
|
+
dataCol.find({}).toArray().catch(() => []),
|
|
62
|
+
tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
const now = new Date().toISOString()
|
|
66
|
+
const snapshot = {
|
|
67
|
+
_id: `snap_${now.replace(/[:.]/g, '-')}_${Math.random().toString(36).slice(2, 8)}`,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
createdBy,
|
|
70
|
+
label,
|
|
71
|
+
schema: schemaDoc && schemaDoc.schema ? schemaDoc.schema : null,
|
|
72
|
+
values: valueDocs.map((d) => ({
|
|
73
|
+
scope: d.scope,
|
|
74
|
+
scope_id: d.scope_id,
|
|
75
|
+
path: d.path,
|
|
76
|
+
value: d.value
|
|
77
|
+
})),
|
|
78
|
+
counts: {
|
|
79
|
+
values: valueDocs.length,
|
|
80
|
+
sections: schemaDoc && schemaDoc.schema ? (schemaDoc.schema.sections || []).length : 0
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await snapCol.insertOne(snapshot)
|
|
84
|
+
|
|
85
|
+
// Compact older snapshots — keep only the most recent SNAPSHOT_MAX.
|
|
86
|
+
try {
|
|
87
|
+
const total = await snapCol.countDocuments({})
|
|
88
|
+
if (total > SNAPSHOT_MAX) {
|
|
89
|
+
const over = total - SNAPSHOT_MAX
|
|
90
|
+
const oldest = await snapCol.find({}).sort({ createdAt: 1 }).limit(over).toArray()
|
|
91
|
+
for (const o of oldest) await snapCol.deleteOne({ _id: o._id })
|
|
92
|
+
}
|
|
93
|
+
} catch (_) { /* compaction best-effort */ }
|
|
94
|
+
|
|
95
|
+
logger.info(`Snapshot ${snapshot._id} created (${snapshot.counts.values} values)`)
|
|
96
|
+
return {
|
|
97
|
+
statusCode: 200,
|
|
98
|
+
body: { ok: true, snapshot: { _id: snapshot._id, label, createdAt: now, createdBy, counts: snapshot.counts } }
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
logger.error(error)
|
|
102
|
+
return errorResponse(500, error.message || 'snapshot create failed', logger)
|
|
103
|
+
} finally {
|
|
104
|
+
try { await close() } catch (_) {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exports.main = main
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Paginated list of snapshots. Returns metadata only (label/createdAt/counts) —
|
|
7
|
+
// the full value array is omitted so the UI list stays light. The restore
|
|
8
|
+
// action loads the full doc on demand.
|
|
9
|
+
|
|
10
|
+
const { Core } = require('@adobe/aio-sdk')
|
|
11
|
+
const { errorResponse } = require('@adobedjangir/commerce-admin-management/actions/utils')
|
|
12
|
+
const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
|
|
13
|
+
|
|
14
|
+
const SNAPSHOT_COLLECTION = 'system_config_snapshots'
|
|
15
|
+
|
|
16
|
+
async function main (params) {
|
|
17
|
+
const logger = Core.Logger('system-config-snapshot-list', { level: params.LOG_LEVEL || 'info' })
|
|
18
|
+
const limit = Math.min(Math.max(parseInt(params.limit, 10) || 50, 1), 500)
|
|
19
|
+
const skip = Math.max(parseInt(params.skip, 10) || 0, 0)
|
|
20
|
+
|
|
21
|
+
let handle
|
|
22
|
+
try { handle = await getClient(params) } catch (e) {
|
|
23
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
24
|
+
}
|
|
25
|
+
const { client, close } = handle
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const col = await client.collection(SNAPSHOT_COLLECTION)
|
|
29
|
+
const docs = await col.find({}).sort({ createdAt: -1 }).skip(skip).limit(limit).toArray().catch(() => [])
|
|
30
|
+
// Strip large payloads from the list response.
|
|
31
|
+
const items = docs.map((d) => ({
|
|
32
|
+
_id: d._id,
|
|
33
|
+
label: d.label,
|
|
34
|
+
createdAt: d.createdAt,
|
|
35
|
+
createdBy: d.createdBy,
|
|
36
|
+
counts: d.counts
|
|
37
|
+
}))
|
|
38
|
+
return { statusCode: 200, body: { ok: true, items, limit, skip, returned: items.length } }
|
|
39
|
+
} catch (error) {
|
|
40
|
+
logger.error(error)
|
|
41
|
+
return errorResponse(500, error.message || 'snapshot list failed', logger)
|
|
42
|
+
} finally {
|
|
43
|
+
try { await close() } catch (_) {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
exports.main = main
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Restore the entire system_config_data collection from a snapshot.
|
|
7
|
+
// Strategy: write a snapshot of the CURRENT state first (so the restore
|
|
8
|
+
// itself is auditable + reversible), then wipe + repopulate.
|
|
9
|
+
//
|
|
10
|
+
// Sensitive values in the snapshot are stored as ciphertext (we never
|
|
11
|
+
// decrypt at snapshot time), so restore replays them verbatim — they
|
|
12
|
+
// remain readable as long as SYSTEM_CONFIG_CRYPT_KEY hasn't changed
|
|
13
|
+
// between snapshot and restore.
|
|
14
|
+
|
|
15
|
+
const { Core } = require('@adobe/aio-sdk')
|
|
16
|
+
const { errorResponse, checkMissingRequestInputs } = require('@adobedjangir/commerce-admin-management/actions/utils')
|
|
17
|
+
const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
|
|
18
|
+
const { toStateKey } = require('@adobedjangir/commerce-admin-management/shared')
|
|
19
|
+
|
|
20
|
+
const DATA_COLLECTION = 'system_config_data'
|
|
21
|
+
const SCHEMA_COLLECTION = 'system_config_schema'
|
|
22
|
+
const SCHEMA_DOC_ID = 'v1'
|
|
23
|
+
const SNAPSHOT_COLLECTION = 'system_config_snapshots'
|
|
24
|
+
const AUDIT_COLLECTION = 'system_config_audit'
|
|
25
|
+
|
|
26
|
+
async function ensureCollection (client, name) {
|
|
27
|
+
try { await client.createCollection(name) } catch (err) {
|
|
28
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
29
|
+
if (!/exist|already|duplicate/i.test(msg)) throw err
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main (params) {
|
|
34
|
+
const logger = Core.Logger('system-config-snapshot-restore', { level: params.LOG_LEVEL || 'info' })
|
|
35
|
+
const missing = checkMissingRequestInputs(params, ['snapshotId'], [])
|
|
36
|
+
if (missing) return errorResponse(400, missing, logger)
|
|
37
|
+
const snapshotId = String(params.snapshotId)
|
|
38
|
+
const actor = (params.actor && String(params.actor)) || 'system'
|
|
39
|
+
const restoreSchema = params.restoreSchema !== false && params.restoreSchema !== 'false'
|
|
40
|
+
|
|
41
|
+
let handle
|
|
42
|
+
try { handle = await getClient(params) } catch (e) {
|
|
43
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
44
|
+
}
|
|
45
|
+
const { client, close } = handle
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const snapCol = await client.collection(SNAPSHOT_COLLECTION)
|
|
49
|
+
const snap = await (async () => {
|
|
50
|
+
try { return await snapCol.findOne({ _id: snapshotId }) }
|
|
51
|
+
catch { return null }
|
|
52
|
+
})()
|
|
53
|
+
if (!snap) return errorResponse(404, `Snapshot ${snapshotId} not found`, logger)
|
|
54
|
+
|
|
55
|
+
// 1) Capture a "pre-restore" snapshot of the current state so this op is
|
|
56
|
+
// reversible. Inline rather than reusing the create action.
|
|
57
|
+
const dataCol = await client.collection(DATA_COLLECTION)
|
|
58
|
+
const schemaCol = await client.collection(SCHEMA_COLLECTION)
|
|
59
|
+
const now = new Date().toISOString()
|
|
60
|
+
const currentValues = await dataCol.find({}).toArray().catch(() => [])
|
|
61
|
+
const currentSchemaDoc = await (async () => {
|
|
62
|
+
try { return await schemaCol.findOne({ _id: SCHEMA_DOC_ID }) }
|
|
63
|
+
catch { return null }
|
|
64
|
+
})()
|
|
65
|
+
const preRestoreSnap = {
|
|
66
|
+
_id: `snap_${now.replace(/[:.]/g, '-')}_pre-restore`,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
createdBy: actor,
|
|
69
|
+
label: `Auto: pre-restore of ${snap.label || snapshotId}`,
|
|
70
|
+
schema: currentSchemaDoc && currentSchemaDoc.schema ? currentSchemaDoc.schema : null,
|
|
71
|
+
values: currentValues.map((d) => ({ scope: d.scope, scope_id: d.scope_id, path: d.path, value: d.value })),
|
|
72
|
+
counts: {
|
|
73
|
+
values: currentValues.length,
|
|
74
|
+
sections: currentSchemaDoc && currentSchemaDoc.schema ? (currentSchemaDoc.schema.sections || []).length : 0
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try { await snapCol.insertOne(preRestoreSnap) } catch (e) {
|
|
78
|
+
logger.warn(`pre-restore snapshot failed (continuing): ${e.message}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2) Replace schema if the snapshot has one and the caller wants it.
|
|
82
|
+
if (restoreSchema && snap.schema) {
|
|
83
|
+
try {
|
|
84
|
+
await schemaCol.updateOne(
|
|
85
|
+
{ _id: SCHEMA_DOC_ID },
|
|
86
|
+
{ $set: { schema: snap.schema, updatedAt: now }, $setOnInsert: { _id: SCHEMA_DOC_ID, createdAt: now } },
|
|
87
|
+
{ upsert: true }
|
|
88
|
+
)
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Fallback for drivers without upsert.
|
|
91
|
+
if (currentSchemaDoc) await schemaCol.updateOne({ _id: SCHEMA_DOC_ID }, { $set: { schema: snap.schema, updatedAt: now } })
|
|
92
|
+
else await schemaCol.insertOne({ _id: SCHEMA_DOC_ID, schema: snap.schema, createdAt: now, updatedAt: now })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3) Wipe data collection then bulk-insert the snapshot's values.
|
|
97
|
+
for (const d of currentValues) {
|
|
98
|
+
await dataCol.deleteOne({ _id: d._id })
|
|
99
|
+
}
|
|
100
|
+
let inserted = 0
|
|
101
|
+
for (const v of (snap.values || [])) {
|
|
102
|
+
try {
|
|
103
|
+
const id = toStateKey(v.scope, v.scope_id, v.path)
|
|
104
|
+
await dataCol.insertOne({
|
|
105
|
+
_id: id,
|
|
106
|
+
scope: v.scope,
|
|
107
|
+
scope_id: v.scope_id,
|
|
108
|
+
path: v.path,
|
|
109
|
+
value: v.value,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now
|
|
112
|
+
})
|
|
113
|
+
inserted++
|
|
114
|
+
} catch (e) {
|
|
115
|
+
logger.warn(`failed to restore ${v.path} at ${v.scope}:${v.scope_id}: ${e.message}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 4) Audit row for the restore action — a single high-level entry.
|
|
120
|
+
try {
|
|
121
|
+
await ensureCollection(client, AUDIT_COLLECTION)
|
|
122
|
+
const auditCol = await client.collection(AUDIT_COLLECTION)
|
|
123
|
+
await auditCol.insertOne({
|
|
124
|
+
scope: 'default',
|
|
125
|
+
scope_id: '0',
|
|
126
|
+
path: '_system/snapshot/restore',
|
|
127
|
+
action: 'update',
|
|
128
|
+
oldValue: `pre:${preRestoreSnap._id}`,
|
|
129
|
+
newValue: `from:${snapshotId}`,
|
|
130
|
+
changedBy: actor,
|
|
131
|
+
changedAt: now
|
|
132
|
+
})
|
|
133
|
+
} catch (_) {}
|
|
134
|
+
|
|
135
|
+
logger.info(`Restored snapshot ${snapshotId}: deleted ${currentValues.length}, inserted ${inserted}`)
|
|
136
|
+
return {
|
|
137
|
+
statusCode: 200,
|
|
138
|
+
body: {
|
|
139
|
+
ok: true,
|
|
140
|
+
restoredFrom: snapshotId,
|
|
141
|
+
preRestoreSnapshot: preRestoreSnap._id,
|
|
142
|
+
deleted: currentValues.length,
|
|
143
|
+
inserted
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error(error)
|
|
148
|
+
return errorResponse(500, error.message || 'snapshot restore failed', logger)
|
|
149
|
+
} finally {
|
|
150
|
+
try { await close() } catch (_) {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
exports.main = main
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adobedjangir/commerce-admin-snapshots",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Snapshot + rollback add-on for @adobedjangir/commerce-admin-management. Capture point-in-time copies of schema + values, restore wholesale (auto pre-restore backup included).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Adobe Inc.",
|
|
7
|
+
"keywords": ["adobe-io", "aio", "app-builder", "commerce-admin", "snapshot", "rollback"],
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./web": "./web/index.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"postinstall": "node ./scripts/setup.js"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src", "actions", "web", "scripts", "README.md"],
|
|
17
|
+
"engines": { "node": ">=18" },
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@adobedjangir/commerce-admin-management": ">=0.3.0",
|
|
20
|
+
"@adobedjangir/commerce-admin-get-config": ">=0.0.7"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
4
|
+
Licensed under the Apache License, Version 2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
|
|
10
|
+
// ── Per-add-on configuration ──
|
|
11
|
+
const PACKAGE_NAME = '@adobedjangir/commerce-admin-snapshots'
|
|
12
|
+
const RUNTIME_KEY = 'Snapshots'
|
|
13
|
+
const INCLUDE_REL = 'node_modules/@adobedjangir/commerce-admin-snapshots/actions/ext.config.yaml'
|
|
14
|
+
const REGISTER_IMPORT = "import registerSnapshots from '@adobedjangir/commerce-admin-snapshots/web'"
|
|
15
|
+
const REGISTER_CALL = 'registerSnapshots()'
|
|
16
|
+
const MARKER = `# ${PACKAGE_NAME} (auto-linked on npm install)`
|
|
17
|
+
|
|
18
|
+
// ── Boilerplate (identical across add-ons; small enough to inline) ──
|
|
19
|
+
function findProjectRoot (startDir) {
|
|
20
|
+
let dir = startDir
|
|
21
|
+
while (dir && dir !== path.dirname(dir)) {
|
|
22
|
+
if (fs.existsSync(path.join(dir, 'app.config.yaml'))) return dir
|
|
23
|
+
dir = path.dirname(dir)
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
function resolveProjectRoot () {
|
|
28
|
+
return (process.env.INIT_CWD && findProjectRoot(process.env.INIT_CWD)) ||
|
|
29
|
+
findProjectRoot(process.cwd())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add (or replace) an `application.runtimeManifest.packages.<RUNTIME_KEY>`
|
|
34
|
+
* block that $includes our fragment. Idempotent.
|
|
35
|
+
*/
|
|
36
|
+
function patchAppConfig (content) {
|
|
37
|
+
// Look for our own marker to detect prior installs.
|
|
38
|
+
const blockRe = new RegExp(
|
|
39
|
+
String.raw`^[ \t]*${MARKER}\n([ \t]+${RUNTIME_KEY}:[ \t]*\n[ \t]+\$include:[^\n]*\n)`, 'm'
|
|
40
|
+
)
|
|
41
|
+
const desiredBody = ` ${MARKER}\n ${RUNTIME_KEY}:\n $include: ${INCLUDE_REL}\n`
|
|
42
|
+
|
|
43
|
+
if (blockRe.test(content)) {
|
|
44
|
+
// Already linked — leave it.
|
|
45
|
+
return { content, changed: false, reason: 'already-linked' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. application.runtimeManifest.packages exists → append our subkey.
|
|
49
|
+
if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+packages:[ \t]*\n/m.test(content)) {
|
|
50
|
+
const next = content.replace(
|
|
51
|
+
/([ \t]+packages:[ \t]*\n)/m,
|
|
52
|
+
(m) => m + desiredBody
|
|
53
|
+
)
|
|
54
|
+
return { content: next, changed: next !== content, reason: 'appended-under-packages' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. application.runtimeManifest exists but no `packages:` → add one.
|
|
58
|
+
if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n/m.test(content)) {
|
|
59
|
+
const next = content.replace(
|
|
60
|
+
/([ \t]+runtimeManifest:[ \t]*\n)/m,
|
|
61
|
+
(m) => m + ` packages:\n${desiredBody}`
|
|
62
|
+
)
|
|
63
|
+
return { content: next, changed: next !== content, reason: 'added-packages' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. application: exists but no runtimeManifest → add the whole tree.
|
|
67
|
+
if (/^application:[ \t]*\n/m.test(content)) {
|
|
68
|
+
const next = content.replace(
|
|
69
|
+
/^application:[ \t]*\n/m,
|
|
70
|
+
`application:\n runtimeManifest:\n packages:\n${desiredBody}`
|
|
71
|
+
)
|
|
72
|
+
return { content: next, changed: next !== content, reason: 'added-runtimeManifest' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. No application: → append the whole block.
|
|
76
|
+
const trimmed = content.replace(/\s+$/, '')
|
|
77
|
+
const sep = trimmed ? '\n\n' : ''
|
|
78
|
+
return {
|
|
79
|
+
content: `${trimmed}${sep}application:\n runtimeManifest:\n packages:\n${desiredBody}`,
|
|
80
|
+
changed: true,
|
|
81
|
+
reason: 'appended-application'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Patch the host's web-src/src/index.js to import + call our register fn.
|
|
87
|
+
* We look for a managed `// --- COMMERCE-ADMIN ADDONS ---` block; if it's
|
|
88
|
+
* absent we add one. Idempotent — the registration line is only added once.
|
|
89
|
+
*/
|
|
90
|
+
function patchBootstrap (content) {
|
|
91
|
+
const blockStart = '// --- COMMERCE-ADMIN ADDONS (auto-managed) ---'
|
|
92
|
+
const blockEnd = '// --- COMMERCE-ADMIN ADDONS END ---'
|
|
93
|
+
// Already registered?
|
|
94
|
+
if (content.includes(REGISTER_IMPORT) && content.includes(REGISTER_CALL)) {
|
|
95
|
+
return { content, changed: false, reason: 'already-registered' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find or create the managed block.
|
|
99
|
+
if (content.includes(blockStart)) {
|
|
100
|
+
// Insert our import + call inside the block.
|
|
101
|
+
const next = content.replace(
|
|
102
|
+
blockStart + '\n',
|
|
103
|
+
blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n'
|
|
104
|
+
)
|
|
105
|
+
return { content: next, changed: true, reason: 'inserted-into-existing-block' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No block yet — append one after `configureWeb({...})` call.
|
|
109
|
+
const cwRe = /configureWeb\s*\([^)]*\)\s*\n/
|
|
110
|
+
if (cwRe.test(content)) {
|
|
111
|
+
const next = content.replace(
|
|
112
|
+
cwRe,
|
|
113
|
+
(m) => m + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n'
|
|
114
|
+
)
|
|
115
|
+
return { content: next, changed: true, reason: 'created-block-after-configureWeb' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// No configureWeb? Append at end as a best effort.
|
|
119
|
+
return {
|
|
120
|
+
content: content + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n',
|
|
121
|
+
changed: true,
|
|
122
|
+
reason: 'appended-block-at-end'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function main () {
|
|
127
|
+
if (process.env.CONFIGURATION_MANAGEMENT_SKIP_SETUP === '1') return
|
|
128
|
+
const root = resolveProjectRoot()
|
|
129
|
+
if (!root) {
|
|
130
|
+
console.log(`[${PACKAGE_NAME}] No app.config.yaml found — skip setup.`)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1. app.config.yaml
|
|
135
|
+
const cfg = path.join(root, 'app.config.yaml')
|
|
136
|
+
if (fs.existsSync(cfg)) {
|
|
137
|
+
const before = fs.readFileSync(cfg, 'utf8')
|
|
138
|
+
const { content, changed, reason } = patchAppConfig(before)
|
|
139
|
+
if (changed) {
|
|
140
|
+
fs.writeFileSync(cfg, content, 'utf8')
|
|
141
|
+
console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. bootstrap (web-src/src/index.js)
|
|
148
|
+
const boot = path.join(root, 'web-src', 'src', 'index.js')
|
|
149
|
+
if (fs.existsSync(boot)) {
|
|
150
|
+
const before = fs.readFileSync(boot, 'utf8')
|
|
151
|
+
const { content, changed, reason } = patchBootstrap(before)
|
|
152
|
+
if (changed) {
|
|
153
|
+
fs.writeFileSync(boot, content, 'utf8')
|
|
154
|
+
console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
|
|
155
|
+
} else {
|
|
156
|
+
console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (require.main === module) {
|
|
162
|
+
try { main() } catch (err) {
|
|
163
|
+
// Never fail npm install on scaffold issues.
|
|
164
|
+
console.error(`[${PACKAGE_NAME}] setup error (install continues):`, err.message)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { patchAppConfig, patchBootstrap }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Snapshots add-on has no server-side hooks back into core today — the
|
|
7
|
+
// snapshot actions are self-contained. This file is the package's entry
|
|
8
|
+
// point for completeness; consumers don't normally need to import from it.
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
SNAPSHOT_COLLECTION: 'system_config_snapshots'
|
|
12
|
+
}
|
package/web/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { configureWeb } from '@adobedjangir/commerce-admin-management/web'
|
|
7
|
+
import SnapshotHistory from './src/SnapshotHistory'
|
|
8
|
+
|
|
9
|
+
export default function registerSnapshots () {
|
|
10
|
+
configureWeb({
|
|
11
|
+
actionKeys: {
|
|
12
|
+
systemConfigSnapshotCreate: 'Snapshots/system-config-snapshot-create',
|
|
13
|
+
systemConfigSnapshotList: 'Snapshots/system-config-snapshot-list',
|
|
14
|
+
systemConfigSnapshotRestore: 'Snapshots/system-config-snapshot-restore'
|
|
15
|
+
},
|
|
16
|
+
extraNav: [{
|
|
17
|
+
id: 'snapshots',
|
|
18
|
+
path: '/snapshots',
|
|
19
|
+
label: 'Snapshots',
|
|
20
|
+
icon: 'Box'
|
|
21
|
+
}],
|
|
22
|
+
extraPages: { snapshots: SnapshotHistory }
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { default as SnapshotHistory } from './src/SnapshotHistory'
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
Licensed under the Apache License, Version 2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Snapshot history viewer + restore UI. Snapshots are written by either:
|
|
7
|
+
// - the create action (user clicks "Save snapshot now")
|
|
8
|
+
// - the restore action (an auto pre-restore snapshot is taken first)
|
|
9
|
+
|
|
10
|
+
import React, { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
import {
|
|
12
|
+
View, Flex, Heading, Text, Button, TextField, ProgressCircle,
|
|
13
|
+
StatusLight, Well, DialogTrigger, Dialog, Content, Header, ButtonGroup,
|
|
14
|
+
Divider, Switch
|
|
15
|
+
} from '@adobe/react-spectrum'
|
|
16
|
+
import { callAction, resolveActor } from '@adobedjangir/commerce-admin-management/web'
|
|
17
|
+
import { getActionKey } from '@adobedjangir/commerce-admin-management/web'
|
|
18
|
+
import { PALETTE, RADIUS, SHADOW } from '@adobedjangir/commerce-admin-management/web'
|
|
19
|
+
|
|
20
|
+
export default function SnapshotHistory ({ runtime, ims }) {
|
|
21
|
+
const [items, setItems] = useState([])
|
|
22
|
+
const [loading, setLoading] = useState(false)
|
|
23
|
+
const [error, setError] = useState(null)
|
|
24
|
+
const [status, setStatus] = useState({ tone: 'neutral', message: '' })
|
|
25
|
+
const [newLabel, setNewLabel] = useState('')
|
|
26
|
+
const [creating, setCreating] = useState(false)
|
|
27
|
+
const [confirmRow, setConfirmRow] = useState(null)
|
|
28
|
+
const [restoreSchema, setRestoreSchema] = useState(true)
|
|
29
|
+
const [restoring, setRestoring] = useState(false)
|
|
30
|
+
|
|
31
|
+
const fetchList = useCallback(async () => {
|
|
32
|
+
setLoading(true); setError(null)
|
|
33
|
+
try {
|
|
34
|
+
const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotList'), '', { limit: 100 })
|
|
35
|
+
const body = res?.body || res
|
|
36
|
+
setItems(Array.isArray(body?.items) ? body.items : [])
|
|
37
|
+
} catch (e) {
|
|
38
|
+
setError(e.message || 'Failed to load snapshots')
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false)
|
|
41
|
+
}
|
|
42
|
+
}, [runtime, ims])
|
|
43
|
+
|
|
44
|
+
useEffect(() => { fetchList() }, [fetchList])
|
|
45
|
+
|
|
46
|
+
const doCreate = async () => {
|
|
47
|
+
setCreating(true); setStatus({ tone: 'notice', message: 'Creating snapshot…' })
|
|
48
|
+
try {
|
|
49
|
+
const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotCreate'), '', {
|
|
50
|
+
label: newLabel.trim() || undefined,
|
|
51
|
+
actor: resolveActor(ims)
|
|
52
|
+
})
|
|
53
|
+
const body = res?.body || res
|
|
54
|
+
if (body?.ok) {
|
|
55
|
+
setStatus({ tone: 'positive', message: `Snapshot saved: ${body.snapshot.label}` })
|
|
56
|
+
setNewLabel('')
|
|
57
|
+
await fetchList()
|
|
58
|
+
} else {
|
|
59
|
+
setStatus({ tone: 'negative', message: body?.error || 'Snapshot failed' })
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
setStatus({ tone: 'negative', message: e.message || 'Snapshot failed' })
|
|
63
|
+
} finally {
|
|
64
|
+
setCreating(false)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const doRestore = async () => {
|
|
69
|
+
if (!confirmRow) return
|
|
70
|
+
setRestoring(true)
|
|
71
|
+
setStatus({ tone: 'notice', message: 'Restoring…' })
|
|
72
|
+
try {
|
|
73
|
+
const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotRestore'), '', {
|
|
74
|
+
snapshotId: confirmRow._id,
|
|
75
|
+
restoreSchema,
|
|
76
|
+
actor: resolveActor(ims)
|
|
77
|
+
})
|
|
78
|
+
const body = res?.body || res
|
|
79
|
+
if (body?.ok) {
|
|
80
|
+
setStatus({
|
|
81
|
+
tone: 'positive',
|
|
82
|
+
message: `Restored from ${confirmRow.label}. Pre-restore backup saved as ${body.preRestoreSnapshot}.`
|
|
83
|
+
})
|
|
84
|
+
await fetchList()
|
|
85
|
+
} else {
|
|
86
|
+
setStatus({ tone: 'negative', message: body?.error || 'Restore failed' })
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
setStatus({ tone: 'negative', message: e.message || 'Restore failed' })
|
|
90
|
+
} finally {
|
|
91
|
+
setRestoring(false)
|
|
92
|
+
setConfirmRow(null)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<View padding="size-400" UNSAFE_style={{ background: PALETTE.bg, minHeight: '100vh' }}>
|
|
98
|
+
<Heading level={2} marginTop={0}>Snapshots</Heading>
|
|
99
|
+
<Text UNSAFE_style={{ color: PALETTE.textMuted }}>
|
|
100
|
+
Each snapshot captures the entire schema + every value row. Restore
|
|
101
|
+
replays a snapshot wholesale — the current state is automatically
|
|
102
|
+
backed up first so a restore is itself reversible.
|
|
103
|
+
</Text>
|
|
104
|
+
|
|
105
|
+
{status.message && (
|
|
106
|
+
<View marginTop="size-150"><StatusLight variant={status.tone}>{status.message}</StatusLight></View>
|
|
107
|
+
)}
|
|
108
|
+
{error && (
|
|
109
|
+
<Well marginTop="size-150" UNSAFE_style={{ borderColor: PALETTE.danger }}>
|
|
110
|
+
<Text UNSAFE_style={{ color: PALETTE.danger }}>{error}</Text>
|
|
111
|
+
</Well>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<View
|
|
115
|
+
marginTop="size-200"
|
|
116
|
+
padding="size-200"
|
|
117
|
+
UNSAFE_style={{ background: PALETTE.surface, border: `1px solid ${PALETTE.border}`, borderRadius: RADIUS.lg, boxShadow: SHADOW.xs }}
|
|
118
|
+
>
|
|
119
|
+
<Flex gap="size-150" alignItems="end" wrap>
|
|
120
|
+
<TextField
|
|
121
|
+
label="Snapshot label (optional)"
|
|
122
|
+
value={newLabel}
|
|
123
|
+
onChange={setNewLabel}
|
|
124
|
+
width="size-4600"
|
|
125
|
+
placeholder="e.g. before-2026-Q2-release"
|
|
126
|
+
/>
|
|
127
|
+
<Button variant="cta" onPress={doCreate} isDisabled={creating || loading}>
|
|
128
|
+
{creating ? 'Saving…' : 'Save snapshot now'}
|
|
129
|
+
</Button>
|
|
130
|
+
<Button variant="secondary" onPress={fetchList} isDisabled={loading}>
|
|
131
|
+
Reload
|
|
132
|
+
</Button>
|
|
133
|
+
</Flex>
|
|
134
|
+
</View>
|
|
135
|
+
|
|
136
|
+
<View
|
|
137
|
+
marginTop="size-200"
|
|
138
|
+
UNSAFE_style={{ background: PALETTE.surface, border: `1px solid ${PALETTE.border}`, borderRadius: RADIUS.lg, boxShadow: SHADOW.xs, overflow: 'hidden' }}
|
|
139
|
+
>
|
|
140
|
+
<div style={{
|
|
141
|
+
display: 'grid',
|
|
142
|
+
gridTemplateColumns: 'minmax(180px, 1fr) minmax(140px, 200px) minmax(160px, 220px) 110px 110px 120px',
|
|
143
|
+
padding: '12px 16px',
|
|
144
|
+
gap: 12,
|
|
145
|
+
background: PALETTE.surfaceMuted,
|
|
146
|
+
fontSize: 11,
|
|
147
|
+
fontWeight: 700,
|
|
148
|
+
letterSpacing: 0.6,
|
|
149
|
+
textTransform: 'uppercase',
|
|
150
|
+
color: PALETTE.textMuted,
|
|
151
|
+
borderBottom: `1px solid ${PALETTE.border}`
|
|
152
|
+
}}>
|
|
153
|
+
<div>Label</div>
|
|
154
|
+
<div>Created at</div>
|
|
155
|
+
<div>Created by</div>
|
|
156
|
+
<div>Values</div>
|
|
157
|
+
<div>Sections</div>
|
|
158
|
+
<div>Action</div>
|
|
159
|
+
</div>
|
|
160
|
+
{loading && items.length === 0 ? (
|
|
161
|
+
<Flex justifyContent="center" margin="size-400"><ProgressCircle aria-label="Loading" isIndeterminate /></Flex>
|
|
162
|
+
) : items.length === 0 ? (
|
|
163
|
+
<View padding="size-400"><Text UNSAFE_style={{ color: PALETTE.textMuted }}>No snapshots yet.</Text></View>
|
|
164
|
+
) : (
|
|
165
|
+
items.map((row, i) => (
|
|
166
|
+
<div
|
|
167
|
+
key={row._id}
|
|
168
|
+
style={{
|
|
169
|
+
display: 'grid',
|
|
170
|
+
gridTemplateColumns: 'minmax(180px, 1fr) minmax(140px, 200px) minmax(160px, 220px) 110px 110px 120px',
|
|
171
|
+
padding: '12px 16px',
|
|
172
|
+
gap: 12,
|
|
173
|
+
borderBottom: `1px solid ${PALETTE.border}`,
|
|
174
|
+
fontSize: 13,
|
|
175
|
+
background: i % 2 === 0 ? PALETTE.surface : PALETTE.surfaceSubtle,
|
|
176
|
+
alignItems: 'center'
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<div style={{ wordBreak: 'break-word' }}>
|
|
180
|
+
<div style={{ fontWeight: 600 }}>{row.label}</div>
|
|
181
|
+
<div style={{ color: PALETTE.textMuted, fontSize: 11, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}>{row._id}</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div style={{ color: PALETTE.textMuted, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}>
|
|
184
|
+
{row.createdAt ? new Date(row.createdAt).toLocaleString() : ''}
|
|
185
|
+
</div>
|
|
186
|
+
<div style={{ color: PALETTE.textMuted, wordBreak: 'break-word' }}>{row.createdBy || 'system'}</div>
|
|
187
|
+
<div>{row.counts?.values ?? '?'}</div>
|
|
188
|
+
<div>{row.counts?.sections ?? '?'}</div>
|
|
189
|
+
<div>
|
|
190
|
+
<Button variant="secondary" onPress={() => setConfirmRow(row)} isDisabled={restoring}>
|
|
191
|
+
Restore
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
))
|
|
196
|
+
)}
|
|
197
|
+
</View>
|
|
198
|
+
|
|
199
|
+
<DialogTrigger isOpen={!!confirmRow} onOpenChange={(o) => { if (!o) setConfirmRow(null) }}>
|
|
200
|
+
<div style={{ display: 'none' }} aria-hidden="true">trigger</div>
|
|
201
|
+
<Dialog>
|
|
202
|
+
<Heading>Restore this snapshot?</Heading>
|
|
203
|
+
<Header><Text>{confirmRow?.label}</Text></Header>
|
|
204
|
+
<Divider />
|
|
205
|
+
<Content>
|
|
206
|
+
<Text>
|
|
207
|
+
The current state will be saved as a backup snapshot, then every
|
|
208
|
+
value row will be replaced with the snapshot's contents.
|
|
209
|
+
{' '}{confirmRow?.counts?.values ?? '?'} value rows and
|
|
210
|
+
{' '}{confirmRow?.counts?.sections ?? '?'} schema sections.
|
|
211
|
+
</Text>
|
|
212
|
+
<View marginTop="size-200">
|
|
213
|
+
<Switch isSelected={restoreSchema} onChange={setRestoreSchema}>
|
|
214
|
+
Restore schema too (uncheck to keep current schema, restore values only)
|
|
215
|
+
</Switch>
|
|
216
|
+
</View>
|
|
217
|
+
</Content>
|
|
218
|
+
<ButtonGroup>
|
|
219
|
+
<Button variant="secondary" onPress={() => setConfirmRow(null)} isDisabled={restoring}>Cancel</Button>
|
|
220
|
+
<Button variant="cta" onPress={doRestore} isDisabled={restoring}>
|
|
221
|
+
{restoring ? 'Restoring…' : 'Restore'}
|
|
222
|
+
</Button>
|
|
223
|
+
</ButtonGroup>
|
|
224
|
+
</Dialog>
|
|
225
|
+
</DialogTrigger>
|
|
226
|
+
</View>
|
|
227
|
+
)
|
|
228
|
+
}
|