1053 lines
43 KiB
JavaScript
1053 lines
43 KiB
JavaScript
// ==UserScript==
|
|
// @name AWS Billing Exporter
|
|
// @namespace https://console.aws.amazon.com/billing
|
|
// @version 4.4
|
|
// @description Export detailed billing CSV. Accounts auto-loaded from billing API on panel open and on month/year change. Shows USD total per account. Identifies management account.
|
|
// @author —
|
|
// @updateURL https://git.radmik.com.my/Syed/aws-bill-to-csv/raw/branch/main/bill-to-csv.user.js
|
|
// @downloadURL https://git.radmik.com.my/Syed/aws-bill-to-csv/raw/branch/main/bill-to-csv.user.js
|
|
// @match https://us-east-1.console.aws.amazon.com/*
|
|
// @match https://*.us-east-1.console.aws.amazon.com/*
|
|
// @grant none
|
|
// @run-at document-start
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
|
|
const VERSION = '4.4';
|
|
|
|
// Detect billing console base URL from current page — handles both standard
|
|
// (us-east-1.console.aws.amazon.com) and account-specific subdomain URLs
|
|
// (347220827321-6aoq6a4e.us-east-1.console.aws.amazon.com).
|
|
const BILLING_BASE = window.location.origin;
|
|
const UI_ID = 'abe-root';
|
|
const CSS_ID = 'abe-styles';
|
|
|
|
// Known AWS issuer IDs → company display names.
|
|
// Add more here if your org has different issuers.
|
|
const ISSUER_NAME_MAP = {
|
|
A1SF0XEGC4WU7S: 'Amazon Web Services Malaysia Sdn. Bhd.',
|
|
AG8OH9DC5PTT3: 'Amazon Web Services, Inc.',
|
|
};
|
|
|
|
// ─── XSRF TOKEN CAPTURE ──────────────────────────────────────────────────────
|
|
//
|
|
// The AWS billing console sends a custom "x-awsbc-xsrf-token" header with every
|
|
// API request. Without it, some accounts return 404 even though they exist.
|
|
// The token is generated by the billing app's JS and held in memory — it is NOT
|
|
// in any cookie or DOM element we can read directly.
|
|
//
|
|
// Solution: hook window.fetch() early to intercept the first billing API call
|
|
// the page makes naturally (e.g. loading the charges table). We extract the
|
|
// token from that call's headers, store it, and attach it to all our requests.
|
|
|
|
let _xsrfToken = null; // captured from page's own fetch calls
|
|
let _managementAccountId = null; // captured from DescribeOrganization response
|
|
|
|
(function hookFetch() {
|
|
const _origFetch = window.fetch.bind(window);
|
|
|
|
window.fetch = function (input, init) {
|
|
const url = (typeof input === 'string') ? input : (input?.url || '');
|
|
|
|
// ── Capture XSRF token from billing API calls ──────────────────────────
|
|
if (!_xsrfToken && url.includes('/billing/rest/')) {
|
|
const headers = init?.headers || {};
|
|
const token =
|
|
(headers instanceof Headers)
|
|
? headers.get('x-awsbc-xsrf-token')
|
|
: Array.isArray(headers)
|
|
? (headers.find(([k]) => k.toLowerCase() === 'x-awsbc-xsrf-token') || [])[1]
|
|
: headers['x-awsbc-xsrf-token'] || headers['X-Awsbc-Xsrf-Token'];
|
|
|
|
if (token) _xsrfToken = token;
|
|
}
|
|
|
|
// ── Passively capture DescribeOrganization responses ───────────────────
|
|
// If the console naturally calls the Organizations API, extract MasterAccountId.
|
|
if (!_managementAccountId && url.includes('organizations')) {
|
|
const h = init?.headers || {};
|
|
const target =
|
|
(h instanceof Headers) ? h.get('X-Amz-Target')
|
|
: Array.isArray(h) ? (h.find(([k]) => k.toLowerCase() === 'x-amz-target') || [])[1]
|
|
: h['X-Amz-Target'] || h['x-amz-target'] || '';
|
|
if (String(target).includes('DescribeOrganization')) {
|
|
const p = _origFetch(input, init);
|
|
p.then(resp => resp.clone().json()
|
|
.then(d => { if (d?.Organization?.MasterAccountId) _managementAccountId = d.Organization.MasterAccountId; })
|
|
.catch(() => {})).catch(() => {});
|
|
return p;
|
|
}
|
|
}
|
|
|
|
return _origFetch(input, init);
|
|
};
|
|
})();
|
|
|
|
// ─── XHR HOOK (AWS SDK v2 uses XHR, not fetch) ───────────────────────────────
|
|
//
|
|
// The AWS SDK v2 sends requests via XMLHttpRequest. We hook open() to note the
|
|
// URL and the target header, then hook the load event to read the response body.
|
|
|
|
(function hookXHR() {
|
|
const _open = XMLHttpRequest.prototype.open;
|
|
const _setHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
|
|
XMLHttpRequest.prototype.open = function (method, url) {
|
|
this._abeUrl = String(url || '');
|
|
this._abeTarget = '';
|
|
return _open.apply(this, arguments);
|
|
};
|
|
|
|
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
|
|
if (name && name.toLowerCase() === 'x-amz-target') this._abeTarget = value || '';
|
|
return _setHeader.apply(this, arguments);
|
|
};
|
|
|
|
const _origSend = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.send = function (body) {
|
|
if (!_managementAccountId &&
|
|
this._abeUrl.includes('organizations') &&
|
|
String(this._abeTarget).includes('DescribeOrganization')) {
|
|
this.addEventListener('load', function () {
|
|
try {
|
|
const d = JSON.parse(this.responseText);
|
|
if (d?.Organization?.MasterAccountId)
|
|
_managementAccountId = d.Organization.MasterAccountId;
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
return _origSend.apply(this, arguments);
|
|
};
|
|
})();
|
|
|
|
// ─── FORMATTING ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format USD amount preserving full raw precision from the API.
|
|
* No rounding — strips only IEEE-754 floating-point noise beyond 10 sig figs.
|
|
* Handles scientific notation (e.g. 6e-06 → "0.000006").
|
|
* Adds comma separators on the integer part.
|
|
* e.g. 9.000004 → "9.000004", 2724.113 → "2,724.113", -74.6232 → "-74.6232"
|
|
*/
|
|
function fmtAmount(raw) {
|
|
if (raw === null || raw === undefined) return '0';
|
|
const v = parseFloat(String(raw));
|
|
if (v === 0 || isNaN(v)) return '0';
|
|
const clean = parseFloat(v.toPrecision(10)); // strip IEEE-754 noise
|
|
if (clean === 0) return '0';
|
|
const neg = clean < 0;
|
|
const cleanAbs = Math.abs(clean);
|
|
// toFixed(10) avoids scientific notation for tiny values, then strip trailing zeros
|
|
const s = cleanAbs.toFixed(10).replace(/\.?0+$/, '');
|
|
const parts = s.split('.');
|
|
parts[0] = parseInt(parts[0], 10).toLocaleString('en-US');
|
|
return (neg ? '-' : '') + parts.join('.');
|
|
}
|
|
|
|
/**
|
|
* Format usage amount number only (no unit) — for the Usage Amount column.
|
|
* Integers: no decimal (1488.0 → "1,488")
|
|
* Decimals: full raw precision (5060.109 → "5,060.109")
|
|
* Handles scientific notation (3.8e-7 → "0.00000038")
|
|
* Returns empty string for null/undefined (credit rows with no quantity)
|
|
*/
|
|
function fmtUsageAmount(amount) {
|
|
if (amount === null || amount === undefined) return '';
|
|
const v = parseFloat(String(amount));
|
|
if (isNaN(v)) return '';
|
|
if (v === Math.floor(v)) return Math.floor(v).toLocaleString('en-US');
|
|
const clean = parseFloat(v.toPrecision(10));
|
|
const s = Math.abs(clean).toFixed(10).replace(/\.?0+$/, '');
|
|
const parts = s.split('.');
|
|
parts[0] = parseInt(parts[0], 10).toLocaleString('en-US');
|
|
return (clean < 0 ? '-' : '') + parts.join('.');
|
|
}
|
|
|
|
/**
|
|
* Return the usage unit string for the Usage Unit column.
|
|
* Returns empty string for rows that have no meaningful unit.
|
|
*/
|
|
function fmtUsageUnit(amount, unit) {
|
|
if (amount === null || amount === undefined) return '';
|
|
return unit || '';
|
|
}
|
|
|
|
/** Month label: "MAR 2026" (matches legacy DOM-scraper format) */
|
|
function fmtMonthLabel(year, month) {
|
|
const M = ['JAN','FEB','MAR','APR','MAY','JUN',
|
|
'JUL','AUG','SEP','OCT','NOV','DEC'];
|
|
return `${M[month - 1]} ${year}`;
|
|
}
|
|
|
|
/** Always-quoted CSV cell */
|
|
function csvQ(val) {
|
|
if (val === null || val === undefined) return '""';
|
|
return '"' + String(val).replace(/"/g, '""') + '"';
|
|
}
|
|
|
|
// ─── COMPANY NAME RESOLVER ────────────────────────────────────────────────────
|
|
|
|
function resolveCompanyName(issuerId) {
|
|
if (ISSUER_NAME_MAP[issuerId]) return ISSUER_NAME_MAP[issuerId];
|
|
const hits = document.body.innerText.match(/Amazon Web Services[^\n,]*/gi);
|
|
if (hits) return hits.sort((a, b) => b.length - a.length)[0].trim();
|
|
return 'Amazon Web Services';
|
|
}
|
|
|
|
// ─── BILLING API ──────────────────────────────────────────────────────────────
|
|
|
|
async function fetchBill(accountId, year, month) {
|
|
const url = accountId
|
|
? `${BILLING_BASE}/billing/rest/v1.0/bill/linked/completebill` +
|
|
`?linkedAccountId=${accountId}&year=${year}&month=${month}&groupBy=ServiceProvider`
|
|
: `${BILLING_BASE}/billing/rest/v1.0/bill/completebill` +
|
|
`?year=${year}&month=${month}&groupBy=ServiceProvider`;
|
|
|
|
// Build headers — include XSRF token if we captured one from the page
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken;
|
|
|
|
const resp = await fetch(url, { credentials: 'include', headers });
|
|
|
|
if (resp.ok) return resp.json();
|
|
|
|
// Build a meaningful error message from the response
|
|
let detail = `HTTP ${resp.status}`;
|
|
if (resp.status === 404) {
|
|
detail = _xsrfToken
|
|
? 'HTTP 404 — account has no billing data for this period'
|
|
: 'HTTP 404 — XSRF token not yet captured; try refreshing the page and waiting a moment';
|
|
} else if (resp.status === 403) {
|
|
detail = 'HTTP 403 — insufficient permissions';
|
|
} else {
|
|
try {
|
|
const body = await resp.json();
|
|
if (body && (body.message || body.errorMessage))
|
|
detail = `HTTP ${resp.status}: ${body.message || body.errorMessage}`;
|
|
} catch (_) {}
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
// ─── LINKED ACCOUNTS API ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch the exact list of linked accounts for a given month/year
|
|
* using the billing gateway API — same source the AWS console uses.
|
|
* Returns [{id, label, total}] sorted by total descending (as AWS returns them).
|
|
*/
|
|
async function fetchLinkedAccounts(year, month) {
|
|
const url = `${BILLING_BASE}/billing/rest/api-proxy/pbgs?query=getLinkedAccountData`;
|
|
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken;
|
|
|
|
const payload = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Encoding': 'amz-1.0',
|
|
'X-Amz-Target': 'AWSProformaBillingGatewayService.GetTotal',
|
|
},
|
|
path: '/',
|
|
region: 'us-east-1',
|
|
contentString: JSON.stringify({
|
|
timePeriod: {
|
|
startBillingPeriod: { year, month },
|
|
endBillingPeriod: { year, month },
|
|
},
|
|
usageType: 'FAMILY',
|
|
caller: 'READONLY',
|
|
sort: { order: 'DESC' },
|
|
groupBy: ['LINKED_ACCOUNT_ID', 'LINKED_ACCOUNT_NAME'],
|
|
filter: {
|
|
logicalOperator: 'AND',
|
|
expressions: [
|
|
{
|
|
logicalOperator: 'NOT',
|
|
expressions: [{ comparison: { attribute: 'LINE_ITEM_TYPE', values: ['Refund'], matchOption: 'EQUAL' } }],
|
|
},
|
|
{
|
|
logicalOperator: 'NOT',
|
|
expressions: [{
|
|
logicalOperator: 'AND',
|
|
expressions: [
|
|
{ comparison: { attribute: 'LINE_ITEM_TYPE', values: ['Tax'], matchOption: 'EQUAL' } },
|
|
{ comparison: { attribute: 'AMOUNT', values: ['0'], matchOption: 'LOWER' } },
|
|
],
|
|
}],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
|
|
const data = await resp.json();
|
|
|
|
return (data.results || []).map(result => {
|
|
const groups = result.groups || [];
|
|
const id = groups.find(g => g.key === 'LINKED_ACCOUNT_ID')?.value || '';
|
|
const label = groups.find(g => g.key === 'LINKED_ACCOUNT_NAME')?.value || '';
|
|
const total = result.totalByCurrencies?.find(c => c.currency === 'USD')?.sum ?? 0;
|
|
return { id, label, total };
|
|
}).filter(a => a.id);
|
|
}
|
|
|
|
// ─── MANAGEMENT ACCOUNT DETECTION ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns the management (payer) account ID.
|
|
*
|
|
* Strategy 1: passive XHR/fetch hook already captured it this session.
|
|
* Strategy 2: GET /billing/rest/v1.0/account — returns accountRole:"Payer"
|
|
* and accountId. Called fresh every run, no caching needed.
|
|
* Strategy 3: manual input fallback (user sets it in the panel).
|
|
*/
|
|
async function fetchManagementAccountId() {
|
|
if (_managementAccountId) return _managementAccountId;
|
|
|
|
// ── Strategy 2: billing REST /account endpoint ─────────────────────────
|
|
try {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken;
|
|
|
|
const resp = await fetch(`${BILLING_BASE}/billing/rest/v1.0/account`, {
|
|
credentials: 'include',
|
|
headers,
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data.accountRole === 'Payer' && data.accountId) {
|
|
_managementAccountId = data.accountId;
|
|
return data.accountId;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── CURRENT ACCOUNT (single-account mode) ────────────────────────────────────
|
|
|
|
function scrapeNavAccountName() {
|
|
const navLabel = document.querySelector('[data-testid="account-label"]');
|
|
if (!navLabel) return null;
|
|
const match = navLabel.textContent.match(/^(.+?)\s*\(\d+\)$/);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
async function fetchCurrentAccount() {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken;
|
|
const resp = await fetch(`${BILLING_BASE}/billing/rest/v1.0/account`, {
|
|
credentials: 'include', headers,
|
|
});
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
const id = data.accountId || '';
|
|
const label = scrapeNavAccountName() || id;
|
|
return { id, label, role: data.accountRole };
|
|
}
|
|
|
|
// ─── ROW PARSER ───────────────────────────────────────────────────────────────
|
|
//
|
|
// SAVINGS PLAN CHARGE TYPE LOGIC (v4.4 — FIXED)
|
|
// ──────────────────────────────────────────────
|
|
// Each SP_Discount line item from the AWS billing API is a SINGLE credit
|
|
// (li.chargeAmount.amount is already negative, e.g. -0.658005). It represents
|
|
// the actual discount AWS applies against the on-demand-equivalent cost of
|
|
// usage that was covered by a Savings Plan.
|
|
//
|
|
// v4.3 bug: this single credit was split into two synthetic rows —
|
|
// "Savings Plan Covered Usage" = +0.658005 (flipped positive)
|
|
// "Savings Plan Negation" = -0.658005 (original, negative)
|
|
// Summed together these net to exactly $0, which silently ERASES the real
|
|
// discount instead of applying it — every SP_Discount line item overstated
|
|
// the grand total by the size of its own discount (and if anyone filtered
|
|
// by charge type and excluded "Savings Plan Negation" — as the old inline
|
|
// filter guidance below suggested — the discount got added a second time).
|
|
//
|
|
// Fix: emit ONE row per SP_Discount line item, carrying the original signed
|
|
// chargeAmount (negative) as-is. No splitting, no cancel-to-zero trap.
|
|
// This matches AWS Cost Explorer / the invoice total exactly, whether you
|
|
// sum the whole CSV or filter by any subset of charge types that includes
|
|
// "Savings Plan Discount".
|
|
//
|
|
// ComputeSavingsPlans commitment rows → "Savings Plan Recurring Fee"
|
|
//
|
|
// For client billing filter:
|
|
// "Usage" + "Savings Plan Discount" + "Savings Plan Recurring Fee"
|
|
|
|
function parseRows(data, accountId, accountLabel, year, month) {
|
|
const rows = [];
|
|
const mo = fmtMonthLabel(year, month);
|
|
const acctCol = `${accountId} (${accountLabel})`;
|
|
const SPP = 'Solution Provider Program Discounts';
|
|
|
|
// bill/completebill (single-account) returns { payerMap: { "<payerId>": { serviceProviderList } } }
|
|
// bill/linked/completebill (management) returns { bill: { serviceProviderList } }
|
|
let serviceProviderList;
|
|
if (data.bill) {
|
|
serviceProviderList = data.bill.serviceProviderList;
|
|
} else if (data.payerMap) {
|
|
const payerEntry = Object.values(data.payerMap)[0];
|
|
serviceProviderList = payerEntry?.serviceProviderList || [];
|
|
} else {
|
|
serviceProviderList = [];
|
|
}
|
|
|
|
for (const sp of serviceProviderList) {
|
|
const companyName = resolveCompanyName(sp.issuerId);
|
|
|
|
for (const product of sp.productInformationList) {
|
|
const serviceName = product.name;
|
|
|
|
for (const region of product.regionList) {
|
|
const regionName = region.name || '';
|
|
|
|
for (const it of region.instanceTypeList) {
|
|
const serviceDetail = it.instanceType || 'No Instance Type';
|
|
|
|
for (const li of it.lineItemList) {
|
|
|
|
// ── Savings Plan credit → single row, true signed amount ────
|
|
if (li.lineItemSubType === 'SP_Discount') {
|
|
rows.push([
|
|
mo, acctCol, companyName, serviceName,
|
|
regionName, serviceDetail, li.description || '',
|
|
'Savings Plan Discount',
|
|
fmtUsageAmount(li.discountUsageAmount),
|
|
fmtUsageUnit(li.discountUsageAmount, li.usageUnitName),
|
|
fmtAmount(li.chargeAmount?.amount ?? 0)
|
|
]);
|
|
|
|
continue; // skip the normal row push below
|
|
}
|
|
|
|
// ── All other line items ─────────────────────────────────────
|
|
|
|
// Charge Type
|
|
let chargeType;
|
|
if (product.code === 'ComputeSavingsPlans') {
|
|
chargeType = 'Savings Plan Recurring Fee';
|
|
} else if (li.lineItemType === 'Credit' && li.isDiscount === true) {
|
|
chargeType = 'Bundle Discount';
|
|
} else {
|
|
chargeType = li.lineItemType || 'Usage';
|
|
}
|
|
|
|
// Usage Amount + Unit
|
|
let usageAmt, usageUnit;
|
|
if (li.lineItemType === 'Credit' && (li.usageAmount === null || li.usageAmount === 0)) {
|
|
usageAmt = '';
|
|
usageUnit = '';
|
|
} else {
|
|
usageAmt = fmtUsageAmount(li.usageAmount);
|
|
usageUnit = fmtUsageUnit(li.usageAmount, li.usageUnitName);
|
|
}
|
|
|
|
rows.push([
|
|
mo, acctCol, companyName, serviceName,
|
|
regionName, serviceDetail, li.description || '',
|
|
chargeType, usageAmt, usageUnit,
|
|
fmtAmount(li.chargeAmount?.amount ?? 0)
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SPP / EDP discount row per service ──────────────────────────
|
|
const sppTotal = Object.values(product.discounts || {}).reduce((s, v) => s + v, 0);
|
|
const edpTotal = parseFloat(String(product.edpDiscounts?.amount ?? 0));
|
|
const discTotal = sppTotal + edpTotal;
|
|
if (discTotal !== 0) {
|
|
rows.push([
|
|
mo, acctCol, companyName, serviceName,
|
|
'', SPP, SPP,
|
|
'SPP Discount', '', '',
|
|
fmtAmount(-discTotal)
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// ─── CSV + DOWNLOAD ───────────────────────────────────────────────────────────
|
|
|
|
function buildCSV(allRows) {
|
|
const header = [
|
|
'Month', 'Account ID', 'Amazon Company Name', 'Service Name',
|
|
'Region', 'Service Detail', 'Charge Rate Detail',
|
|
'Charge Type', 'Usage Amount', 'Usage Unit', 'Amount in USD'
|
|
];
|
|
const lines = [header.map(csvQ).join(',')];
|
|
for (const row of allRows) lines.push(row.map(csvQ).join(','));
|
|
return lines.join('\r\n');
|
|
}
|
|
|
|
function triggerDownload(csv, filename) {
|
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
const a = Object.assign(document.createElement('a'), {
|
|
href: URL.createObjectURL(blob), download: filename, style: 'display:none'
|
|
});
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
|
|
}
|
|
|
|
// ─── STYLES ───────────────────────────────────────────────────────────────────
|
|
|
|
function injectCSS() {
|
|
if (document.getElementById(CSS_ID)) return;
|
|
const el = document.createElement('style');
|
|
el.id = CSS_ID;
|
|
el.textContent = `
|
|
#abe-root {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
z-index: 2147483647;
|
|
font-family: 'Amazon Ember','Helvetica Neue',Arial,sans-serif;
|
|
font-size: 13px; line-height: 1.4;
|
|
}
|
|
#abe-fab {
|
|
width: 54px; height: 54px; border-radius: 50%;
|
|
background: #232f3e; border: 2.5px solid #ff9900;
|
|
color: #ff9900; font-size: 22px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
cursor: pointer; margin-left: auto;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,.4);
|
|
transition: transform .18s, background .18s;
|
|
}
|
|
#abe-fab:hover { background: #1a2533; transform: scale(1.08); }
|
|
|
|
#abe-panel {
|
|
display: none; margin-bottom: 12px;
|
|
background: #fff; border-radius: 14px;
|
|
box-shadow: 0 12px 48px rgba(0,0,0,.22);
|
|
width: 360px; overflow: hidden;
|
|
border: 1px solid #e2e5ea;
|
|
}
|
|
#abe-panel.open { display: block; animation: abe-in .18s ease; }
|
|
@keyframes abe-in {
|
|
from { opacity:0; transform:translateY(10px); }
|
|
to { opacity:1; transform:translateY(0); }
|
|
}
|
|
|
|
#abe-hdr {
|
|
background: #232f3e; padding: 15px 18px;
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
#abe-hdr-icon { font-size: 20px; }
|
|
#abe-hdr-text h3 { margin:0; font-size:14px; font-weight:700; color:#fff; }
|
|
#abe-hdr-text small { font-size:11px; color:#8fa3bc; }
|
|
#abe-hdr-close {
|
|
margin-left:auto; background:none; border:none;
|
|
color:#8fa3bc; font-size:18px; cursor:pointer; padding:0; line-height:1;
|
|
}
|
|
#abe-hdr-close:hover { color:#fff; }
|
|
|
|
#abe-body { padding: 16px 18px; }
|
|
|
|
.abe-row { display:flex; gap:10px; margin-bottom:14px; }
|
|
.abe-field { flex:1; }
|
|
.abe-label {
|
|
display:block; font-size:10.5px; font-weight:700;
|
|
color:#6b7280; text-transform:uppercase;
|
|
letter-spacing:.06em; margin-bottom:5px;
|
|
}
|
|
.abe-field select,
|
|
.abe-field input[type=number],
|
|
.abe-field input[type=text] {
|
|
width:100%; box-sizing:border-box; padding:7px 10px;
|
|
border:1.5px solid #d1d5db; border-radius:7px;
|
|
font-size:13px; color:#1f2937; background:#f9fafb;
|
|
outline:none; transition:border-color .15s;
|
|
}
|
|
.abe-field select:focus,
|
|
.abe-field input[type=number]:focus,
|
|
.abe-field input[type=text]:focus { border-color:#ff9900; background:#fff; }
|
|
|
|
#abe-acct-hdr { display:flex; align-items:center; margin-bottom:6px; }
|
|
#abe-acct-hdr .abe-label { margin:0; }
|
|
#abe-acct-actions { margin-left:auto; display:flex; gap:10px; align-items:center; }
|
|
.abe-link-btn {
|
|
font-size:11px; color:#ff9900; cursor:pointer;
|
|
text-decoration:underline; background:none;
|
|
border:none; padding:0; font-family:inherit;
|
|
}
|
|
#abe-refresh-btn { color:#6b7280; }
|
|
#abe-refresh-btn:hover { color:#ff9900; }
|
|
|
|
#abe-acct-box {
|
|
border:1.5px solid #e5e7eb; border-radius:9px;
|
|
max-height:160px; overflow-y:auto;
|
|
background:#f9fafb; margin-bottom:14px;
|
|
}
|
|
#abe-acct-box::-webkit-scrollbar { width:5px; }
|
|
#abe-acct-box::-webkit-scrollbar-track { background:transparent; }
|
|
#abe-acct-box::-webkit-scrollbar-thumb { background:#d1d5db; border-radius:99px; }
|
|
|
|
.abe-acct {
|
|
display:flex; align-items:center; gap:9px;
|
|
padding:8px 12px; border-bottom:1px solid #f0f0f0;
|
|
cursor:pointer; transition:background .1s;
|
|
}
|
|
.abe-acct:last-child { border-bottom:none; }
|
|
.abe-acct:hover { background:#f0f4ff; }
|
|
.abe-acct input[type=checkbox] { accent-color:#ff9900; width:14px; height:14px; flex-shrink:0; }
|
|
.abe-acct-id { font-size:12px; font-weight:700; color:#1f2937; }
|
|
.abe-acct-lbl { font-size:11px; color:#9ca3af; }
|
|
.abe-acct-total { margin-left:auto; font-size:11px; font-weight:600; color:#374151; white-space:nowrap; }
|
|
.abe-mgmt-badge {
|
|
display:inline-block; margin-left:6px;
|
|
font-size:9.5px; font-weight:700; letter-spacing:.04em;
|
|
color:#7c3aed; background:#ede9fe; border:1px solid #c4b5fd;
|
|
border-radius:4px; padding:1px 5px; vertical-align:middle;
|
|
white-space:nowrap;
|
|
}
|
|
|
|
.abe-no-acct {
|
|
padding:14px 12px; font-size:12px;
|
|
color:#9ca3af; text-align:center; line-height:1.6;
|
|
}
|
|
.abe-no-acct a { color:#ff9900; text-decoration:none; cursor:pointer; }
|
|
|
|
/* Scanning state */
|
|
.abe-scanning {
|
|
padding:12px; display:flex; align-items:center;
|
|
gap:8px; font-size:12px; color:#6b7280;
|
|
}
|
|
.abe-spinner {
|
|
width:14px; height:14px; border:2px solid #e5e7eb;
|
|
border-top-color:#ff9900; border-radius:50%;
|
|
animation:abe-spin .7s linear infinite; flex-shrink:0;
|
|
}
|
|
@keyframes abe-spin { to { transform:rotate(360deg); } }
|
|
|
|
#abe-export {
|
|
width:100%; padding:11px; border:none; border-radius:8px;
|
|
background:#ff9900; color:#fff; font-size:14px; font-weight:700;
|
|
cursor:pointer; transition:background .18s, opacity .18s;
|
|
}
|
|
#abe-export:hover:not(:disabled) { background:#e68900; }
|
|
#abe-export:disabled { opacity:.55; cursor:not-allowed; }
|
|
|
|
#abe-status {
|
|
margin-top:10px; font-size:12px; color:#6b7280;
|
|
min-height:16px; text-align:center;
|
|
}
|
|
#abe-status.err { color:#dc2626; }
|
|
#abe-status.ok { color:#16a34a; font-weight:700; }
|
|
|
|
#abe-bar-wrap {
|
|
margin-top:8px; background:#e5e7eb; border-radius:99px;
|
|
height:5px; overflow:hidden; display:none;
|
|
}
|
|
#abe-bar {
|
|
height:100%; background:#ff9900; border-radius:99px;
|
|
width:0; transition:width .25s ease;
|
|
}
|
|
#abe-errors {
|
|
margin-top:8px; font-size:11px; color:#dc2626;
|
|
max-height:60px; overflow-y:auto; display:none;
|
|
}
|
|
`;
|
|
document.head.appendChild(el);
|
|
}
|
|
|
|
// ─── UI ───────────────────────────────────────────────────────────────────────
|
|
|
|
let _panel = null; // panel DOM element reference
|
|
|
|
function buildUI() {
|
|
document.getElementById(CSS_ID)?.remove();
|
|
document.getElementById(UI_ID)?.remove();
|
|
injectCSS();
|
|
|
|
const now = new Date();
|
|
const curY = now.getFullYear();
|
|
const curM = now.getMonth() + 1;
|
|
const defM = curM === 1 ? 12 : curM - 1;
|
|
const defY = curM === 1 ? curY - 1 : curY;
|
|
|
|
const MONTH_NAMES = [
|
|
'January','February','March','April','May','June',
|
|
'July','August','September','October','November','December'
|
|
];
|
|
|
|
const root = document.createElement('div');
|
|
root.id = UI_ID;
|
|
|
|
const panel = document.createElement('div');
|
|
panel.id = 'abe-panel';
|
|
panel.innerHTML = `
|
|
<div id="abe-hdr">
|
|
<span id="abe-hdr-icon">📊</span>
|
|
<div id="abe-hdr-text">
|
|
<h3>AWS Billing Exporter
|
|
<span style="font-size:10px;opacity:.55;font-weight:400;margin-left:4px">v${VERSION}</span>
|
|
</h3>
|
|
<small id="abe-subtitle">Multi-account · Direct API · CSV</small>
|
|
</div>
|
|
<button id="abe-hdr-close" title="Close">✕</button>
|
|
</div>
|
|
<div id="abe-body">
|
|
<div class="abe-row">
|
|
<div class="abe-field" style="flex:2">
|
|
<span class="abe-label">Project Name <span style="font-weight:400;opacity:.6">(prefix)</span></span>
|
|
<input type="text" id="abe-project" placeholder="Auto-filled from management account…"
|
|
style="text-transform:uppercase">
|
|
</div>
|
|
</div>
|
|
<div class="abe-row">
|
|
<div class="abe-field">
|
|
<span class="abe-label">Month</span>
|
|
<select id="abe-month">
|
|
${MONTH_NAMES.map((n, i) =>
|
|
`<option value="${i+1}"${i+1 === defM ? ' selected':''}>${n}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="abe-field">
|
|
<span class="abe-label">Year</span>
|
|
<input type="number" id="abe-year" value="${defY}" min="2020" max="${curY}">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="abe-acct-hdr">
|
|
<span class="abe-label">Accounts</span>
|
|
<div id="abe-acct-actions">
|
|
<button class="abe-link-btn" id="abe-refresh-btn" title="Re-scan page for accounts">↺ Refresh</button>
|
|
<button class="abe-link-btn" id="abe-sel-all">Select all</button>
|
|
</div>
|
|
</div>
|
|
<div id="abe-acct-box">
|
|
<div class="abe-scanning">
|
|
<div class="abe-spinner"></div>
|
|
<span>Scanning for accounts…</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button id="abe-export">⬇ Export CSV</button>
|
|
<div id="abe-status"></div>
|
|
<div id="abe-bar-wrap"><div id="abe-bar"></div></div>
|
|
<div id="abe-errors"></div>
|
|
</div>
|
|
`;
|
|
|
|
const fab = document.createElement('button');
|
|
fab.id = 'abe-fab';
|
|
fab.title = 'AWS Billing Exporter';
|
|
fab.innerHTML = '📥';
|
|
|
|
root.appendChild(panel);
|
|
root.appendChild(fab);
|
|
document.body.appendChild(root);
|
|
_panel = panel;
|
|
|
|
// ── Events ───────────────────────────────────────────────────────────────
|
|
|
|
// FAB: if not on billing page, redirect there (XSRF token only exists on /billing/).
|
|
// Pass ?abe-open=1 so the panel auto-opens after redirect.
|
|
// On the billing page: toggle panel normally.
|
|
const _onBillingPage = window.location.pathname.startsWith('/billing');
|
|
let _panelEverOpened = false;
|
|
|
|
fab.addEventListener('click', () => {
|
|
if (!_onBillingPage) {
|
|
window.location.href = window.location.origin + '/billing/home?abe-open=1#/bills';
|
|
return;
|
|
}
|
|
const isOpen = panel.classList.toggle('open');
|
|
if (isOpen && !_panelEverOpened) {
|
|
_panelEverOpened = true;
|
|
loadAccounts(panel);
|
|
}
|
|
});
|
|
|
|
panel.querySelector('#abe-hdr-close').addEventListener('click', () => panel.classList.remove('open'));
|
|
|
|
// Refresh button: reload accounts for current month/year
|
|
panel.querySelector('#abe-refresh-btn').addEventListener('click', () => loadAccounts(panel));
|
|
|
|
// Month / Year change: reload accounts with debounce (year needs typing time)
|
|
let _debounceTimer = null;
|
|
const onPeriodChange = () => {
|
|
clearTimeout(_debounceTimer);
|
|
_debounceTimer = setTimeout(() => loadAccounts(panel), 600);
|
|
};
|
|
panel.querySelector('#abe-month').addEventListener('change', onPeriodChange);
|
|
panel.querySelector('#abe-year').addEventListener('input', onPeriodChange);
|
|
|
|
// Select/deselect all toggle
|
|
panel.querySelector('#abe-sel-all').addEventListener('click', () => {
|
|
const chks = [...panel.querySelectorAll('.abe-chk')];
|
|
const allTicked = chks.every(c => c.checked);
|
|
chks.forEach(c => (c.checked = !allTicked));
|
|
panel.querySelector('#abe-sel-all').textContent = allTicked ? 'Select all' : 'Deselect all';
|
|
});
|
|
|
|
panel.querySelector('#abe-export').addEventListener('click', () => runExport(panel));
|
|
|
|
return panel;
|
|
}
|
|
|
|
// ─── ACCOUNT BOX RENDERER ────────────────────────────────────────────────────
|
|
|
|
function renderAccounts(panel, accounts) {
|
|
const box = panel.querySelector('#abe-acct-box');
|
|
box.innerHTML = '';
|
|
|
|
if (accounts.length === 0) {
|
|
box.innerHTML = `
|
|
<div class="abe-no-acct">
|
|
No accounts found for this period.<br>
|
|
Try a different month or click <a id="abe-inline-refresh">↺ Refresh</a>.
|
|
</div>`;
|
|
box.querySelector('#abe-inline-refresh')?.addEventListener('click', () => {
|
|
loadAccounts(panel);
|
|
});
|
|
return;
|
|
}
|
|
|
|
accounts.forEach(acc => {
|
|
const row = document.createElement('label');
|
|
row.className = 'abe-acct';
|
|
const isMgmt = _managementAccountId && acc.id === _managementAccountId;
|
|
const totalStr = acc.total !== undefined
|
|
? 'USD ' + acc.total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
: '';
|
|
row.innerHTML = `
|
|
<input type="checkbox" class="abe-chk"
|
|
value="${acc.id}" data-label="${acc.label}" checked>
|
|
<div>
|
|
<div class="abe-acct-id">
|
|
${acc.id}
|
|
${isMgmt ? '<span class="abe-mgmt-badge">★ Management</span>' : ''}
|
|
</div>
|
|
<div class="abe-acct-lbl">${acc.label}</div>
|
|
</div>
|
|
${totalStr ? `<div class="abe-acct-total">${totalStr}</div>` : ''}`;
|
|
box.appendChild(row);
|
|
});
|
|
|
|
panel.querySelector('#abe-sel-all').textContent = 'Deselect all';
|
|
}
|
|
|
|
// ─── ACCOUNT LOADER ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch linked accounts from the billing API for the currently selected
|
|
* month/year, then render them into the panel.
|
|
* Called on: FAB click, month change, year change, manual refresh.
|
|
*/
|
|
async function loadAccounts(panel) {
|
|
const year = parseInt(panel.querySelector('#abe-year').value, 10);
|
|
const month = parseInt(panel.querySelector('#abe-month').value, 10);
|
|
|
|
if (isNaN(year) || year < 2020 || isNaN(month)) return;
|
|
|
|
// Show loading spinner
|
|
const box = panel.querySelector('#abe-acct-box');
|
|
box.innerHTML = `
|
|
<div class="abe-scanning">
|
|
<div class="abe-spinner"></div>
|
|
<span>Loading accounts for ${new Date(year, month-1).toLocaleString('en-US',{month:'long'})} ${year}…</span>
|
|
</div>`;
|
|
|
|
// Reset select-all label
|
|
panel.querySelector('#abe-sel-all').textContent = 'Select all';
|
|
|
|
try {
|
|
// Run both concurrently; management account detection may arrive first or after
|
|
const [accounts, mgmtId] = await Promise.all([
|
|
fetchLinkedAccounts(year, month),
|
|
fetchManagementAccountId(),
|
|
]);
|
|
if (mgmtId) _managementAccountId = mgmtId;
|
|
|
|
const subtitle = panel.querySelector('#abe-subtitle');
|
|
|
|
if (accounts.length === 0) {
|
|
// Single-account mode: no linked accounts — fetch current account identity
|
|
const current = await fetchCurrentAccount();
|
|
if (current.id) {
|
|
panel.dataset.singleAccountMode = '1';
|
|
panel.dataset.singleAccountId = current.id;
|
|
panel.dataset.singleAccountLabel = current.label;
|
|
renderAccounts(panel, [{ id: current.id, label: current.label }]);
|
|
if (current.label && current.label !== current.id) {
|
|
panel.querySelector('#abe-project').value = current.label.toUpperCase();
|
|
}
|
|
if (subtitle) {
|
|
subtitle.textContent = 'Single account mode · Direct API · CSV · ✓ Auth ready';
|
|
subtitle.style.color = '#6fcf97';
|
|
}
|
|
} else {
|
|
renderAccounts(panel, []);
|
|
}
|
|
} else {
|
|
delete panel.dataset.singleAccountMode;
|
|
renderAccounts(panel, accounts);
|
|
|
|
// Auto-fill Project Name — prefer nav bar name, fallback to linked accounts label
|
|
if (_managementAccountId) {
|
|
const navName = scrapeNavAccountName();
|
|
if (navName) {
|
|
panel.querySelector('#abe-project').value = navName.toUpperCase();
|
|
} else {
|
|
const mgmtAcc = accounts.find(a => a.id === _managementAccountId);
|
|
if (mgmtAcc?.label) {
|
|
panel.querySelector('#abe-project').value = mgmtAcc.label.toUpperCase();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (subtitle) {
|
|
subtitle.textContent = 'Multi-account · Direct API · CSV · ✓ Auth ready';
|
|
subtitle.style.color = '#6fcf97';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
box.innerHTML = `
|
|
<div class="abe-no-acct">
|
|
Failed to load accounts: ${err.message}<br>
|
|
<a id="abe-inline-refresh">↺ Try again</a>
|
|
</div>`;
|
|
box.querySelector('#abe-inline-refresh')?.addEventListener('click', () => loadAccounts(panel));
|
|
}
|
|
}
|
|
|
|
// ─── EXPORT RUNNER ────────────────────────────────────────────────────────────
|
|
|
|
async function runExport(panel) {
|
|
const btn = panel.querySelector('#abe-export');
|
|
const status = panel.querySelector('#abe-status');
|
|
const barWrap = panel.querySelector('#abe-bar-wrap');
|
|
const bar = panel.querySelector('#abe-bar');
|
|
const errBox = panel.querySelector('#abe-errors');
|
|
|
|
const year = parseInt(panel.querySelector('#abe-year').value, 10);
|
|
const month = parseInt(panel.querySelector('#abe-month').value, 10);
|
|
const selected = [...panel.querySelectorAll('.abe-chk:checked')];
|
|
|
|
if (isNaN(year) || year < 2020) {
|
|
setStatus(status, 'Enter a valid year (2020 or later).', 'err'); return;
|
|
}
|
|
if (selected.length === 0) {
|
|
setStatus(status, 'Select at least one account.', 'err'); return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
errBox.style.display = 'none';
|
|
errBox.innerHTML = '';
|
|
barWrap.style.display = 'block';
|
|
bar.style.width = '0%';
|
|
setStatus(status, 'Starting…', '');
|
|
|
|
const allRows = [];
|
|
const errors = [];
|
|
const total = selected.length;
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
const chk = selected[i];
|
|
const id = chk.value;
|
|
const label = chk.dataset.label;
|
|
|
|
setStatus(status, `Fetching ${id} (${label})… [${i+1} / ${total}]`, '');
|
|
|
|
try {
|
|
const fetchId = panel.dataset.singleAccountMode === '1' ? null : id;
|
|
const json = await fetchBill(fetchId, year, month);
|
|
allRows.push(...parseRows(json, id, label, year, month));
|
|
} catch (err) {
|
|
errors.push(`${id} (${label}): ${err.message}`);
|
|
}
|
|
|
|
bar.style.width = Math.round(((i + 1) / total) * 100) + '%';
|
|
}
|
|
|
|
btn.disabled = false;
|
|
|
|
if (allRows.length === 0) {
|
|
setStatus(status,
|
|
errors.length ? 'All fetches failed — see details below.'
|
|
: 'No data returned from API.', 'err');
|
|
showErrors(errBox, errors);
|
|
return;
|
|
}
|
|
|
|
const M = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
|
const project = (panel.querySelector('#abe-project')?.value || '').trim().toUpperCase();
|
|
const prefix = project ? `${project}-` : '';
|
|
const filename = `${prefix}aws-billing-${M[month-1]}${year}-${total}accounts.csv`;
|
|
triggerDownload(buildCSV(allRows), filename);
|
|
|
|
const errNote = errors.length
|
|
? ` (${errors.length} account${errors.length > 1 ? 's' : ''} failed)` : '';
|
|
setStatus(status, `✓ ${filename} — ${allRows.length.toLocaleString()} rows${errNote}`, 'ok');
|
|
if (errors.length) showErrors(errBox, errors);
|
|
}
|
|
|
|
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
|
|
function setStatus(el, msg, cls) {
|
|
el.className = cls;
|
|
el.textContent = msg;
|
|
}
|
|
|
|
function showErrors(el, errors) {
|
|
if (!errors.length) return;
|
|
el.style.display = 'block';
|
|
el.innerHTML = errors.map(e => `• ${e}`).join('<br>');
|
|
}
|
|
|
|
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
|
|
|
// ── UI only on billing pages; hooks above already run on all pages ──────────
|
|
const ON_BILLING_PAGE = window.location.pathname.startsWith('/billing') ||
|
|
window.location.pathname.startsWith('/costmanagement');
|
|
|
|
function init() {
|
|
if (!ON_BILLING_PAGE) return;
|
|
const panel = buildUI();
|
|
// Auto-open if redirected here from another console page via ?abe-open=1
|
|
if (new URLSearchParams(window.location.search).get('abe-open') === '1') {
|
|
panel.classList.add('open');
|
|
// Clean the param from the URL without a page reload (keep fragment)
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('abe-open');
|
|
history.replaceState(null, '', url.toString());
|
|
// Retry loading accounts every 1 s, max 5 attempts — billing page needs
|
|
// a moment to initialise and capture the XSRF token after redirect.
|
|
(async () => {
|
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
await loadAccounts(panel);
|
|
if (panel.querySelector('.abe-chk')) break; // accounts loaded OK
|
|
if (attempt < 5) await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
// document-start: DOM may not exist yet — defer until body is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
})(); |