nuevo dash
This commit is contained in:
+117
-60
@@ -9,8 +9,14 @@
|
||||
import { config } from './config.js';
|
||||
|
||||
// ─── Cache en memoria ──────────────────────────────────────────────
|
||||
let _cache = null;
|
||||
let _cacheTime = 0;
|
||||
let _cache = {};
|
||||
let _cacheTime = {};
|
||||
|
||||
export function clearCache() {
|
||||
_cache = {};
|
||||
_cacheTime = {};
|
||||
console.log('🧹 Caché en memoria limpiada');
|
||||
}
|
||||
|
||||
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -19,35 +25,67 @@ let _cacheTime = 0;
|
||||
* @param {string} token - API Token
|
||||
* @returns {Promise<{issues: Array, total: number}>}
|
||||
*/
|
||||
async function fetchJiraIssues(email, token) {
|
||||
async function fetchJiraIssues(email, token, period) {
|
||||
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
||||
const maxResultsWanted = 1500; // Límite seguro por usuario
|
||||
let allIssues = [];
|
||||
let totalInJira = 0;
|
||||
let startAt = 0;
|
||||
|
||||
const url = `https://${config.jira.host}/rest/api/3/search/jql`
|
||||
+ `?jql=${encodeURIComponent(config.jql)}`
|
||||
+ `&fields=${encodeURIComponent(buildFieldsList())}`
|
||||
+ `&maxResults=${config.api.maxResults}`;
|
||||
|
||||
console.log(`📡 Consultando JIRA: ${config.jira.project} (${config.api.maxResults} resultados max)`);
|
||||
console.log(`📋 JQL: ${config.jql}`);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`;
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
// Inyectar el filtro de fecha en el JQL base
|
||||
let dynamicJql = config.jql;
|
||||
if (period !== 'all') {
|
||||
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
|
||||
// Intentamos insertarlo antes del ORDER BY
|
||||
if (dynamicJql.includes(' ORDER BY ')) {
|
||||
dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `);
|
||||
} else {
|
||||
dynamicJql += ` AND updated >= ${timeFilter}`;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
|
||||
console.log(`📡 Consultando JIRA: ${config.jira.project} (${maxResultsWanted} resultados max, periodo: ${period})`);
|
||||
console.log(`📋 JQL: ${dynamicJql}`);
|
||||
|
||||
let nextPageToken = null;
|
||||
const fieldsParam = encodeURIComponent(buildFieldsList());
|
||||
const jqlParam = encodeURIComponent(dynamicJql);
|
||||
|
||||
while (allIssues.length < maxResultsWanted) {
|
||||
let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`;
|
||||
if (nextPageToken) {
|
||||
searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const items = data.issues || [];
|
||||
allIssues = allIssues.concat(items);
|
||||
nextPageToken = data.nextPageToken;
|
||||
|
||||
if (!nextPageToken || items.length === 0) {
|
||||
break; // No more pages
|
||||
}
|
||||
}
|
||||
|
||||
// Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado
|
||||
const isTruncated = !!nextPageToken;
|
||||
|
||||
return data;
|
||||
console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`);
|
||||
|
||||
return { issues: allIssues, isTruncated: isTruncated };
|
||||
}
|
||||
|
||||
// ─── Build Fields List ─────────────────────────────────────────────
|
||||
@@ -62,9 +100,9 @@ function buildFieldsList() {
|
||||
config.fields.spEstimate, // Story points estimate
|
||||
config.fields.spFinal, // Story points finales
|
||||
config.fields.developer, // Desarrollador (multi-user)
|
||||
'parent',
|
||||
'resolutiondate',
|
||||
'created',
|
||||
'updated',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
@@ -77,7 +115,10 @@ function buildFieldsList() {
|
||||
function processIssues(issues) {
|
||||
const assigneeData = {};
|
||||
const monthlyDone = {};
|
||||
const epicMap = {};
|
||||
const issuesList = [];
|
||||
const warnings = [];
|
||||
const now = new Date();
|
||||
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
@@ -119,9 +160,6 @@ function processIssues(issues) {
|
||||
? f.created.slice(0, 7)
|
||||
: null;
|
||||
|
||||
// ── Extraer Épica ─────────────────────────────────────────
|
||||
const epicName = f.parent?.fields?.summary ?? 'Sin épica';
|
||||
|
||||
// ── Acumular por Desarrollador ────────────────────────────
|
||||
for (const dev of devs) {
|
||||
if (!assigneeData[dev]) {
|
||||
@@ -148,17 +186,34 @@ function processIssues(issues) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Acumular por Épica ─────────────────────────────────────
|
||||
if (!epicMap[epicName]) {
|
||||
epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 };
|
||||
// ── Generar issuesList para Filtros ───────────────────────
|
||||
issuesList.push({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
summary: f.summary,
|
||||
status: statusKey,
|
||||
sp: sp,
|
||||
devs: devs,
|
||||
creMonth
|
||||
});
|
||||
|
||||
// ── Detectar Warnings ───────────────────────────────────────
|
||||
// 1. Tickets > 14 días in-progress
|
||||
if (isInProgress && f.updated) {
|
||||
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
|
||||
if (days > 14) {
|
||||
warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Done sin SP
|
||||
if (isDone && sp === 0) {
|
||||
warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
epicMap[epicName].done += sp;
|
||||
} else if (isInProgress) {
|
||||
epicMap[epicName].in_progress += sp;
|
||||
} else {
|
||||
epicMap[epicName].todo += sp;
|
||||
// 3. SP sin asignar (todo/in-progress sin dev)
|
||||
if (!isDone && devs.includes('Sin asignar') && sp > 0) {
|
||||
warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp });
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
@@ -168,7 +223,7 @@ function processIssues(issues) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
@@ -176,10 +231,8 @@ function processIssues(issues) {
|
||||
skippedIssues: skippedCount,
|
||||
assigneeData,
|
||||
monthlyDone,
|
||||
epicData: Object.values(epicMap)
|
||||
.sort((a, b) =>
|
||||
(b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)
|
||||
),
|
||||
issuesList,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,43 +250,47 @@ export default async (req, res) => {
|
||||
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600');
|
||||
|
||||
try {
|
||||
// Force refresh con parámetro ?refresh=1
|
||||
// Force refresh y Periodo temporal
|
||||
const forceRefresh = req.query?.refresh === '1';
|
||||
const period = req.query?.period || '90d';
|
||||
|
||||
// Servir desde caché si está fresco
|
||||
if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) {
|
||||
console.log('📦 Sirviendo desde caché (en memoria)');
|
||||
return res.status(200).json({ ..._cache, fromCache: true });
|
||||
// Servir desde caché segmentado por período si está fresco
|
||||
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
|
||||
console.log(`📦 Sirviendo desde caché para periodo: ${period}`);
|
||||
return res.status(200).json({ ..._cache[period], fromCache: true });
|
||||
}
|
||||
|
||||
// ── Consultar JIRA ─────────────────────────────────────────
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token);
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
|
||||
const data = processIssues(jiraData.issues);
|
||||
|
||||
// Agregar metadata
|
||||
data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults)
|
||||
data.isTruncated = jiraData.isTruncated; // Si hay más paginación pendiente del límite
|
||||
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
|
||||
data.period = period;
|
||||
data.config = {
|
||||
project: config.jira.project,
|
||||
host: config.jira.host,
|
||||
maxResults: config.api.maxResults,
|
||||
maxResults: 1500,
|
||||
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
|
||||
};
|
||||
|
||||
// Guardar en caché en memoria
|
||||
_cache = data;
|
||||
_cacheTime = Date.now();
|
||||
// Guardar en caché segmentado
|
||||
_cache[period] = data;
|
||||
_cacheTime[period] = Date.now();
|
||||
|
||||
console.log('✅ Datos frescos desde JIRA almacenados en caché');
|
||||
console.log(`✅ Datos frescos almacenados en caché para periodo: ${period}`);
|
||||
return res.status(200).json({ ...data, fromCache: false });
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ [dashboard/api/data] Error:', err.message);
|
||||
|
||||
// Intentar servir desde caché si hay error
|
||||
if (_cache) {
|
||||
// Intentar servir desde caché global si hay error
|
||||
const period = req.query?.period || '90d';
|
||||
if (_cache[period]) {
|
||||
console.log('⚠️ Sirviendo caché anterior debido a error');
|
||||
return res.status(200).json({
|
||||
..._cache,
|
||||
..._cache[period],
|
||||
fromCache: true,
|
||||
error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user