akv-manager 0.0.3 → 0.0.5
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/package.json +1 -1
- package/public/index.html +627 -382
- package/public/styles.css +120 -0
- package/server.js +88 -9
package/public/styles.css
CHANGED
|
@@ -240,10 +240,130 @@
|
|
|
240
240
|
border-radius:999px;margin:16px auto 0;overflow:hidden}
|
|
241
241
|
.progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
|
|
242
242
|
|
|
243
|
+
/* ---------- sidebar: pinned / recent + account switcher ---------- */
|
|
244
|
+
.navpins{display:flex;flex-direction:column;gap:1px;margin:2px 0 4px}
|
|
245
|
+
.navpin{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:0;
|
|
246
|
+
color:var(--muted);padding:6px 11px;border-radius:var(--r-sm);cursor:pointer;font-family:var(--mono);
|
|
247
|
+
font-size:11.5px;transition:.12s;overflow:hidden}
|
|
248
|
+
.navpin:hover{background:rgba(255,255,255,.03);color:var(--text)}
|
|
249
|
+
.navpin.active{background:var(--azuredim);color:#fff}
|
|
250
|
+
.navpin .pn{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
251
|
+
.navpin .star{color:var(--warn);font-size:10px;flex:none}
|
|
252
|
+
.navpin .env-dot{width:6px;height:6px;border-radius:50%;flex:none}
|
|
253
|
+
.navempty{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:4px 12px}
|
|
254
|
+
.acct{margin-top:auto;border-top:1px solid var(--line2);padding-top:10px}
|
|
255
|
+
.acctbtn{display:flex;align-items:center;gap:9px;width:100%;text-align:left;background:transparent;border:0;
|
|
256
|
+
color:var(--muted);padding:8px;border-radius:var(--r-sm);cursor:pointer;font-size:12px;transition:.12s;line-height:1.5}
|
|
257
|
+
.acctbtn:hover{background:rgba(255,255,255,.03)}
|
|
258
|
+
.acctbtn b{color:var(--text);font-weight:600}
|
|
259
|
+
.acctbtn .chev{margin-left:auto;color:var(--faint);flex:none}
|
|
260
|
+
|
|
261
|
+
/* ---------- command palette (⌘K) ---------- */
|
|
262
|
+
.cmdk-ov{position:fixed;inset:0;background:rgba(5,8,14,.55);backdrop-filter:blur(3px);opacity:0;
|
|
263
|
+
pointer-events:none;transition:.14s;z-index:70;display:flex;align-items:flex-start;justify-content:center;padding-top:12vh}
|
|
264
|
+
.cmdk-ov.show{opacity:1;pointer-events:auto}
|
|
265
|
+
.cmdk{width:min(640px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
|
266
|
+
box-shadow:0 40px 90px -30px rgba(0,0,0,.85);overflow:hidden;transform:translateY(-8px);transition:.16s}
|
|
267
|
+
.cmdk-ov.show .cmdk{transform:translateY(0)}
|
|
268
|
+
.cmdk-in{display:flex;align-items:center;gap:11px;padding:15px 18px;border-bottom:1px solid var(--line)}
|
|
269
|
+
.cmdk-in svg{width:17px;height:17px;color:var(--faint);flex:none}
|
|
270
|
+
.cmdk-in input{flex:1;background:transparent;border:0;outline:0;color:var(--text);font-family:var(--sans);font-size:15px}
|
|
271
|
+
.cmdk-in input::placeholder{color:var(--faint)}
|
|
272
|
+
.cmdk-in kbd{font-family:var(--mono);font-size:10px;color:var(--faint);border:1px solid var(--line);border-radius:5px;padding:2px 6px}
|
|
273
|
+
.cmdk-list{max-height:54vh;overflow:auto;padding:7px}
|
|
274
|
+
.cmdk-sec{font-family:var(--mono);font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);padding:9px 11px 5px}
|
|
275
|
+
.cmdk-it{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:8px;cursor:pointer}
|
|
276
|
+
.cmdk-it .ico{width:16px;height:16px;color:var(--muted);flex:none}
|
|
277
|
+
.cmdk-it .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px}
|
|
278
|
+
.cmdk-it .lbl small{color:var(--faint);font-family:var(--mono);font-size:11px;margin-left:8px}
|
|
279
|
+
.cmdk-it .hint{font-family:var(--mono);font-size:10px;color:var(--faint)}
|
|
280
|
+
.cmdk-it.cur{background:var(--azuredim)}
|
|
281
|
+
.cmdk-it.cur .ico{color:var(--azure)}
|
|
282
|
+
.cmdk-empty{padding:26px;text-align:center;color:var(--muted);font-size:13px}
|
|
283
|
+
|
|
284
|
+
/* ---------- dashboard ---------- */
|
|
285
|
+
.actcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:13px;margin-bottom:26px}
|
|
286
|
+
.actcard{display:block;text-align:left;background:linear-gradient(180deg,var(--panel),var(--panel2));
|
|
287
|
+
border:1px solid var(--line);border-radius:var(--r);padding:16px 17px;cursor:pointer;position:relative;
|
|
288
|
+
overflow:hidden;transition:.15s;font-family:inherit}
|
|
289
|
+
.actcard::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
|
|
290
|
+
.actcard:hover{border-color:var(--azure);transform:translateY(-1px)}
|
|
291
|
+
.actcard .k{display:flex;align-items:center;gap:8px;font-family:var(--mono);color:var(--muted);font-size:10.5px;
|
|
292
|
+
text-transform:uppercase;letter-spacing:.12em}
|
|
293
|
+
.actcard .k svg{width:14px;height:14px}
|
|
294
|
+
.actcard .v{font-family:var(--mono);font-size:32px;font-weight:600;margin-top:11px;line-height:1;letter-spacing:-.02em}
|
|
295
|
+
.actcard .sub{font-family:var(--sans);font-size:12px;color:var(--muted);margin-top:6px}
|
|
296
|
+
.actcard.sev-alert::after{background:var(--alert)} .actcard.sev-alert .v{color:#FF8893}
|
|
297
|
+
.actcard.sev-warn::after{background:var(--warn)} .actcard.sev-warn .v{color:#FBC56B}
|
|
298
|
+
.actcard.sev-azure::after{background:var(--azure)} .actcard.sev-azure .v{color:#8FBEFF}
|
|
299
|
+
.actcard.sev-ok::after{background:var(--ok)} .actcard.sev-ok .v{color:#7FE0C8}
|
|
300
|
+
.dashgrid{display:grid;grid-template-columns:1fr 1fr;gap:22px;align-items:start}
|
|
301
|
+
@media (max-width:1100px){.dashgrid{grid-template-columns:1fr}}
|
|
302
|
+
.secthead2{display:flex;align-items:center;justify-content:space-between;margin:0 0 12px}
|
|
303
|
+
.secthead2 .eyebrow{font-size:11px}
|
|
304
|
+
.vtiles{display:flex;flex-direction:column;gap:7px}
|
|
305
|
+
.vtile{display:flex;align-items:center;gap:11px;background:var(--panel);border:1px solid var(--line);
|
|
306
|
+
border-radius:10px;padding:11px 13px;cursor:pointer;transition:.13s}
|
|
307
|
+
.vtile:hover{border-color:var(--azure);background:var(--raise)}
|
|
308
|
+
.vtile .star{color:var(--warn);font-size:12px;flex:none;background:none;border:0;cursor:pointer;padding:0}
|
|
309
|
+
.vtile .star.off{color:var(--faint)}
|
|
310
|
+
.vtile .nm{flex:1;font-family:var(--mono);font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
311
|
+
.vtile .rg{font-family:var(--mono);font-size:11px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
|
|
312
|
+
|
|
313
|
+
/* ---------- facets (tag filtering) ---------- */
|
|
314
|
+
.facets{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
|
315
|
+
.facet{display:flex;align-items:center;gap:6px}
|
|
316
|
+
.facet label{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
|
|
317
|
+
select.facetsel{font-family:var(--mono);font-size:12px;background:var(--panel2);border:1px solid var(--line);
|
|
318
|
+
color:var(--text);border-radius:7px;padding:6px 9px;cursor:pointer;color-scheme:dark;max-width:200px}
|
|
319
|
+
select.facetsel:focus{outline:none;border-color:var(--azure)}
|
|
320
|
+
select.facetsel.on{border-color:var(--azure);color:#fff}
|
|
321
|
+
.facetclear{font-family:var(--mono);font-size:11px;color:var(--azure);background:none;border:0;cursor:pointer}
|
|
322
|
+
|
|
323
|
+
/* ---------- tag chips + editor ---------- */
|
|
324
|
+
.tagchips{display:flex;gap:5px;flex-wrap:wrap}
|
|
325
|
+
.tagchip{font-family:var(--mono);font-size:10px;color:var(--muted);background:rgba(255,255,255,.04);
|
|
326
|
+
border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
|
|
327
|
+
.tagchip b{color:var(--text);font-weight:500}
|
|
328
|
+
.tagedit{display:flex;flex-direction:column;gap:9px;max-width:680px}
|
|
329
|
+
.tagrow{display:flex;align-items:center;gap:9px}
|
|
330
|
+
.tagrow input{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
|
|
331
|
+
color:var(--text);border-radius:6px;padding:8px 10px;color-scheme:dark}
|
|
332
|
+
.tagrow .tk{width:230px;flex:none} .tagrow .tv{flex:1}
|
|
333
|
+
.tagrow .del{flex:none}
|
|
334
|
+
.tagrow.fixed input{opacity:.6}
|
|
335
|
+
|
|
336
|
+
/* ---------- routed vault page ---------- */
|
|
337
|
+
.vhead{margin-bottom:18px}
|
|
338
|
+
.crumb{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:6px}
|
|
339
|
+
.crumb a{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
|
340
|
+
.crumb a:hover{color:var(--text)}
|
|
341
|
+
.crumb .sep{color:var(--faint)}
|
|
342
|
+
.crumb .here{font-family:var(--mono);font-size:16px;font-weight:600;color:var(--text);word-break:break-all}
|
|
343
|
+
.star{background:none;border:0;cursor:pointer;color:var(--faint);font-size:16px;padding:0 2px;line-height:1}
|
|
344
|
+
.star.on{color:var(--warn)}
|
|
345
|
+
.vmeta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
|
346
|
+
.vmeta .mono{word-break:break-all}
|
|
347
|
+
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin-bottom:16px}
|
|
348
|
+
.tabs button{font-family:var(--sans);font-size:13px;font-weight:500;background:transparent;border:0;
|
|
349
|
+
color:var(--muted);padding:10px 15px;cursor:pointer;position:relative;transition:.12s}
|
|
350
|
+
.tabs button:hover{color:var(--text)}
|
|
351
|
+
.tabs button.active{color:#fff}
|
|
352
|
+
.tabs button.active::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--azure)}
|
|
353
|
+
.tabs button .c{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-left:6px}
|
|
354
|
+
.vtools{display:flex;flex-direction:column;gap:10px;margin-bottom:6px}
|
|
355
|
+
.vtools-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
356
|
+
.vtools-row .search{flex:1;min-width:0}
|
|
357
|
+
.vtools-actions{margin-left:auto;display:flex;gap:7px}
|
|
358
|
+
.stick{position:sticky;top:0;z-index:5}
|
|
359
|
+
.itemwrap{margin-top:8px}
|
|
360
|
+
|
|
243
361
|
@media (max-width:900px){
|
|
244
362
|
/* .app grid + main padding handled by Tailwind max-[900px]: utilities */
|
|
245
363
|
aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
|
|
246
364
|
aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
|
|
365
|
+
.navpins,.acct{display:none}
|
|
247
366
|
input.search{min-width:180px}
|
|
367
|
+
.dashgrid{grid-template-columns:1fr}
|
|
248
368
|
}
|
|
249
369
|
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
|
package/server.js
CHANGED
|
@@ -268,16 +268,26 @@ const VAULT_QUERY =
|
|
|
268
268
|
"| project name, resourceGroup, subscriptionId, location, " +
|
|
269
269
|
"sku=tostring(properties.sku.name), " +
|
|
270
270
|
"enableRbac=tostring(properties.enableRbacAuthorization), " +
|
|
271
|
-
"uri=tostring(properties.vaultUri)
|
|
271
|
+
"uri=tostring(properties.vaultUri), tags " +
|
|
272
|
+
"| order by name asc";
|
|
272
273
|
|
|
273
274
|
async function listVaultsRaw(force) {
|
|
274
275
|
if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
|
|
275
276
|
const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
// Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
|
|
278
|
+
const data = [];
|
|
279
|
+
let skipToken;
|
|
280
|
+
for (let page = 0; page < 100; page++) { // hard stop at 100k vaults — a safety bound
|
|
281
|
+
const options = { resultFormat: 'objectArray', $top: 1000 };
|
|
282
|
+
if (skipToken) options.$skipToken = skipToken;
|
|
283
|
+
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
body: { subscriptions: subIds, query: VAULT_QUERY, options },
|
|
286
|
+
});
|
|
287
|
+
if (out.data) data.push(...out.data);
|
|
288
|
+
skipToken = out.$skipToken;
|
|
289
|
+
if (!skipToken) break;
|
|
290
|
+
}
|
|
281
291
|
vaultCache = { at: Date.now(), data };
|
|
282
292
|
return data;
|
|
283
293
|
}
|
|
@@ -292,6 +302,65 @@ app.get('/api/vaults', async (req, res) => {
|
|
|
292
302
|
}
|
|
293
303
|
});
|
|
294
304
|
|
|
305
|
+
// Find a vault's management-plane coordinates (sub + rg) from the cached inventory.
|
|
306
|
+
async function findVaultMeta(name) {
|
|
307
|
+
const vaults = await listVaultsRaw();
|
|
308
|
+
return vaults.find((v) => (v.name || '').toLowerCase() === name.toLowerCase());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// "Default" / governance tags that this tool must never edit or remove — they are
|
|
312
|
+
// applied by Azure Policy / automation and are read-only here. Patterns are matched
|
|
313
|
+
// case-insensitively; a trailing '*' is a prefix wildcard (e.g. "automation.*").
|
|
314
|
+
// Operators can override the whole list with AZBO_PROTECTED_TAGS (comma-separated);
|
|
315
|
+
// set it to an empty string to disable protection entirely.
|
|
316
|
+
const PROTECTED_TAGS = (() => {
|
|
317
|
+
const def = ['env', 'environment', 'cost-center', 'costcenter', 'contact-*', 'owner',
|
|
318
|
+
'solution', 'location', 'roles', 'managed-by', 'managedby',
|
|
319
|
+
'expirationdate', 'runninginterval', 'automation.*', 'hidden-*'];
|
|
320
|
+
const raw = process.env.AZBO_PROTECTED_TAGS;
|
|
321
|
+
if (raw === undefined) return def;
|
|
322
|
+
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
323
|
+
})();
|
|
324
|
+
function tagMatch(pattern, key) {
|
|
325
|
+
const p = pattern.toLowerCase(), k = String(key).toLowerCase();
|
|
326
|
+
return p.endsWith('*') ? k.startsWith(p.slice(0, -1)) : k === p;
|
|
327
|
+
}
|
|
328
|
+
function isProtectedTag(key) { return PROTECTED_TAGS.some((p) => tagMatch(p, key)); }
|
|
329
|
+
|
|
330
|
+
// Replace a vault's tag set (management plane). The body's `tags` object is the
|
|
331
|
+
// COMPLETE desired set — Key Vault's PATCH overwrites the whole tags collection.
|
|
332
|
+
// Protected/default tags are force-preserved from the current state, so they can be
|
|
333
|
+
// neither edited nor removed regardless of what the client sends.
|
|
334
|
+
// Requires management-plane write (e.g. Contributor / Tag Contributor) on the vault.
|
|
335
|
+
app.put('/api/vaults/:name/tags', (req, res) => {
|
|
336
|
+
const { name } = req.params;
|
|
337
|
+
if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
338
|
+
const { tags } = req.body || {};
|
|
339
|
+
if (!tags || typeof tags !== 'object' || Array.isArray(tags))
|
|
340
|
+
return res.status(400).json({ ok: false, error: 'tags must be an object of name → value' });
|
|
341
|
+
const entries = Object.entries(tags);
|
|
342
|
+
if (entries.length > 50) return res.status(400).json({ ok: false, error: 'Azure allows at most 50 tags per resource' });
|
|
343
|
+
for (const [k, v] of entries) {
|
|
344
|
+
if (typeof k !== 'string' || !k.length || k.length > 512) return res.status(400).json({ ok: false, error: `Invalid tag name: ${k}` });
|
|
345
|
+
if (typeof v !== 'string' || v.length > 256) return res.status(400).json({ ok: false, error: `Invalid value for tag "${k}" (max 256 chars)` });
|
|
346
|
+
}
|
|
347
|
+
send(res, (async () => {
|
|
348
|
+
const v = await findVaultMeta(name);
|
|
349
|
+
if (!v) throw new Error('Vault not found in inventory — try Refresh first');
|
|
350
|
+
const current = v.tags || {};
|
|
351
|
+
// Start from the client's desired set, then force every protected tag that exists
|
|
352
|
+
// today back to its original value — this blocks edit AND removal of default tags.
|
|
353
|
+
const finalTags = {};
|
|
354
|
+
for (const [k, val] of entries) { if (!isProtectedTag(k)) finalTags[k] = val; }
|
|
355
|
+
for (const [k, val] of Object.entries(current)) { if (isProtectedTag(k)) finalTags[k] = val; }
|
|
356
|
+
const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
|
|
357
|
+
`/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
|
|
358
|
+
const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags } });
|
|
359
|
+
v.tags = (out && out.tags) || finalTags; // reflect immediately in the cached inventory
|
|
360
|
+
return { tags: v.tags };
|
|
361
|
+
})());
|
|
362
|
+
});
|
|
363
|
+
|
|
295
364
|
// ---- API: expiring secrets & certificates (scans every readable vault) -----
|
|
296
365
|
|
|
297
366
|
/** Run async worker over items with bounded concurrency. */
|
|
@@ -475,6 +544,15 @@ app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
|
|
|
475
544
|
send(res, kvWrite(name, 'PATCH', `/certificates/${cert}?api-version=${KV_API}`, { attributes: attrs }));
|
|
476
545
|
});
|
|
477
546
|
|
|
547
|
+
// Turn a write failure into a precise, actionable reason. A 403 from Key Vault is
|
|
548
|
+
// either a network/firewall block (fix: VPN) or a real data-plane permission gap
|
|
549
|
+
// (fix: grant Set on secrets) — never conflate the two as a bare "forbidden".
|
|
550
|
+
function writeReason(e) {
|
|
551
|
+
if (e.firewall) return 'blocked by the vault firewall — your IP is not allowed (connect to the corporate VPN)';
|
|
552
|
+
if (e.forbidden) return 'no write permission — your identity can read but not Set secrets on this vault';
|
|
553
|
+
return e.message || 'failed';
|
|
554
|
+
}
|
|
555
|
+
|
|
478
556
|
// Create one or many secrets in a vault. items: [{name, value}], optional common expires.
|
|
479
557
|
app.post('/api/vaults/:name/secrets-create', async (req, res) => {
|
|
480
558
|
const { name } = req.params;
|
|
@@ -492,7 +570,7 @@ app.post('/api/vaults/:name/secrets-create', async (req, res) => {
|
|
|
492
570
|
if (Object.keys(attrs).length) body.attributes = attrs;
|
|
493
571
|
await kvWrite(name, 'PUT', `/secrets/${it.name}?api-version=${KV_API}`, body);
|
|
494
572
|
return { ...r, ok: true };
|
|
495
|
-
} catch (e) { return { ...r, ok: false, error: e.
|
|
573
|
+
} catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
|
|
496
574
|
}, 10);
|
|
497
575
|
res.json({ ok: true, results });
|
|
498
576
|
});
|
|
@@ -517,7 +595,7 @@ app.post('/api/batch', async (req, res) => {
|
|
|
517
595
|
await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
|
|
518
596
|
}
|
|
519
597
|
return { ...r, ok: true };
|
|
520
|
-
} catch (e) { return { ...r, ok: false, error: e.
|
|
598
|
+
} catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
|
|
521
599
|
}, 12);
|
|
522
600
|
res.json({ ok: true, results });
|
|
523
601
|
});
|
|
@@ -534,7 +612,8 @@ const ENV_RULES = (() => {
|
|
|
534
612
|
})();
|
|
535
613
|
const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
|
|
536
614
|
app.get('/api/config', (req, res) =>
|
|
537
|
-
res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
|
|
615
|
+
res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
|
|
616
|
+
version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
|
|
538
617
|
|
|
539
618
|
function openBrowser(url) {
|
|
540
619
|
if (process.env.AZBO_NO_OPEN) return;
|