From d754146fead1ea46cae8689bc62569d14904b0b9 Mon Sep 17 00:00:00 2001 From: Mathieu Garcia Date: Tue, 11 Nov 2025 15:08:50 +0100 Subject: [PATCH 1/2] feat(ui): add import and export functionality --- ui/controllers/api.js | 5 + ui/index.js.map | 50 +++++++ ui/public/forms/settings.html | 65 +++++++++ ui/public/forms/settings_export.html | 16 +++ ui/schemas/infrastructures.js | 31 +++++ ui/schemas/settings.js | 197 +++++++++++++++++++++++++++ ui/schemas/softwares.js | 16 +++ ui/schemas/spotlight.js | 2 + ui/schemas/variables.js | 15 ++ ui/views/index.html | 4 + 10 files changed, 401 insertions(+) create mode 100644 ui/public/forms/settings.html create mode 100644 ui/public/forms/settings_export.html create mode 100644 ui/schemas/settings.js diff --git a/ui/controllers/api.js b/ui/controllers/api.js index 2e52aa05..b0d37632 100644 --- a/ui/controllers/api.js +++ b/ui/controllers/api.js @@ -61,6 +61,11 @@ exports.install = function() { ROUTE('+API /api/ +variables_remove/{id} --> Variables/remove'); ROUTE('+POST /api/secret --> Variables/secret'); + // Settings + ROUTE('+API /api/ -settings --> Settings/list'); + ROUTE('+API /api/ -settings_import --> Settings/import'); + ROUTE('+API /api/ -settings_export --> Settings/export'); + // 3dForceGraph ROUTE('+API /api/ -graphs --> Graphs/list'); diff --git a/ui/index.js.map b/ui/index.js.map index 029b074c..e2c7c30d 100644 --- a/ui/index.js.map +++ b/ui/index.js.map @@ -319,6 +319,29 @@ "id": "variables_remove", "name": "Remove a variable set" }, + { + "method": "API", + "url": "/api/", + "auth": 1, + "id": "settings", + "error": "Action not found" + }, + { + "method": "API", + "url": "/api/", + "auth": 1, + "id": "settings_import", + "input": "*import:String, *password:String", + "name": "Import backup" + }, + { + "method": "API", + "url": "/api/", + "auth": 1, + "id": "settings_export", + "input": "*projects:Array, *password:String", + "name": "Export backup" + }, { "method": "API", "url": "/api/", @@ -459,9 +482,26 @@ "name": "Infrastructures/tfstates_update", "params": "*id:UID" }, + { + "name": "Infrastructures/export", + "params": "*ids:String" + }, + { + "name": "Infrastructures/import", + "params": "*id:UID", + "input": "*color:Color, *description:String, *dtcreated:String, *icon:Icon, isarchived:Boolean, *name:String, *tfstate:Json" + }, { "name": "Inventory/read" }, + { + "name": "Settings/import", + "input": "*import:String, *password:String" + }, + { + "name": "Settings/export", + "input": "*projects:Array, *password:String" + }, { "name": "Softwares/spotlight", "params": "*prefix:String,*action:String,*api:String, alias:String" @@ -499,6 +539,11 @@ "params": "*id:UID", "input": "*action:{start|stop|main|backup|restore|destroy}" }, + { + "name": "Softwares/import", + "params": "*id:UID", + "input": "*domain:String,domain_alias:String,*exposition:String,*instance:String,*size:String,*software:String,*version:String" + }, { "name": "Spotlight/search" }, @@ -573,6 +618,11 @@ { "name": "Variables/secret", "input": "*type:String, *key:String, subkey:String, missing:{create|warn|error}, nosymbols:Boolean, userpass:String, length:Number, overwrite:Boolean, delete:Boolean" + }, + { + "name": "Variables/import", + "params": "*id:UID", + "input": "*key:String, *key2:String, *type:String, *value:Json" } ] } \ No newline at end of file diff --git a/ui/public/forms/settings.html b/ui/public/forms/settings.html new file mode 100644 index 00000000..5d221b00 --- /dev/null +++ b/ui/public/forms/settings.html @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/ui/public/forms/settings_export.html b/ui/public/forms/settings_export.html new file mode 100644 index 00000000..3c4927d9 --- /dev/null +++ b/ui/public/forms/settings_export.html @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/ui/schemas/infrastructures.js b/ui/schemas/infrastructures.js index 6f06a136..b6a01efe 100644 --- a/ui/schemas/infrastructures.js +++ b/ui/schemas/infrastructures.js @@ -202,4 +202,35 @@ NEWSCHEMA('Infrastructures', function (schema) { $.success(); } }); + + schema.action('export', { + name: 'Export all infrastructures', + params: '*ids:String', + action: async function ($) { + const result = await DATA + .list('nosql/infrastructures') + .where('uid', $.user.id) + .in('id', $.params.ids.split(',')) + .error('@(Error)') + .promise($); + $.callback(result.items); + } + }); + + schema.action('import', { + name: 'Import an infrastructure', + params: '*id:UID', + input: '*color:Color, *description:String, *dtcreated:String, *icon:Icon, isarchived:Boolean, *name:String, *tfstate:Json', + action: async function ($, model) { + const { id } = $.params; + model.tfstate = JSON.parse(model.tfstate); + + DATA.modify('nosql/infrastructures', model, true).where('id', id).insert(function(doc) { + doc.uid = $.user.id; + doc.id = id; + doc.dtupdated = NOW; + }); + $.success(); + } + }); }); \ No newline at end of file diff --git a/ui/schemas/settings.js b/ui/schemas/settings.js new file mode 100644 index 00000000..2a0d3061 --- /dev/null +++ b/ui/schemas/settings.js @@ -0,0 +1,197 @@ +NEWSCHEMA('Settings', function (schema) { + + schema.action('import', { + name: 'Import backup', + input: '*import:String, *password:String', + action: async function ($, model) { + + let projects = DECRYPT(model.import, model.password); + if(!projects){ + $.invalid('invalid import'); + return; + } + + // Process each project sequentially + for (const project of projects) { + + // Infrastructure + project.infrastructure.tfstate = JSON.stringify(project.infrastructure.tfstate); + + await ACTION('Infrastructures/import', project.infrastructure) + .params({ id: project.infrastructure.id }) + .user($.user) + .promise($); + + // Softwares + await Promise.all( + (project.softwares ?? []).map(software => + ACTION('Softwares/import', software) + .params({ id: software.id }) + .user($.user) + .promise($) + ) + ); + + // Variables + await Promise.all( + (project.variables ?? []) + .map(variable => { + // Guard against undefined/null values + if (variable.value == null) return null; + + // Store the value as a JSON string for consistency + variable.value = JSON.stringify(variable.value); + + return ACTION('Variables/import', variable) + .params({ id: variable.id }) + .user($.user) + .promise($); + }) + .filter(Boolean) // remove the `null` placeholders + ); + } + $.success(); + } + }); + + schema.action('export', { + name: 'Export backup', + input: '*projects:Array, *password:String', + action: async function ($, model) { + + const decryptValue = (value) => { + const decrypted = DECRYPT(value, process.env.AUTH_SECRET); + try { + return JSON.parse(decrypted); + } catch (_) { + return decrypted; + } + }; + + const logError = (err, ctx) => { + console.error(`⚠️ ${ctx}:`, err); + }; + + const fetchVariables = async (key, cache = new Map()) => { + if (cache.has(key)) return cache.get(key); + const { items } = await DATA.list('nosql/variables') + .where('key', key) + .error('@(Error)') + .promise($); + cache.set(key, items); + return items; + }; + + const fetchSoftwares = async (instanceName) => { + const { items } = await DATA.list('nosql/softwares') + .where('uid', $.user.id) + .where('instance', instanceName) + .error('@(Error)') + .promise($); + return items; + }; + + const resolveHostData = async (hostName) => { + const [vars, softs] = await Promise.all([ + fetchVariables(hostName), + fetchSoftwares(hostName), + ]); + return { vars, softs }; + }; + + const resolveSoftwareVars = async (softwares) => { + const varPromises = softwares.map(async (software) => { + if (!software.domain) return []; + return fetchVariables(software.domain); + }); + const results = await Promise.all(varPromises); + return results.flat(); + }; + + const buildProject = async (infra) => { + if (!infra?.tfstate?.resources) return null; + + // Decrypt infrastructure‑level variables once + let infraVars = []; + try { + infraVars = JSON.parse(DECRYPT(infra.variables, process.env.AUTH_SECRET)); + } catch (e) { + logError(e, 'infra.variables decryption'); + } + + const project = { + infrastructure: { ...infra, variables: infraVars }, + softwares: [], + variables: [], + }; + + // Cache for variable look‑ups across hosts + const varCache = new Map(); + + // Process only ansible_host resources + const hostResources = infra.tfstate.resources.filter( + (r) => r.type === 'ansible_host' && r.instances + ); + + await Promise.all( + hostResources.map(async (resource) => { + await Promise.all( + resource.instances.map(async (instance) => { + const hostName = instance.attributes?.name; + if (!hostName) return; + + // ---- a️ Hierarchical domain‑part variables ---- + const parts = hostName.split('.'); + const hierarchicalVars = []; + for (let i = parts.length - 1; i >= 0; i--) { + const id = parts.slice(i).join('.'); + const vars = await fetchVariables(id, varCache); + if (vars?.length) hierarchicalVars.push(...vars); + } + project.variables.push(...hierarchicalVars); + + // ---- b️ Host‑level data ---- + const { vars: hostVars, softs: hostSofts } = await resolveHostData(hostName); + project.variables.push(...hostVars); + project.softwares.push(...hostSofts); + + // ---- c️ Software‑level variables ---- + const swVars = await resolveSoftwareVars(hostSofts); + project.variables.push(...swVars); + }) + ); + }) + ); + + // Final flatten & decryption of variable values + project.softwares = project.softwares.flat(); + project.variables = project.variables + .flat() + .map((v) => ({ + ...v, + value: decryptValue(v.value), + })); + + return project; + }; + + const output = []; + try { + const infrastructures = await ACTION('Infrastructures/export') + .params({ ids: model.projects }) + .user($.user) + .promise($); + + const projects = await Promise.all(infrastructures.map(buildProject)); + + projects.forEach((proj) => { + if (proj) output.push(proj); + }); + } catch (err) { + logError(err, 'settings schema execution'); + } + + $.callback(ENCRYPT(output, model.password)); + } + }); +}); diff --git a/ui/schemas/softwares.js b/ui/schemas/softwares.js index 9e5ffbdf..13328cf4 100644 --- a/ui/schemas/softwares.js +++ b/ui/schemas/softwares.js @@ -242,4 +242,20 @@ NEWSCHEMA('Softwares', function (schema) { }); } }); + + schema.action('import', { + name: 'Import a software', + params: '*id:UID', + input: '*domain:String,domain_alias:String,*exposition:String,*instance:String,*size:String,*software:String,*version:String', + action: async function ($, model) { + + const { id } = $.params; + DATA.modify('nosql/softwares', model, true).where('id', id).insert(function(doc) { + doc.uid = $.user.id; + doc.id = id; + doc.dtupdated = NOW; + }); + $.success(); + } + }); }); diff --git a/ui/schemas/spotlight.js b/ui/schemas/spotlight.js index 672657b7..84eb1393 100644 --- a/ui/schemas/spotlight.js +++ b/ui/schemas/spotlight.js @@ -12,6 +12,8 @@ NEWACTION('Spotlight/search', { if ($.user.sa) { ORIGIN.push({ id: 'users', form: 'formusers', search: 'users', name: TRANSLATE($.user.language || '', '@(Users)'), icon: 'users', color: '#EB73F8' }); ORIGIN.push('-'); + ORIGIN.push({ id: 'import', form: 'formsettings', search: 'settings', name: TRANSLATE($.user.language || '', '@(Settings)'), icon: 'cog', color: '#EB73F8' }); + ORIGIN.push('-'); } ORIGIN.push({ id: 'profile', form: 'formprofile', search: 'profile', name: TRANSLATE($.user.language || '', '@(Your profile)'), icon: 'user', color: '#EB73F8' }); ORIGIN.push({ id: 'password', form: 'formpassword', search: 'user password', name: TRANSLATE($.user.language || '', '@(Your password)'), icon: 'key', color: '#EB73F8' }); diff --git a/ui/schemas/variables.js b/ui/schemas/variables.js index 2d3b80bf..937a9f82 100644 --- a/ui/schemas/variables.js +++ b/ui/schemas/variables.js @@ -244,4 +244,19 @@ NEWSCHEMA('Variables', function (schema) { } } }); + + schema.action('import', { + name: 'Import a variable', + params: '*id:UID', + input: '*key:String, *key2:String, *type:String, *value:Json', + action: async function ($, model) { + const { id } = $.params; + model.value = ENCRYPT(model.value, process.env.AUTH_SECRET); + DATA.modify('nosql/variables', model, true).where('id', id).insert(function(doc) { + doc.id = id; + doc.dtupdated = NOW; + }); + $.success(); + } + }); }); \ No newline at end of file diff --git a/ui/views/index.html b/ui/views/index.html index e11e688f..b2becd2f 100644 --- a/ui/views/index.html +++ b/ui/views/index.html @@ -48,6 +48,7 @@ + @{json(model, 'pluginsdata')} @@ -92,6 +93,9 @@ ROUTE('/', function() { SET('common.page', 'graph'); + + SET('formupgrade @reset @hideloading', { version: DEF.versionhtml, currentstep: 'step1' }); + SET('common.form', 'formupgrade'); }, 'init'); SETTER(true, 'shortcuts/register', 'esc', function(e) { From 4341cf5bf75a3ab515c7ffe20d4294b22938583c Mon Sep 17 00:00:00 2001 From: Mathieu Garcia Date: Mon, 17 Nov 2025 22:26:33 +0100 Subject: [PATCH 2/2] refactor(ui)!: remove admin credentials from infrastructure schema --- ui/index.js.map | 8 +++---- ui/public/forms/infrastructure.html | 19 ----------------- ui/schemas/infrastructures.js | 33 ++++------------------------- 3 files changed, 8 insertions(+), 52 deletions(-) diff --git a/ui/index.js.map b/ui/index.js.map index e2c7c30d..d634edcc 100644 --- a/ui/index.js.map +++ b/ui/index.js.map @@ -193,7 +193,7 @@ "url": "/api/", "auth": 1, "id": "infrastructures_create", - "input": "*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, *admin_pass:String", + "input": "*name:String, *icon:Icon, *color:Color, *description:String", "name": "Create project" }, { @@ -202,7 +202,7 @@ "auth": 1, "params": "id:string", "id": "infrastructures_update", - "input": "*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, admin_pass:String", + "input": "*name:String, *icon:Icon, *color:Color, *description:String", "name": "Update project" }, { @@ -459,7 +459,7 @@ }, { "name": "Infrastructures/create", - "input": "*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, *admin_pass:String" + "input": "*name:String, *icon:Icon, *color:Color, *description:String" }, { "name": "Infrastructures/read", @@ -468,7 +468,7 @@ { "name": "Infrastructures/update", "params": "*id:UID", - "input": "*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, admin_pass:String" + "input": "*name:String, *icon:Icon, *color:Color, *description:String" }, { "name": "Infrastructures/remove", diff --git a/ui/public/forms/infrastructure.html b/ui/public/forms/infrastructure.html index 5407de5a..5bc5e372 100644 --- a/ui/public/forms/infrastructure.html +++ b/ui/public/forms/infrastructure.html @@ -15,21 +15,6 @@
-
-
-
- @(Admin login) -
-
- @(Admin password) - -
-
- @(Admin ip address) -
-
-
-
@@ -60,10 +45,6 @@ caller = exports.caller; }; - exports.generate = function() { - exports.set('admin_pass', GUID(10)); - }; - exports.submit = function(hide) { var form = exports.form; exports.tapi('infrastructures_{0} ERROR'.format(form.id ? ('update/' + form.id) : 'create'), form, function() { diff --git a/ui/schemas/infrastructures.js b/ui/schemas/infrastructures.js index b6a01efe..d994072f 100644 --- a/ui/schemas/infrastructures.js +++ b/ui/schemas/infrastructures.js @@ -62,15 +62,12 @@ NEWSCHEMA('Infrastructures', function (schema) { schema.action('create', { name: 'Create project', - input: '*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, *admin_pass:String', + input: '*name:String, *icon:Icon, *color:Color, *description:String', action: async function ($, model) { // Input validation – early exit on first failure const validators = [ { fn: REGEX_PROJECTS.name, field: 'name' }, - { fn: REGEX_PROJECTS.description, field: 'description' }, - { fn: REGEX_PROJECTS.admin_ip, field: 'admin_ip' }, - { fn: REGEX_PROJECTS.admin_login, field: 'admin_login' }, - { fn: REGEX_PROJECTS.admin_pass, field: 'admin_pass' } + { fn: REGEX_PROJECTS.description, field: 'description' } ]; for (const v of validators) { if (v.optional && (model[v.field] === '' || model[v.field] == null)) continue; @@ -83,7 +80,6 @@ NEWSCHEMA('Infrastructures', function (schema) { // Populate system fields model.id = UID(); model.uid = $.user.id; - model.admin_pass = model.admin_pass.sha256(process.env.AUTH_SECRET); model.dtcreated = new Date(); model.isarchived = false; model.tfstate = { version: 4 }; @@ -102,7 +98,7 @@ NEWSCHEMA('Infrastructures', function (schema) { .read('nosql/infrastructures') .where('uid', $.user.id) .where('id', id) - .fields('id,name,description,admin_login,admin_ip,icon,color') + .fields('id,name,description,icon,color') .error('@(Error)') .promise($); $.callback(result); @@ -112,7 +108,7 @@ NEWSCHEMA('Infrastructures', function (schema) { schema.action('update', { name: 'Update project', params: '*id:UID', - input: '*name:String, *icon:Icon, *color:Color, *description:String, *admin_ip:String, *admin_login:String, admin_pass:String', + input: '*name:String, *icon:Icon, *color:Color, *description:String', action: async function ($, model) { const { id } = $.params; @@ -120,8 +116,6 @@ NEWSCHEMA('Infrastructures', function (schema) { const validators = [ { fn: REGEX_PROJECTS.name, field: 'name' }, { fn: REGEX_PROJECTS.description, field: 'description' }, - { fn: REGEX_PROJECTS.admin_ip, field: 'admin_ip' }, - { fn: REGEX_PROJECTS.admin_login, field: 'admin_login' } ]; for (const v of validators) { if (!FUNC.regex(v.fn, model[v.field])) { @@ -129,25 +123,6 @@ NEWSCHEMA('Infrastructures', function (schema) { return; } } - - // Password handling – only hash when a new password is supplied - if (model.admin_pass) { - if (!FUNC.regex(REGEX_PROJECTS.admin_pass, model.admin_pass)) { - $.invalid(`${REGEX_PROJECTS.admin_pass.comment}`); - return; - } - model.admin_pass = model.admin_pass.sha256(process.env.AUTH_SECRET); - } else { - // Preserve existing hash - const existing = await DATA - .read('nosql/infrastructures') - .where('uid', $.user.id) - .where('id', id) - .fields('admin_pass') - .error('@(Error)') - .promise($); - model.admin_pass = existing.admin_pass; - } model.dtupdated = new Date();