Compare commits
11 Commits
7042054333
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e87d25826 | |||
| 695401f237 | |||
| 76edc176f9 | |||
| 72050df789 | |||
| d7f7339fde | |||
| 8dfa3bf137 | |||
| c8806a6701 | |||
| a2d49b441e | |||
| 7410b22623 | |||
| 5467427a08 | |||
| b4a0dfae24 |
@@ -1,3 +1,6 @@
|
||||
.vercel
|
||||
.env
|
||||
ex.env
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 📊 Dashboard Infra — Tickets
|
||||
|
||||
Dashboard en tiempo real para visualizar la carga de trabajo y métricas del equipo de Infraestructura, usando datos extraídos directamente de la API de Jira.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Stack Tecnológico
|
||||
|
||||
- **Frontend:** HTML5 + Vanilla JavaScript + [Chart.js (v4.4.1)](https://www.chartjs.org/)
|
||||
- **Backend:** Python + Flask
|
||||
- **API:** Integración directa con Jira REST API v3
|
||||
- **Autenticación:** Basic Auth mediante Email y API Token de Jira
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalación y Configuración Local
|
||||
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.beldo.duckdns.org/jpbeldomenico/dashboard-infra.git
|
||||
cd dashboard-infra
|
||||
```
|
||||
|
||||
### 2. Instalar dependencias Python
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Configurar Variables de Entorno
|
||||
|
||||
Copiá el archivo de ejemplo y completá con tus credenciales:
|
||||
|
||||
```bash
|
||||
cp ex.env .env
|
||||
```
|
||||
|
||||
Editá el `.env`:
|
||||
|
||||
```env
|
||||
JIRA_HOST=invera.atlassian.net
|
||||
JIRA_EMAIL=tu-email@dominio.com
|
||||
JIRA_TOKEN=tu-jira-api-token
|
||||
JIRA_PROJECT=IO
|
||||
USE_TICKET_COUNT=true
|
||||
```
|
||||
|
||||
> Podés generar un API Token en: https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
|
||||
### 4. Levantar el servidor
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
Abrí el navegador en: `http://localhost:8888`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
dashboard-infra/
|
||||
├── index.html # Frontend: layout y gráficos
|
||||
├── js/
|
||||
│ └── app.js # Lógica del dashboard (Chart.js, KPIs, filtros)
|
||||
├── css/
|
||||
│ └── styles.css # Estilos
|
||||
├── server.py # Backend Flask: fetch de Jira y endpoint /api/data
|
||||
├── requirements.txt # Dependencias Python
|
||||
├── ex.env # Ejemplo de variables de entorno
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Métricas disponibles
|
||||
|
||||
- **Carga de trabajo actual:** tickets En Progreso y Por Hacer por desarrollador
|
||||
- **Alertas de salud:** tickets estancados, vencidos, sin asignar
|
||||
- **Resultados del período:** tickets completados, promedio semanal, solicitudes por semana
|
||||
- Filtro por desarrollador y por rango de tiempo (hoy, 7d, 30d, 90d, 1 año, histórico)
|
||||
@@ -73,6 +73,15 @@ export const config = {
|
||||
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||
// ── QA Fields ────────────────────────────────────────────────────
|
||||
qaAssignee: 'customfield_10632',
|
||||
qaEntryRtqa: 'customfield_10821',
|
||||
qaTotalTimeRtqa: 'customfield_10822',
|
||||
qaLastDurRtqa: 'customfield_10823',
|
||||
qaEntryQa: 'customfield_10824',
|
||||
qaTotalTimeQa: 'customfield_10825',
|
||||
qaLastDurQa: 'customfield_10826',
|
||||
timesReturned: 'customfield_11217',
|
||||
},
|
||||
|
||||
// ── JQL Query ──────────────────────────────────────────────────────
|
||||
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* api/data.js — Vercel Serverless Function (Refactorizado)
|
||||
*
|
||||
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
|
||||
*
|
||||
* Usa: api/config.js para centralizar variables de entorno
|
||||
*/
|
||||
|
||||
import { config } from './config.js';
|
||||
|
||||
// ─── Cache en memoria ──────────────────────────────────────────────
|
||||
let _cache = null;
|
||||
let _cacheTime = 0;
|
||||
|
||||
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
||||
/**
|
||||
* Consulta JIRA API con Basic Auth
|
||||
* @param {string} email - Email JIRA
|
||||
* @param {string} token - API Token
|
||||
* @returns {Promise<{issues: Array, total: number}>}
|
||||
*/
|
||||
async function fetchJiraIssues(email, token) {
|
||||
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Build Fields List ─────────────────────────────────────────────
|
||||
/**
|
||||
* Construye lista de campos a traer de JIRA
|
||||
*/
|
||||
function buildFieldsList() {
|
||||
return [
|
||||
'summary',
|
||||
'status',
|
||||
'assignee',
|
||||
config.fields.spEstimate, // Story points estimate
|
||||
config.fields.spFinal, // Story points finales
|
||||
config.fields.developer, // Desarrollador (multi-user)
|
||||
'parent',
|
||||
'resolutiondate',
|
||||
'created',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
// ─── Process Issues ────────────────────────────────────────────────
|
||||
/**
|
||||
* Procesa issues de JIRA y agrupa los datos
|
||||
* @param {Array} issues - Array de issues de JIRA
|
||||
* @returns {Object} Datos procesados
|
||||
*/
|
||||
function processIssues(issues) {
|
||||
const assigneeData = {};
|
||||
const monthlyDone = {};
|
||||
const epicMap = {};
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
const f = issue.fields;
|
||||
|
||||
// ── Extraer desarrolladores ────────────────────────────────
|
||||
// Prioridad: customfield_10622 (multi) → assignee (single)
|
||||
let devs = [];
|
||||
|
||||
if (f[config.fields.developer]?.length) {
|
||||
// Multi-usuario
|
||||
devs = f[config.fields.developer].map(d => d.displayName);
|
||||
} else if (f.assignee) {
|
||||
// Single usuario
|
||||
devs = [f.assignee.displayName];
|
||||
} else {
|
||||
// Sin asignar
|
||||
devs = ['Sin asignar'];
|
||||
}
|
||||
|
||||
// ── Extraer Story Points ───────────────────────────────────
|
||||
// Prioridad: SP Finales → SP Estimate
|
||||
const sp = (f[config.fields.spFinal] != null
|
||||
? f[config.fields.spFinal]
|
||||
: f[config.fields.spEstimate]) ?? 0;
|
||||
|
||||
// ── Extraer Status ─────────────────────────────────────────
|
||||
const statusKey = f.status?.statusCategory?.key ?? 'new';
|
||||
const isDone = statusKey === 'done';
|
||||
const isInProgress = statusKey === 'indeterminate';
|
||||
|
||||
// ── Extraer Fechas (YYYY-MM) ──────────────────────────────
|
||||
const resMonth = f.resolutiondate
|
||||
? f.resolutiondate.slice(0, 7)
|
||||
: null;
|
||||
const creMonth = f.created
|
||||
? 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]) {
|
||||
assigneeData[dev] = { done: 0, in_progress: 0, todo: 0 };
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
assigneeData[dev].done += sp;
|
||||
} else if (isInProgress) {
|
||||
assigneeData[dev].in_progress += sp;
|
||||
} else {
|
||||
assigneeData[dev].todo += sp;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Acumular completados por mes ───────────────────────────
|
||||
if (isDone) {
|
||||
const month = resMonth || creMonth;
|
||||
if (month) {
|
||||
if (!monthlyDone[month]) monthlyDone[month] = {};
|
||||
for (const dev of devs) {
|
||||
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Acumular por Épica ─────────────────────────────────────
|
||||
if (!epicMap[epicName]) {
|
||||
epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 };
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
epicMap[epicName].done += sp;
|
||||
} else if (isInProgress) {
|
||||
epicMap[epicName].in_progress += sp;
|
||||
} else {
|
||||
epicMap[epicName].todo += sp;
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
totalIssues: processedCount,
|
||||
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)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main Handler ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Vercel Serverless Function Handler
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
// ── CORS ───────────────────────────────────────────────────────
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
||||
|
||||
// ── Caching ────────────────────────────────────────────────────
|
||||
// CDN cache: 24hs fresco, sirve stale mientras recarga
|
||||
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600');
|
||||
|
||||
try {
|
||||
// Force refresh con parámetro ?refresh=1
|
||||
const forceRefresh = req.query?.refresh === '1';
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// ── Consultar JIRA ─────────────────────────────────────────
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token);
|
||||
const data = processIssues(jiraData.issues);
|
||||
|
||||
// Agregar metadata
|
||||
data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults)
|
||||
data.config = {
|
||||
project: config.jira.project,
|
||||
host: config.jira.host,
|
||||
maxResults: config.api.maxResults,
|
||||
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
|
||||
};
|
||||
|
||||
// Guardar en caché en memoria
|
||||
_cache = data;
|
||||
_cacheTime = Date.now();
|
||||
|
||||
console.log('✅ Datos frescos desde JIRA almacenados en caché');
|
||||
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) {
|
||||
console.log('⚠️ Sirviendo caché anterior debido a error');
|
||||
return res.status(200).json({
|
||||
..._cache,
|
||||
fromCache: true,
|
||||
error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Si no hay caché, retornar error
|
||||
return res.status(500).json({
|
||||
error: err.message,
|
||||
hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clearCache } from './data.js';
|
||||
|
||||
export default async (req, res) => {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST');
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Método no permitido. Usa POST.' });
|
||||
}
|
||||
|
||||
// ── Validación de Secret ──────────────────────────────────────────────
|
||||
const expectedSecret = process.env.WEBHOOK_SECRET;
|
||||
const providedSecret = req.query.secret;
|
||||
|
||||
// Si hay un secret configurado en .env, exigimos que coincida
|
||||
if (expectedSecret && providedSecret !== expectedSecret) {
|
||||
console.warn('⚠️ Intento de Webhook con secret inválido.');
|
||||
return res.status(401).json({ error: 'No autorizado / Secret inválido.' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔔 Webhook recibido desde Jira!');
|
||||
|
||||
// Invocamos la limpieza de caché del módulo data.js
|
||||
clearCache();
|
||||
|
||||
return res.status(200).json({ success: true, message: 'Caché invalidada exitosamente.' });
|
||||
} catch (error) {
|
||||
console.error('❌ Error manejando webhook:', error);
|
||||
return res.status(500).json({ error: 'Error interno invalidando caché.' });
|
||||
}
|
||||
};
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
|
||||
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
header h1 { font-size: 22px; font-weight: 700; }
|
||||
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.header-meta span { font-size: 12px; opacity: 0.75; }
|
||||
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
|
||||
.refresh-btn:hover { background: rgba(255,255,255,.25); }
|
||||
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.cache-badge { font-size: 11px; opacity: .7; }
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 1000; gap: 16px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-overlay p { color: #5e6c84; font-size: 14px; }
|
||||
|
||||
/* Error e Info */
|
||||
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
|
||||
|
||||
/* Headers de sección */
|
||||
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
|
||||
.section-header.current { border-color: #0065ff; }
|
||||
.section-header.history { border-color: #36b37e; }
|
||||
.section-header.health { border-color: #ff5630; }
|
||||
.section-header h2 { font-size: 14px; font-weight: 700; }
|
||||
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.section-header.current .dot { background: #0065ff; }
|
||||
.section-header.history .dot { background: #36b37e; }
|
||||
.section-header.health .dot { background: #ff5630; }
|
||||
|
||||
/* Inputs y selects */
|
||||
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
|
||||
.filter-group { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.filter-group label { margin-left: 0; }
|
||||
select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
|
||||
select:focus { border-color: #0052cc; }
|
||||
|
||||
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
|
||||
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
|
||||
.section-block { margin-bottom: 36px; }
|
||||
|
||||
/* KPIs */
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); transition: transform .2s; }
|
||||
.kpi:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,.1); }
|
||||
.kpi.done { border-color: #36b37e; }
|
||||
.kpi.progress{ border-color: #0065ff; }
|
||||
.kpi.todo { border-color: #ff8b00; }
|
||||
.kpi.members { border-color: #6554c0; }
|
||||
.kpi.warning { border-color: #ff5630; }
|
||||
.kpi.qa { border-color: #6554c0; }
|
||||
.kpi.qa-time { border-color: #00b8d9; }
|
||||
.kpi.qa-queue { border-color: #36b37e; }
|
||||
.kpi.returned { border-color: #ff5630; }
|
||||
|
||||
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
|
||||
.kpi.done .kpi-value { color: #36b37e; }
|
||||
.kpi.progress .kpi-value { color: #0065ff; }
|
||||
.kpi.todo .kpi-value { color: #ff8b00; }
|
||||
.kpi.members .kpi-value { color: #6554c0; }
|
||||
.kpi.warning .kpi-value { color: #ff5630; }
|
||||
.kpi.qa .kpi-value { color: #6554c0; }
|
||||
.kpi.qa-time .kpi-value { color: #00b8d9; }
|
||||
.kpi.qa-queue .kpi-value { color: #36b37e; }
|
||||
.kpi.returned .kpi-value { color: #ff5630; }
|
||||
|
||||
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
|
||||
|
||||
/* Section header QA */
|
||||
.section-header.qa { border-color: #6554c0; }
|
||||
.section-header.qa .dot { background: #6554c0; }
|
||||
|
||||
/* Pill y row QA */
|
||||
.pill.qa { background: #f3f0ff; color: #403294; }
|
||||
.pill.returned { background: #ffebe6; color: #bf2600; }
|
||||
.row-critical td { background: #fff3f0; }
|
||||
|
||||
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.chart-card.full { grid-column: 1 / -1; }
|
||||
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
|
||||
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
|
||||
|
||||
/* Tables */
|
||||
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
|
||||
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8f9fa; }
|
||||
|
||||
/* Píldoras y avatares */
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.pill.done { background: #e3fcef; color: #006644; }
|
||||
.pill.progress { background: #deebff; color: #0052cc; }
|
||||
.pill.todo { background: #fffae6; color: #7a4b00; }
|
||||
.pill.warning { background: #ffebe6; color: #bf2600; }
|
||||
|
||||
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
|
||||
.pbar-done { background: #36b37e; height: 100%; }
|
||||
.pbar-ip { background: #0065ff; height: 100%; }
|
||||
.pbar-todo { background: #ff8b00; height: 100%; }
|
||||
|
||||
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
|
||||
.name-cell { display: flex; align-items: center; }
|
||||
|
||||
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
|
||||
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
|
||||
|
||||
/* Health Warnings Panel */
|
||||
.warning-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.warning-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: #fffcfcf5; border-left: 4px solid #ff5630; border-radius: 4px; }
|
||||
.warning-item .badge { background: #ffebe6; color: #bf2600; }
|
||||
.warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;}
|
||||
.warning-item .desc { font-size: 12px; color: #5e6c84; }
|
||||
|
||||
/* Popover Tooltips */
|
||||
.pop-trigger { position: relative; display: inline-block; cursor: help; }
|
||||
.pop-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 120%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(23,43,77,.25);
|
||||
border: 1px solid #dfe1e6;
|
||||
padding: 12px;
|
||||
width: 280px;
|
||||
z-index: 100;
|
||||
}
|
||||
.pop-trigger:hover .pop-content { display: block; animation: popIn .2s ease-out; }
|
||||
@keyframes popIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.95); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.pop-item { font-size: 11px; padding: 6px 0; border-bottom: 1px solid #f4f5f7; line-height: 1.4; color: #5e6c84; }
|
||||
.pop-item:last-child { border-bottom: none; }
|
||||
.pop-item a { color: #0052cc; text-decoration: none; font-weight: 600; margin-right: 4px; }
|
||||
.pop-item a:hover { text-decoration: underline; }
|
||||
|
||||
/* Jira Links */
|
||||
.jira-link { color: #0052cc; text-decoration: none; font-weight: bold; border-bottom: 1px dashed rgba(0,82,204,.3); transition: color .2s; }
|
||||
.jira-link:hover { color: #0747a6; border-bottom-style: solid; text-decoration: underline; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
.chart-card.full { grid-column: 1; }
|
||||
.main { padding: 16px; }
|
||||
.section-header { gap: 8px; flex-direction: column; align-items: flex-start;}
|
||||
.filter-group { margin-left: 0; }
|
||||
.table-card { overflow-x: auto; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
debug_fields.py — Lista todas las personas asignadas en el proyecto
|
||||
"""
|
||||
|
||||
import requests
|
||||
import base64
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
email = os.getenv('JIRA_EMAIL')
|
||||
token = os.getenv('JIRA_TOKEN')
|
||||
host = os.getenv('JIRA_HOST', 'invera.atlassian.net')
|
||||
project = os.getenv('JIRA_PROJECT', 'Infra')
|
||||
|
||||
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||
headers = { 'Authorization': f'Basic {credentials}', 'Accept': 'application/json' }
|
||||
|
||||
print(f"🔍 Conectando a {host} — Proyecto: {project}\n")
|
||||
|
||||
jql = f'project = {project} AND issuetype not in (Epic, "Sub-task", Subtask) AND created >= "2026-01-01" ORDER BY updated DESC'
|
||||
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=500&fields=summary,status,assignee"
|
||||
res = requests.get(url, headers=headers)
|
||||
|
||||
if not res.ok:
|
||||
print(f"❌ Error {res.status_code}: {res.text[:300]}")
|
||||
exit(1)
|
||||
|
||||
issues = res.json().get('issues', [])
|
||||
print(f" → {len(issues)} tickets obtenidos\n")
|
||||
|
||||
assignees = {}
|
||||
for issue in issues:
|
||||
f = issue['fields']
|
||||
name = (f.get('assignee') or {}).get('displayName', 'Sin asignar')
|
||||
status_cat = (f.get('status') or {}).get('statusCategory', {}).get('key', '')
|
||||
if name not in assignees:
|
||||
assignees[name] = {'done': 0, 'pending': 0}
|
||||
if status_cat == 'done':
|
||||
assignees[name]['done'] += 1
|
||||
else:
|
||||
assignees[name]['pending'] += 1
|
||||
|
||||
print(f"{'─'*60}")
|
||||
print(f"👥 TODAS LAS PERSONAS — {project} (desde 2026-01-01):")
|
||||
print(f"{'─'*60}")
|
||||
for name, counts in sorted(assignees.items()):
|
||||
print(f" {name:35s} → {counts['done']:2} finalizados | {counts['pending']:2} pendientes")
|
||||
print(f"{'─'*60}")
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"key": "IO-167",
|
||||
"summary": "Pipeline - Configurar Bitbucket para Producción",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-166",
|
||||
"summary": "ArgoCD - Actualizar ApplicationSet",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-165",
|
||||
"summary": "Kubernetes & GitOps - Configurar prd-values.yaml",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-161",
|
||||
"summary": "Documentar infraestructura y proceso de deployment",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-160",
|
||||
"summary": "Ejecutar migraciones y deployment inicial",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-159",
|
||||
"summary": "Configurar Ingress y DNS para dominio",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-158",
|
||||
"summary": "Crear namespace y configurar RBAC en EKS",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-157",
|
||||
"summary": "Configurar usuario readonly en RDS InvWallet",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-156",
|
||||
"summary": " Crear ArgoCD Application para Dashboard",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
},
|
||||
{
|
||||
"key": "IO-155",
|
||||
"summary": "Configurar Bitbucket Pipeline en repositorio de la app",
|
||||
"assignee": "Josefina Vera Ocampo",
|
||||
"reporter": "Nelson Argañaraz"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "5",
|
||||
"remote": {
|
||||
"https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb",
|
||||
"https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025",
|
||||
"https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f",
|
||||
"https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05",
|
||||
"https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39",
|
||||
"https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f"
|
||||
}
|
||||
}
|
||||
+91
-388
@@ -3,454 +3,157 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard LABS — Story Points</title>
|
||||
<title>Dashboard Infra — Tickets</title>
|
||||
<link rel="stylesheet" href="./css/styles.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
|
||||
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
header h1 { font-size: 22px; font-weight: 700; }
|
||||
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.header-meta span { font-size: 12px; opacity: 0.75; }
|
||||
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
|
||||
.refresh-btn:hover { background: rgba(255,255,255,.25); }
|
||||
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.cache-badge { font-size: 11px; opacity: .7; }
|
||||
/* Loading */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; gap: 16px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-overlay p { color: #5e6c84; font-size: 14px; }
|
||||
/* Error */
|
||||
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
|
||||
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
|
||||
.section-header.current { border-color: #0065ff; }
|
||||
.section-header.history { border-color: #36b37e; }
|
||||
.section-header h2 { font-size: 14px; font-weight: 700; }
|
||||
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.section-header.current .dot { background: #0065ff; }
|
||||
.section-header.history .dot { background: #36b37e; }
|
||||
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
|
||||
.section-header select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
|
||||
.section-header select:focus { border-color: #0052cc; }
|
||||
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
|
||||
.section-block { margin-bottom: 36px; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.kpi.done { border-color: #36b37e; }
|
||||
.kpi.progress{ border-color: #0065ff; }
|
||||
.kpi.todo { border-color: #ff8b00; }
|
||||
.kpi.members { border-color: #6554c0; }
|
||||
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
|
||||
.kpi.done .kpi-value { color: #36b37e; }
|
||||
.kpi.progress .kpi-value { color: #0065ff; }
|
||||
.kpi.todo .kpi-value { color: #ff8b00; }
|
||||
.kpi.members .kpi-value { color: #6554c0; }
|
||||
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
|
||||
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.chart-card.full { grid-column: 1 / -1; }
|
||||
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
|
||||
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
|
||||
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
|
||||
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8f9fa; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.pill.done { background: #e3fcef; color: #006644; }
|
||||
.pill.progress { background: #deebff; color: #0052cc; }
|
||||
.pill.todo { background: #fffae6; color: #7a4b00; }
|
||||
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
|
||||
.pbar-done { background: #36b37e; height: 100%; }
|
||||
.pbar-ip { background: #0065ff; height: 100%; }
|
||||
.pbar-todo { background: #ff8b00; height: 100%; }
|
||||
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
|
||||
.name-cell { display: flex; align-items: center; }
|
||||
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
|
||||
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
.chart-card.full { grid-column: 1; }
|
||||
.main { padding: 16px; }
|
||||
.section-header { gap: 8px; }
|
||||
.section-header label { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="./js/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Consultando Jira en tiempo real…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div class="error-banner" id="errorBanner"></div>
|
||||
<div class="error-banner" id="limitBanner" style="background:#fff7d6; color:#a15900; border-color:#ffab00;"></div>
|
||||
|
||||
<!-- Header Principal -->
|
||||
<header>
|
||||
<div>
|
||||
<h1>📊 Dashboard LABS — Story Points</h1>
|
||||
<h1>📊 Dashboard Infra</h1>
|
||||
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
||||
Proyecto: <strong>LABS</strong> · invera.atlassian.net · Campo: Desarrollador
|
||||
Proyecto: <strong>Infra</strong> · invera.atlassian.net
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span id="lastUpdatedLabel">—</span>
|
||||
<span class="cache-badge" id="cacheBadge"></span>
|
||||
<button class="refresh-btn" id="refreshBtn" onclick="loadData(true)">↻ Actualizar</button>
|
||||
<label style="font-size:12px;opacity:0.8;color:white;margin-right:-6px;">Rango:</label>
|
||||
<select id="timeRangeFilter" style="padding:4px 8px; font-size:12px; border-radius:4px; max-width:120px;">
|
||||
<option value="1d">Hoy</option>
|
||||
<option value="7d">Últimos 7 días</option>
|
||||
<option value="30d">Último mes</option>
|
||||
<option value="90d" selected>Últimos 3 meses</option>
|
||||
<option value="365d">Último año</option>
|
||||
<option value="all">Histórico completo</option>
|
||||
</select>
|
||||
<button class="refresh-btn" id="refreshBtn">↻ Actualizar</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ── SECCIÓN 1: Estado Actual ── -->
|
||||
|
||||
<!-- ── SECCIÓN 1: Carga de Trabajo (Pendientes) ── -->
|
||||
<div class="section-header current">
|
||||
<div class="dot"></div>
|
||||
<h2>Estado Actual del Equipo</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets activos en el tablero ahora mismo</span>
|
||||
<div class="dot" style="background:#0065ff;"></div>
|
||||
<h2>Carga de Trabajo Actual</h2>
|
||||
<span id="currentTicketsLabel" style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets pendientes en el tablero</span>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Filtrar por Dev:</label>
|
||||
<select id="devFilter"><option value="all">Cargando...</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="section-block">
|
||||
<div class="kpi-row">
|
||||
<div class="kpi progress"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">En curso · Code Review · QA</div></div>
|
||||
<div class="kpi todo"> <div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog asignado</div></div>
|
||||
<div class="kpi done"> <div class="kpi-value" id="kpi-done-total">—</div><div class="kpi-label">SP Completados (total)</div><div class="kpi-sub">Histórico acumulado</div></div>
|
||||
<div class="kpi members"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs activos</div><div class="kpi-sub">Con SP asignados</div></div>
|
||||
<div class="kpi progress" title="Tickets actualmente en la columna En Progreso"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">Tickets En Progreso</div><div class="kpi-sub">Trabajo en curso</div></div>
|
||||
<div class="kpi todo" title="Tickets pendientes que aún no comenzaron"><div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">Tickets Por Hacer</div><div class="kpi-sub">Backlog del periodo</div></div>
|
||||
<div class="kpi members" title="Cantidad de personas con tickets asignados en este momento"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">DevOps</div><div class="kpi-sub">Con tickets pendientes</div></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>🔵 SP en Progreso — por Desarrollador</h3>
|
||||
<div class="sub">Estado actual · En curso / Code Review / QA</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartInProgress"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📦 Estado actual por Desarrollador</h3>
|
||||
<div class="sub">Completados · En Progreso · Por Hacer (acumulado)</div>
|
||||
<h3>🔵 Tkt pendientes</h3>
|
||||
<div class="sub">Story Points con trabajo pendiente</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3 style="display:flex;align-items:center;">📢 Alertas de Salud
|
||||
<div class="pop-trigger" style="margin-left:8px; font-size:14px; cursor:help; border:none;">ℹ️
|
||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left; font-size:12px; width:260px;">
|
||||
<strong>Tipos de Alertas:</strong><br><br>
|
||||
<span class="badge" style="background:#ffab00;color:white;font-size:10px;padding:2px 4px;">ESTANCADO</span> › +14 días en curso.<br><br>
|
||||
<span class="badge" style="background:#ff5630;color:white;font-size:10px;padding:2px 4px;">VENCIDO</span> › Fecha de entrega caducada.<br><br>
|
||||
<span class="badge" style="background:#6554c0;color:white;font-size:10px;padding:2px 4px;">SIN SP</span> › Completado sin estimación.<br><br>
|
||||
<span class="badge" style="background:#ff8b00;color:white;font-size:10px;padding:2px 4px;">HUÉRFANO</span> › Ticket con SP sin asignar.<br><br>
|
||||
<span class="badge" style="background:#36b37e;color:white;font-size:10px;padding:2px 4px;">DEV LIBRE</span> › Dev sin tickets pendientes ni en curso.
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="sub">Tickets estancados o sin estimación</div>
|
||||
<div id="warningsSection" style="display:none; overflow-y:auto; max-height:230px; margin-top:10px;">
|
||||
<ul class="warning-list" id="warningsList" style="padding:0; margin:0;"></ul>
|
||||
</div>
|
||||
<div id="noWarningsMsg" style="text-align:center; color:#36b37e; padding:40px 0; display:none;">
|
||||
✅ No hay alertas pendientes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3>Detalle por Desarrollador — Estado Actual</h3>
|
||||
<h3>Detalle de Carga de Trabajo</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ Completados</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total SP</th><th>Progreso</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total Pendiente</th><th>Distribución</th></tr></thead>
|
||||
<tbody id="tableCurrentState"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── SECCIÓN 2: Completados por período ── -->
|
||||
|
||||
<!-- ── SECCIÓN 2: Rendimiento & Resultados ── -->
|
||||
<div class="section-header history">
|
||||
<div class="dot"></div>
|
||||
<h2>Completados por Período</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Filtrar por mes de resolución</span>
|
||||
<label>Ver mes:</label>
|
||||
<select id="monthFilter" onchange="updateHistory()"></select>
|
||||
<span class="badge" id="filterBadge">Todos los meses</span>
|
||||
<div class="dot" style="background:#36b37e;"></div>
|
||||
<h2>Resultados del Período</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Story Points finalizados y velocidad</span>
|
||||
<span class="badge" id="filterBadge" style="display:none;"></span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="section-block">
|
||||
<div class="kpi-row-3">
|
||||
<div class="kpi done"> <div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub" id="kpi-done-period-sub">en el período</div></div>
|
||||
<div class="kpi members"><div class="kpi-value" id="kpi-devs-period">—</div><div class="kpi-label">Desarrolladores</div><div class="kpi-sub">que completaron SP</div></div>
|
||||
<div class="kpi progress"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">SP promedio / dev</div><div class="kpi-sub">en el período</div></div>
|
||||
<div class="kpi done" title="Total de tickets completados en el período de tiempo seleccionado"><div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">Tickets Completados</div><div class="kpi-sub">en el período</div></div>
|
||||
<div class="kpi progress" title="Promedio de tickets resueltos por semana en el período seleccionado"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">Promedio tkts / semana</div><div class="kpi-sub" id="kpi-avg-period-sub">tickets por semana</div></div>
|
||||
<div class="kpi" style="border-color:#6554c0;" title="Promedio de tickets nuevos solicitados por semana en el período seleccionado"><div class="kpi-value" id="kpi-created-week">—</div><div class="kpi-label">Tkts solicitados / semana</div><div class="kpi-sub" id="kpi-created-week-sub">tickets por semana</div></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>✅ SP Completados — por Desarrollador</h3>
|
||||
<div class="sub" id="subtitle-history-dev">Período seleccionado</div>
|
||||
<h3>✅ Tickets Completados</h3>
|
||||
<div class="sub">Período seleccionado</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📈 Velocidad del Equipo</h3>
|
||||
<div class="sub">SP completados por mes · cada color = un dev</div>
|
||||
<h3>📥 Solicitudes de Tickets</h3>
|
||||
<div class="sub">Tickets creados por persona en el período</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card full">
|
||||
<h3>🗂 SP por Épica — Estado Actual</h3>
|
||||
<div class="sub">Completados · En Progreso · Por Hacer en cada épica</div>
|
||||
<div style="position:relative;height:220px"><canvas id="chartEpic"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3 id="table-history-title">SP Completados — Todos los meses</h3>
|
||||
<h3 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>SP Completados — Tabla Resumen</span>
|
||||
<span id="teamAvgLabel" style="font-size:12px; font-weight:normal; background:#e6fcff; color:#0065ff; padding:4px 8px; border-radius:4px; display:none;"></span>
|
||||
</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del total del período</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ Tickets Total</th><th>📉 % del equipo</th></tr></thead>
|
||||
<tbody id="tableHistory"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- ── SECCIÓN: Revisión de Código (Code Review) ── -->
|
||||
|
||||
|
||||
|
||||
<footer>
|
||||
Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net ·
|
||||
Campo: <strong>Desarrollador</strong> (customfield_10622) + SP Finales (customfield_11019) ·
|
||||
Cache: 5 min · <span id="totalIssuesLabel"></span>
|
||||
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
||||
</footer>
|
||||
<script>
|
||||
// ── Colores ────────────────────────────────────────────────────────────────
|
||||
const COLORS = [
|
||||
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
|
||||
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
|
||||
'#403294','#00875a','#de350b','#505f79','#42526e',
|
||||
];
|
||||
const MONTH_LABELS_MAP = {
|
||||
'2025-01':'Ene 2025','2025-02':'Feb 2025','2025-03':'Mar 2025',
|
||||
'2025-04':'Abr 2025','2025-05':'May 2025','2025-06':'Jun 2025',
|
||||
'2025-07':'Jul 2025','2025-08':'Ago 2025','2025-09':'Sep 2025',
|
||||
'2025-10':'Oct 2025','2025-11':'Nov 2025','2025-12':'Dic 2025',
|
||||
'2026-01':'Ene 2026','2026-02':'Feb 2026','2026-03':'Mar 2026',
|
||||
'2026-04':'Abr 2026','2026-05':'May 2026','2026-06':'Jun 2026',
|
||||
};
|
||||
function monthLabel(m) { return MONTH_LABELS_MAP[m] || m; }
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
let _peopleOrder = [];
|
||||
function personColor(name) {
|
||||
let idx = _peopleOrder.indexOf(name);
|
||||
if (idx < 0) { _peopleOrder.push(name); idx = _peopleOrder.length - 1; }
|
||||
return COLORS[idx % COLORS.length];
|
||||
}
|
||||
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
|
||||
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
|
||||
// ── Chart instances ────────────────────────────────────────────────────────
|
||||
let charts = {};
|
||||
function destroyChart(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } }
|
||||
// ── Global data ────────────────────────────────────────────────────────────
|
||||
let DATA = null;
|
||||
// ── Load data from API ─────────────────────────────────────────────────────
|
||||
async function loadData(forceRefresh = false) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
document.getElementById('errorBanner').style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = true;
|
||||
try {
|
||||
const url = '/api/data' + (forceRefresh ? '?refresh=1' : '');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
DATA = await res.json();
|
||||
_peopleOrder = Object.keys(DATA.assigneeData);
|
||||
buildMonthSelector();
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
// Last updated label
|
||||
const d = new Date(DATA.lastUpdated);
|
||||
document.getElementById('lastUpdatedLabel').textContent =
|
||||
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
document.getElementById('cacheBadge').textContent = DATA.fromCache ? '(desde caché)' : '(CACHE)';
|
||||
if (DATA.jiraTotal > DATA.totalIssues) {
|
||||
document.getElementById('totalIssuesLabel').textContent =
|
||||
`Mostrando ${DATA.totalIssues} de ${DATA.jiraTotal} issues totales con SP`;
|
||||
}
|
||||
} catch(e) {
|
||||
const banner = document.getElementById('errorBanner');
|
||||
banner.style.display = 'block';
|
||||
banner.textContent = '⚠️ Error al cargar datos: ' + e.message;
|
||||
console.error(e);
|
||||
} finally {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
// ── Month selector ─────────────────────────────────────────────────────────
|
||||
function buildMonthSelector() {
|
||||
const sel = document.getElementById('monthFilter');
|
||||
const months = Object.keys(DATA.monthlyDone).sort();
|
||||
sel.innerHTML = '<option value="all">Todos los meses</option>'
|
||||
+ months.map(m => `<option value="${m}">${monthLabel(m)}</option>`).join('');
|
||||
// Default: mostrar el mes más reciente
|
||||
if (months.length > 0) sel.value = months[months.length - 1];
|
||||
}
|
||||
// ── Sección 1: Estado Actual ───────────────────────────────────────────────
|
||||
function renderCurrentSection() {
|
||||
const ad = DATA.assigneeData;
|
||||
const people = Object.keys(ad).filter(p => ad[p].done + ad[p].in_progress + ad[p].todo > 0);
|
||||
// KPIs
|
||||
let ip = 0, todo = 0, done = 0, devs = 0;
|
||||
people.forEach(p => {
|
||||
ip += ad[p].in_progress;
|
||||
todo += ad[p].todo;
|
||||
done += ad[p].done;
|
||||
devs++;
|
||||
});
|
||||
document.getElementById('kpi-ip').textContent = ip;
|
||||
document.getElementById('kpi-todo').textContent = todo;
|
||||
document.getElementById('kpi-done-total').textContent = done;
|
||||
document.getElementById('kpi-members').textContent = devs;
|
||||
// Chart: solo en progreso
|
||||
{
|
||||
destroyChart('inProgress');
|
||||
const rows = people.filter(p => ad[p].in_progress > 0).sort((a,b) => ad[b].in_progress - ad[a].in_progress);
|
||||
charts['inProgress'] = new Chart(document.getElementById('chartInProgress'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(shortName),
|
||||
datasets: [{ label: 'SP en progreso', data: rows.map(p => ad[p].in_progress),
|
||||
backgroundColor: rows.map(personColor), borderRadius: 5 }]
|
||||
},
|
||||
options: { responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ display:false } },
|
||||
scales:{ x:{ grid:{display:false}, ticks:{font:{size:11}} },
|
||||
y:{ beginAtZero:true, ticks:{font:{size:11}}, grid:{color:'#f0f2f5'} } } }
|
||||
});
|
||||
}
|
||||
// Chart: estado full
|
||||
{
|
||||
destroyChart('currentState');
|
||||
const rows = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
|
||||
charts['currentState'] = new Chart(document.getElementById('chartCurrentState'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(shortName),
|
||||
datasets: [
|
||||
{ label:'Completados', data:rows.map(p=>ad[p].done), backgroundColor:'#36b37e', borderRadius:3 },
|
||||
{ label:'En Progreso', data:rows.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:rows.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
||||
]
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
|
||||
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
}
|
||||
// Tabla estado actual
|
||||
const tbody = document.getElementById('tableCurrentState');
|
||||
tbody.innerHTML = '';
|
||||
const sorted = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
|
||||
sorted.forEach(p => {
|
||||
const d = ad[p], total = d.done + d.in_progress + d.todo;
|
||||
const dp = pct(d.done, total), ipp = pct(d.in_progress, total), tp = pct(d.todo, total);
|
||||
const c = personColor(p);
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${d.done} SP</span></td>
|
||||
<td><span class="pill progress">${d.in_progress} SP</span></td>
|
||||
<td><span class="pill todo">${d.todo} SP</span></td>
|
||||
<td><strong>${total}</strong></td>
|
||||
<td><div class="pbar-wrap"><div class="pbar-done" style="width:${dp}%"></div><div class="pbar-ip" style="width:${ipp}%"></div><div class="pbar-todo" style="width:${tp}%"></div></div></td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
// ── Sección 2: Historial ───────────────────────────────────────────────────
|
||||
function renderHistorySection() {
|
||||
buildVelocityChart();
|
||||
buildEpicChart();
|
||||
updateHistory();
|
||||
}
|
||||
function buildVelocityChart() {
|
||||
destroyChart('velocity');
|
||||
const months = Object.keys(DATA.monthlyDone).sort();
|
||||
const allDevs = [...new Set(months.flatMap(m => Object.keys(DATA.monthlyDone[m])))];
|
||||
charts['velocity'] = new Chart(document.getElementById('chartVelocity'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: months.map(monthLabel),
|
||||
datasets: allDevs.map((p, i) => ({
|
||||
label: shortName(p),
|
||||
data: months.map(m => DATA.monthlyDone[m][p] || 0),
|
||||
backgroundColor: COLORS[i % COLORS.length],
|
||||
borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:10}, boxWidth:12 } } },
|
||||
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
}
|
||||
function buildEpicChart() {
|
||||
destroyChart('epic');
|
||||
const top = [...DATA.epicData]
|
||||
.sort((a,b) => (b.done+b.in_progress+b.todo)-(a.done+a.in_progress+a.todo))
|
||||
.slice(0, 15);
|
||||
charts['epic'] = new Chart(document.getElementById('chartEpic'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top.map(e => e.name.length > 42 ? e.name.slice(0,40)+'…' : e.name),
|
||||
datasets: [
|
||||
{ label:'Completados', data:top.map(e=>e.done), backgroundColor:'#36b37e', borderRadius:3 },
|
||||
{ label:'En Progreso', data:top.map(e=>e.in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:top.map(e=>e.todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
||||
]
|
||||
},
|
||||
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
|
||||
scales:{ x:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}},
|
||||
y:{stacked:true,grid:{display:false},ticks:{font:{size:11}}} } }
|
||||
});
|
||||
}
|
||||
function updateHistory() {
|
||||
if (!DATA) return;
|
||||
const month = document.getElementById('monthFilter').value;
|
||||
document.getElementById('filterBadge').textContent =
|
||||
month === 'all' ? 'Todos los meses' : monthLabel(month);
|
||||
// Aggregate
|
||||
let periodData = {};
|
||||
if (month === 'all') {
|
||||
Object.values(DATA.monthlyDone).forEach(md => {
|
||||
Object.entries(md).forEach(([p, sp]) => { periodData[p] = (periodData[p] || 0) + sp; });
|
||||
});
|
||||
} else {
|
||||
periodData = { ...(DATA.monthlyDone[month] || {}) };
|
||||
}
|
||||
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
|
||||
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
const avgSP = devCount > 0 ? Math.round(totalSP / devCount) : 0;
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-devs-period').textContent = devCount;
|
||||
document.getElementById('kpi-avg-period').textContent = avgSP;
|
||||
const lbl = month === 'all' ? 'todos los meses' : monthLabel(month);
|
||||
document.getElementById('kpi-done-period-sub').textContent = `SP completados · ${lbl}`;
|
||||
document.getElementById('subtitle-history-dev').textContent = `SP completados en ${lbl}`;
|
||||
document.getElementById('table-history-title').textContent =
|
||||
`SP Completados — ${month === 'all' ? 'Todos los meses' : monthLabel(month)}`;
|
||||
// Chart done by dev
|
||||
destroyChart('doneDev');
|
||||
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
charts['doneDev'] = new Chart(document.getElementById('chartDoneDev'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: sorted.map(([p]) => shortName(p)),
|
||||
datasets: [{ label:'SP completados', data:sorted.map(([,sp]) => sp),
|
||||
backgroundColor: sorted.map(([p]) => personColor(p)), borderRadius:5 }]
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ display:false } },
|
||||
scales:{ x:{grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
// Tabla historial
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sorted.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay datos para el período seleccionado</td></tr>';
|
||||
return;
|
||||
}
|
||||
sorted.forEach(([p, sp]) => {
|
||||
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
|
||||
const c = personColor(p);
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${sp} SP</span></td>
|
||||
<td><div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="background:#f0f2f5;border-radius:4px;height:8px;width:120px;overflow:hidden;">
|
||||
<div style="background:#36b37e;height:100%;width:${p_}%"></div>
|
||||
</div>
|
||||
<span style="font-size:12px;color:#5e6c84;">${p_}%</span>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
|
||||
// ── Auto-refresh cada 24 horas ────────────────────────────────────────────
|
||||
setInterval(() => loadData(false), 24 * 60 * 60 * 1000);
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
loadData();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* js/api.js
|
||||
* Centraliza la comunicación con el Backend (Netlify Functions)
|
||||
*/
|
||||
|
||||
export const api = {
|
||||
data: null, // Guarda la última respuesta en memoria del cliente
|
||||
|
||||
/**
|
||||
* Obtiene los datos de JIRA desde nuestro backend
|
||||
* @param {boolean} forceRefresh - Forzar bypassing de la caché del backend
|
||||
*/
|
||||
async fetchDashboardData(forceRefresh = false, period = '90d') {
|
||||
// Para Netlify Functions + Redirección en netlify.toml
|
||||
let url = `/api/data?period=${period}`;
|
||||
if (forceRefresh) url += '&refresh=1';
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
this.data = json; // Guardar en memoria global de la app
|
||||
return json;
|
||||
} catch (e) {
|
||||
console.error('Error al cargar datos:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* js/app.js
|
||||
* Controlador principal de la UI. Lee desde api.js y actualiza el DOM + charts.js
|
||||
*/
|
||||
|
||||
import { api } from './api.js';
|
||||
import { chartsManager } from './charts.js';
|
||||
|
||||
// utils
|
||||
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
|
||||
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
|
||||
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
|
||||
|
||||
const state = {
|
||||
selectedDev: 'all',
|
||||
selectedPeriod: '90d'
|
||||
};
|
||||
|
||||
// ── INIT ───────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load from localStorage
|
||||
const savedDev = localStorage.getItem('dash_selectedDev');
|
||||
const savedPeriod = localStorage.getItem('dash_selectedPeriod');
|
||||
if (savedDev) state.selectedDev = savedDev;
|
||||
if (savedPeriod) state.selectedPeriod = savedPeriod;
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => loadAndRender(true));
|
||||
|
||||
document.getElementById('devFilter').addEventListener('change', (e) => {
|
||||
state.selectedDev = e.target.value;
|
||||
localStorage.setItem('dash_selectedDev', state.selectedDev);
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
});
|
||||
|
||||
document.getElementById('timeRangeFilter').addEventListener('change', (e) => {
|
||||
state.selectedPeriod = e.target.value;
|
||||
localStorage.setItem('dash_selectedPeriod', state.selectedPeriod);
|
||||
loadAndRender(false);
|
||||
});
|
||||
|
||||
// Set initial value in UI from state (which might come from localStorage)
|
||||
const periodFilter = document.getElementById('timeRangeFilter');
|
||||
if (periodFilter) periodFilter.value = state.selectedPeriod;
|
||||
|
||||
loadAndRender();
|
||||
});
|
||||
|
||||
async function loadAndRender(forceRefresh = false) {
|
||||
const loading = document.getElementById('loadingOverlay');
|
||||
const errorBanner = document.getElementById('errorBanner');
|
||||
loading.style.display = 'flex';
|
||||
errorBanner.style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const data = await api.fetchDashboardData(forceRefresh, state.selectedPeriod);
|
||||
|
||||
const limitBanner = document.getElementById('limitBanner');
|
||||
if (data.isTruncated) {
|
||||
limitBanner.style.display = 'block';
|
||||
limitBanner.textContent = `⚠️ Advertencia de límite: Solo se procesaron 1500 tickets en este período debido a las políticas de seguridad (Total real encontrado: ${data.jiraTotal}). Reducí el rango de fechas para ver el detalle exacto.`;
|
||||
} else {
|
||||
limitBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
buildFilters(data);
|
||||
renderHeader(data);
|
||||
renderHealthWarnings(data.warnings);
|
||||
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
|
||||
} catch(e) {
|
||||
errorBanner.style.display = 'block';
|
||||
errorBanner.textContent = '⚠️ Error al cargar datos: ' + e.message;
|
||||
} finally {
|
||||
loading.style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── FILTERS & HEADER ───────────────────────────────────────────────────────
|
||||
function buildFilters(data) {
|
||||
const devsSet = new Set();
|
||||
|
||||
data.issuesList.forEach(issue => {
|
||||
issue.devs.forEach(d => devsSet.add(d));
|
||||
});
|
||||
|
||||
const devSel = document.getElementById('devFilter');
|
||||
devSel.innerHTML = '<option value="all">Todos los desarrolladores</option>'
|
||||
+ [...devsSet].sort().map(d => `<option value="${d}">${d}</option>`).join('');
|
||||
devSel.value = state.selectedDev;
|
||||
}
|
||||
|
||||
function renderHeader(data) {
|
||||
const d = new Date(data.lastUpdated);
|
||||
document.getElementById('lastUpdatedLabel').textContent =
|
||||
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
document.getElementById('cacheBadge').textContent = data.fromCache ? '(desde caché)' : '(CACHE)';
|
||||
}
|
||||
|
||||
function getJiraUrl(key) {
|
||||
const host = api.data?.config?.host || 'invera.atlassian.net';
|
||||
return `https://${host}/browse/${key}`;
|
||||
}
|
||||
|
||||
// ── HEALTH WARNINGS ────────────────────────────────────────────────────────
|
||||
function renderHealthWarnings(warnings = []) {
|
||||
const container = document.getElementById('warningsSection');
|
||||
const list = document.getElementById('warningsList');
|
||||
const noMsg = document.getElementById('noWarningsMsg');
|
||||
|
||||
if(!warnings || warnings.length === 0) {
|
||||
container.style.display = 'none';
|
||||
if (noMsg) noMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
if (noMsg) noMsg.style.display = 'none';
|
||||
|
||||
list.innerHTML = warnings.map(w => {
|
||||
let text = '';
|
||||
let badge = w.type;
|
||||
const link = w.key ? `<a href="${getJiraUrl(w.key)}" target="_blank" class="jira-link">${w.key}</a>` : '';
|
||||
|
||||
if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; }
|
||||
if(w.type === 'expired') { badge = 'VENCIDO'; text = `🗓️ [${link}] ${w.summary.slice(0,50)}... (Vencido hace ${w.days} días)`; }
|
||||
if(w.type === 'done_no_sp') { badge = 'SIN SP'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (Completado sin estimación)`; }
|
||||
if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; }
|
||||
if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; }
|
||||
|
||||
return `<li class="warning-item" title="${w.summary}"><span class="badge" style="width:90px;text-align:center;">${badge}</span> <span class="title" style="font-size:11px;">${text}</span></li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── CURRENT SECTION (Interactive filtered) ─────────────────────────────────
|
||||
function renderCurrentSection() {
|
||||
const data = api.data;
|
||||
if (!data) return;
|
||||
|
||||
// 1. Filtrar solo pendientes (En progreso o To Do) + Filtro de Dev
|
||||
let allPending = data.issuesList.filter(i => i.status !== 'done');
|
||||
|
||||
// Para el label "Mostrando X de Y", filtramos por Dev
|
||||
let filtered = allPending;
|
||||
if (state.selectedDev !== 'all') {
|
||||
filtered = allPending.filter(i => i.devs.includes(state.selectedDev));
|
||||
}
|
||||
|
||||
// Agrupar métricas para los KPIs (basado en el filtro de Dev)
|
||||
const ad = {};
|
||||
let totalDevs = new Set();
|
||||
|
||||
filtered.forEach(i => {
|
||||
i.devs.forEach(dev => {
|
||||
if(state.selectedDev !== 'all' && dev !== state.selectedDev) return;
|
||||
if (!ad[dev]) ad[dev] = { in_progress: 0, todo: 0, issues_ip: [], issues_todo: [] };
|
||||
|
||||
if (i.status === 'indeterminate') {
|
||||
ad[dev].in_progress += i.sp;
|
||||
ad[dev].issues_ip.push(i);
|
||||
} else {
|
||||
ad[dev].todo += i.sp;
|
||||
ad[dev].issues_todo.push(i);
|
||||
}
|
||||
totalDevs.add(dev);
|
||||
});
|
||||
});
|
||||
|
||||
let ip = 0, todo = 0;
|
||||
Object.values(ad).forEach(d => { ip += d.in_progress; todo += d.todo; });
|
||||
|
||||
document.getElementById('kpi-ip').textContent = ip;
|
||||
document.getElementById('kpi-todo').textContent = todo;
|
||||
document.getElementById('kpi-members').textContent = totalDevs.size;
|
||||
|
||||
// Conteo de tickets
|
||||
const label = document.getElementById('currentTicketsLabel');
|
||||
if (label) {
|
||||
label.textContent = state.selectedDev === 'all'
|
||||
? `Hay ${allPending.length} tickets pendientes en total`
|
||||
: `Mostrando ${filtered.length} tickets pendientes para ${state.selectedDev}`;
|
||||
}
|
||||
|
||||
// Gráfico: Comparativa IP vs Todo (Stacked)
|
||||
// "Sin asignar" siempre va último y con color gris
|
||||
const people = Object.keys(ad).sort((a, b) => {
|
||||
if (a === 'Sin asignar') return 1;
|
||||
if (b === 'Sin asignar') return -1;
|
||||
return (ad[b].in_progress + ad[b].todo) - (ad[a].in_progress + ad[a].todo);
|
||||
});
|
||||
const colorIP = people.map(p => p === 'Sin asignar' ? '#97a0af' : '#0065ff');
|
||||
const colorTodo = people.map(p => p === 'Sin asignar' ? '#c1c7d0' : '#ff8b00');
|
||||
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
||||
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:colorIP, borderRadius:3 },
|
||||
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:colorTodo, borderRadius:3 },
|
||||
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
||||
|
||||
// Tabla detallada (Solo pendientes)
|
||||
const tbody = document.getElementById('tableCurrentState');
|
||||
tbody.innerHTML = '';
|
||||
people.forEach(p => {
|
||||
const d = ad[p], total = d.in_progress + d.todo;
|
||||
const c = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p);
|
||||
|
||||
const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||
const hasTodoAlert = d.issues_todo.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||
|
||||
const genPopover = (issues) => issues.map(i => {
|
||||
let badge = '';
|
||||
if(i.warnings && i.warnings.some(w => w.type === 'expired')) badge = '<span style="color:#ff5630;font-size:11px;font-weight:bold;margin-left:4px;">(Vencido)</span>';
|
||||
else if(i.warnings && i.warnings.some(w => w.type === 'stalled')) badge = '<span style="color:#ffab00;font-size:11px;font-weight:bold;margin-left:4px;">(Estancado)</span>';
|
||||
return `<div class="pop-item" style="display:flex;align-items:center;white-space:nowrap;"><a href="${getJiraUrl(i.key)}" target="_blank" style="margin-right:6px;">${i.key}</a> <span style="overflow:hidden;text-overflow:ellipsis;max-width:180px;" title="${i.summary.replace(/"/g, '"')}">${i.summary}</span> <strong style="margin-left:auto;padding-left:8px;">(${i.sp} SP)</strong>${badge}</div>`;
|
||||
}).join('');
|
||||
|
||||
const popoverIP = genPopover(d.issues_ip);
|
||||
const popoverTodo = genPopover(d.issues_todo);
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td>
|
||||
<div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="pop-trigger">
|
||||
<span class="pill progress" ${hasIPAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.in_progress} tkts${hasIPAlert ? ' ⚠️' : ''}</span>
|
||||
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="pop-trigger">
|
||||
<span class="pill todo" ${hasTodoAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.todo} tkts${hasTodoAlert ? ' ⚠️' : ''}</span>
|
||||
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>${total} tkts</strong></td>
|
||||
<td><div class="pbar-wrap">
|
||||
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
|
||||
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── HISTORY SECTION ────────────────────────────────────────────────────────
|
||||
function renderHistorySection() {
|
||||
const data = api.data;
|
||||
if(!data) return;
|
||||
|
||||
// Consolidar todos los meses del rango actual
|
||||
let periodData = {};
|
||||
let periodTicketCount = {};
|
||||
|
||||
Object.keys(data.assigneeData || {}).forEach(p => {
|
||||
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
|
||||
periodData[p] = 0;
|
||||
periodTicketCount[p] = 0;
|
||||
});
|
||||
|
||||
data.issuesList.filter(i => i.status === 'done').forEach(i => {
|
||||
i.devs.forEach(p => {
|
||||
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
|
||||
periodData[p] = (periodData[p] || 0) + i.sp;
|
||||
periodTicketCount[p] = (periodTicketCount[p] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
|
||||
const devsWithActiveTickets = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
// Promedio global del equipo usando total de devs (incluyendo los de 0) para medir impacto real
|
||||
const allDevsCount = Object.keys(periodData).length;
|
||||
const teamAvgSP = allDevsCount > 0 ? (totalSP / allDevsCount) : 0;
|
||||
|
||||
const labelEl = document.getElementById('teamAvgLabel');
|
||||
if (labelEl && teamAvgSP > 0 && state.selectedDev === 'all') {
|
||||
labelEl.textContent = `🎯 Media Equipo: ${teamAvgSP.toFixed(1)} SP por Dev`;
|
||||
labelEl.style.display = 'inline-block';
|
||||
} else if (labelEl) {
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Calcular semanas del período seleccionado
|
||||
const periodDays = state.selectedPeriod === 'all' ? 365
|
||||
: state.selectedPeriod === '1d' ? 1
|
||||
: parseInt(state.selectedPeriod.replace('d', ''));
|
||||
const periodWeeks = Math.max(periodDays / 7, 1);
|
||||
const totalTickets = Object.values(periodTicketCount).reduce((a,b) => a+b, 0);
|
||||
const avgPerWeek = (totalTickets / periodWeeks).toFixed(1);
|
||||
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-avg-period').textContent = avgPerWeek;
|
||||
|
||||
// Tickets solicitados (creados) por semana
|
||||
const ticketsCreated = data.ticketsCreated || 0;
|
||||
const avgCreatedPerWeek = (ticketsCreated / periodWeeks).toFixed(1);
|
||||
document.getElementById('kpi-created-week').textContent = avgCreatedPerWeek;
|
||||
const createdSubEl = document.getElementById('kpi-created-week-sub');
|
||||
if (createdSubEl) createdSubEl.textContent = `${ticketsCreated} tkts en el período`;
|
||||
|
||||
// Gráfico de impacto personal (Terminados) (solo mayores a 0)
|
||||
const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
chartsManager.drawBarChart('doneDev', 'chartDoneDev', sortedGraph.map(([p]) => shortName(p)), [{
|
||||
label: 'SP entregados', data: sortedGraph.map(([,sp]) => sp),
|
||||
backgroundColor: sortedGraph.map(([p]) => chartsManager.personColor(p)), borderRadius: 5
|
||||
}], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } });
|
||||
|
||||
// Tabla de Resultados
|
||||
const sortedAll = Object.entries(periodData).sort((a,b) => b[1]-a[1]);
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sortedAll.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="no-data">No hay entregas en este período</td></tr>';
|
||||
} else {
|
||||
sortedAll.forEach(([p, sp]) => {
|
||||
const p_vsTeam = teamAvgSP > 0 ? (sp / teamAvgSP * 100).toFixed(0) : 0;
|
||||
const c = chartsManager.personColor(p);
|
||||
const tickets = periodTicketCount[p] || 0;
|
||||
const avgPerTicket = tickets > 0 ? (sp / tickets).toFixed(1) : 0;
|
||||
|
||||
let badgeHtml = '';
|
||||
if (sp === 0) {
|
||||
badgeHtml = `<span class="badge" style="background:#ff5630;color:white;font-size:10px;">0 SP (SIN ENTREGAS)</span>`;
|
||||
} else if (p_vsTeam >= 150) {
|
||||
badgeHtml = `<span style="font-size:12px;color:#36b37e;font-weight:bold;">🔥 +${p_vsTeam - 100}% sup. prom.</span>`;
|
||||
} else if (p_vsTeam <= 50) {
|
||||
badgeHtml = `<span style="font-size:12px;color:#ff8b00;font-weight:bold;">📉 -${100 - p_vsTeam}% del prom.</span>`;
|
||||
} else {
|
||||
badgeHtml = `<span style="font-size:12px;color:#5e6c84;">~ ${p_vsTeam}% del prom.</span>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${tickets} tkts</span></td>
|
||||
<td>${badgeHtml}</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Gráfico de Solicitudes por persona (reporter)
|
||||
const reporterData = data.reporterData || {};
|
||||
const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]);
|
||||
chartsManager.drawBarChart('velocity', 'chartVelocity',
|
||||
sortedReporters.map(([p]) => shortName(p)),
|
||||
[{
|
||||
label: 'Tickets solicitados',
|
||||
data: sortedReporters.map(([, v]) => v),
|
||||
backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)),
|
||||
borderRadius: 5
|
||||
}],
|
||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* js/charts.js
|
||||
* Encargado de instanciar, dibujar y destruir gráficos de Chart.js
|
||||
*/
|
||||
|
||||
const COLORS = [
|
||||
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
|
||||
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
|
||||
'#403294','#00875a','#de350b','#505f79','#42526e',
|
||||
];
|
||||
|
||||
export const chartsManager = {
|
||||
instances: {},
|
||||
_peopleOrder: [],
|
||||
|
||||
personColor(name) {
|
||||
let idx = this._peopleOrder.indexOf(name);
|
||||
if (idx < 0) {
|
||||
this._peopleOrder.push(name);
|
||||
idx = this._peopleOrder.length - 1;
|
||||
}
|
||||
return COLORS[idx % COLORS.length];
|
||||
},
|
||||
|
||||
destroyChart(id) {
|
||||
if (this.instances[id]) {
|
||||
this.instances[id].destroy();
|
||||
delete this.instances[id];
|
||||
}
|
||||
},
|
||||
|
||||
drawBarChart(id, canvasId, labels, datasets, options = {}) {
|
||||
this.destroyChart(id);
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
this.instances[id] = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
drawDoughnutChart(id, canvasId, labels, data, backgroundColors, options = {}) {
|
||||
this.destroyChart(id);
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
this.instances[id] = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 2, borderColor: '#fff' }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } },
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
drawLineChart(id, canvasId, labels, datasets, options = {}) {
|
||||
this.destroyChart(id);
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
this.instances[id] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
tension: 0.4,
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
# Configuración de Netlify para Dashboard JIRA
|
||||
|
||||
[build]
|
||||
# Comando para compilar el proyecto (si usas un build tool)
|
||||
# command = "npm run build"
|
||||
# publish = "dist"
|
||||
|
||||
# Si NO tienes build tool, comenta lo anterior
|
||||
|
||||
# Redireccionar /api/* a las funciones Netlify
|
||||
[[redirects]]
|
||||
from = "/api/*"
|
||||
to = "/.netlify/functions/:splat"
|
||||
status = 200
|
||||
|
||||
# Redireccionar todas las demás rutas a index.html (SPA)
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
# Configuración de funciones Netlify
|
||||
[functions]
|
||||
# Ruta donde están las funciones
|
||||
directory = "netlify/functions"
|
||||
# Bundler para Node.js
|
||||
node_bundler = "esbuild"
|
||||
|
||||
# Headers para CORS y cache
|
||||
[[headers]]
|
||||
for = "/.netlify/functions/*"
|
||||
[headers.values]
|
||||
Access-Control-Allow-Origin = "*"
|
||||
Access-Control-Allow-Methods = "GET, POST, OPTIONS"
|
||||
Access-Control-Allow-Headers = "Content-Type"
|
||||
Cache-Control = "s-maxage=3600, stale-while-revalidate=86400"
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* netlify/functions/data.js — Netlify Function (Convertido de Vercel)
|
||||
*
|
||||
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
|
||||
*/
|
||||
|
||||
// Configuración desde variables de entorno
|
||||
const config = {
|
||||
jira: {
|
||||
host: process.env.JIRA_HOST || 'invera.atlassian.net',
|
||||
project: process.env.JIRA_PROJECT || 'Infra',
|
||||
email: process.env.JIRA_EMAIL,
|
||||
token: process.env.JIRA_TOKEN,
|
||||
},
|
||||
fields: {
|
||||
spEstimate: process.env.JIRA_FIELD_SP_ESTIMATE || 'customfield_10016',
|
||||
spFinal: process.env.JIRA_FIELD_SP_FINAL || 'customfield_11019',
|
||||
developer: process.env.JIRA_FIELD_DEVELOPER || 'customfield_10622',
|
||||
qaAssignee: 'customfield_10632',
|
||||
qaEntryRtqa: 'customfield_10821',
|
||||
qaTotalTimeRtqa: 'customfield_10822',
|
||||
qaLastDurRtqa: 'customfield_10823',
|
||||
qaEntryQa: 'customfield_10824',
|
||||
qaTotalTimeQa: 'customfield_10825',
|
||||
qaLastDurQa: 'customfield_10826',
|
||||
timesReturned: 'customfield_11217',
|
||||
spFinalReal: 'customfield_11216',
|
||||
crEntry: 'customfield_10818',
|
||||
crTotalTime: 'customfield_10819',
|
||||
enCursoEntry: 'customfield_10815',
|
||||
enCursoTotalTime: 'customfield_10816',
|
||||
},
|
||||
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND issuetype not in (Epic, "Sub-task", Subtask) ORDER BY updated DESC',
|
||||
api: {
|
||||
cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Cache en memoria (Netlify Functions pueden persistir entre llamadas calientes) ───
|
||||
let _cache = {};
|
||||
let _cacheTime = {};
|
||||
|
||||
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
||||
async function fetchJiraIssues(email, token, period) {
|
||||
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
||||
const maxResultsWanted = 1500;
|
||||
let allIssues = [];
|
||||
let startAt = 0;
|
||||
|
||||
let dynamicJql = config.jql
|
||||
.replace('${PROJECT}', config.jira.project)
|
||||
.replace('${FIELD_SP_ESTIMATE}', config.fields.spEstimate);
|
||||
|
||||
if (period !== 'all') {
|
||||
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
|
||||
if (dynamicJql.includes(' ORDER BY ')) {
|
||||
dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `);
|
||||
} else {
|
||||
dynamicJql += ` AND updated >= ${timeFilter}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📡 Consultando JIRA: ${config.jira.project} (periodo: ${period})`);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return { issues: allIssues, isTruncated: !!nextPageToken };
|
||||
}
|
||||
|
||||
function buildFieldsList() {
|
||||
return [
|
||||
'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated', 'duedate',
|
||||
config.fields.spEstimate, config.fields.spFinal, config.fields.developer,
|
||||
config.fields.qaAssignee, config.fields.qaEntryRtqa, config.fields.qaTotalTimeRtqa,
|
||||
config.fields.qaLastDurRtqa, config.fields.qaEntryQa, config.fields.qaTotalTimeQa,
|
||||
config.fields.qaLastDurQa, config.fields.timesReturned, config.fields.spFinalReal,
|
||||
config.fields.crEntry, config.fields.crTotalTime, config.fields.enCursoEntry,
|
||||
config.fields.enCursoTotalTime,
|
||||
].join(',');
|
||||
}
|
||||
|
||||
function minsToHrs(mins) {
|
||||
if (mins == null || mins === 0) return 0;
|
||||
return Math.round((mins / 60) * 10) / 10;
|
||||
}
|
||||
|
||||
function processIssues(issues, period = '90d') {
|
||||
const assigneeData = {};
|
||||
const monthlyDone = {};
|
||||
const issuesList = [];
|
||||
const warnings = [];
|
||||
const now = new Date();
|
||||
|
||||
let cutoffDate = null;
|
||||
if (period !== 'all') {
|
||||
const periodDays = period === '1d' ? 1 : parseInt(period.replace('d', ''));
|
||||
if (!isNaN(periodDays)) {
|
||||
if (period === '1d') {
|
||||
cutoffDate = new Date();
|
||||
cutoffDate.setHours(0,0,0,0);
|
||||
} else {
|
||||
cutoffDate = new Date(Date.now() - (periodDays * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const qaData = {
|
||||
tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0,
|
||||
monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [],
|
||||
timesQa: [], timesRtqa: []
|
||||
};
|
||||
|
||||
const deliveryData = {
|
||||
totalLeadTimeDays: 0, totalCycleTimeDays: 0, doneTicketsLead: 0, doneTicketsCycle: 0,
|
||||
doneSpOriginal: 0, doneSpFinal: 0
|
||||
};
|
||||
|
||||
const crData = {
|
||||
tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: [], times: []
|
||||
};
|
||||
|
||||
const currentStates = {
|
||||
codeReview: { count: 0, sp: 0 },
|
||||
readyToQa: { count: 0, sp: 0 },
|
||||
inQa: { count: 0, sp: 0 }
|
||||
};
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
const f = issue.fields;
|
||||
let devs = [];
|
||||
if (f[config.fields.developer]?.length) {
|
||||
devs = f[config.fields.developer].map(d => d.displayName);
|
||||
} else if (f.assignee) {
|
||||
devs = [f.assignee.displayName];
|
||||
} else {
|
||||
devs = ['Sin asignar'];
|
||||
}
|
||||
|
||||
const INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno'];
|
||||
devs = devs.filter(d => !INACTIVE_DEVS.includes(d));
|
||||
if (devs.length === 0) continue;
|
||||
|
||||
const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0;
|
||||
const statusKey = f.status?.statusCategory?.key ?? 'new';
|
||||
const statusName = f.status?.name?.toLowerCase() ?? '';
|
||||
const isDone = statusKey === 'done';
|
||||
|
||||
if (isDone && f.resolutiondate && cutoffDate) {
|
||||
const resDate = new Date(f.resolutiondate);
|
||||
if (resDate < cutoffDate) {
|
||||
continue; // Ticket resuelto antes del período (actualizado recientemente por otro motivo)
|
||||
}
|
||||
}
|
||||
|
||||
const isInProgress = statusKey === 'indeterminate';
|
||||
|
||||
if (!isDone) {
|
||||
if (statusName === 'code review' || statusName.includes('code review') || statusName === 'cr') {
|
||||
currentStates.codeReview.count++;
|
||||
currentStates.codeReview.sp += sp;
|
||||
} else if (statusName === 'ready to qa' || statusName.includes('ready to qa') || statusName === 'listo para qa') {
|
||||
currentStates.readyToQa.count++;
|
||||
currentStates.readyToQa.sp += sp;
|
||||
} else if (statusName === 'qa' || statusName.includes('in qa') || statusName === 'en qa') {
|
||||
currentStates.inQa.count++;
|
||||
currentStates.inQa.sp += sp;
|
||||
}
|
||||
}
|
||||
const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null;
|
||||
const creMonth = f.created ? f.created.slice(0, 7) : null;
|
||||
|
||||
for (const dev of devs) {
|
||||
if (!assigneeData[dev]) assigneeData[dev] = { done: 0, in_progress: 0, todo: 0, pending_count: 0 };
|
||||
if (isDone) {
|
||||
assigneeData[dev].done += sp;
|
||||
} else if (isInProgress) {
|
||||
assigneeData[dev].in_progress += sp;
|
||||
assigneeData[dev].pending_count++;
|
||||
} else {
|
||||
assigneeData[dev].todo += sp;
|
||||
assigneeData[dev].pending_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
const month = resMonth || creMonth;
|
||||
if (month) {
|
||||
if (!monthlyDone[month]) monthlyDone[month] = {};
|
||||
for (const dev of devs) {
|
||||
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery Metrics
|
||||
const createdDate = f.created ? new Date(f.created) : null;
|
||||
const resolvedDate = f.resolutiondate ? new Date(f.resolutiondate) : null;
|
||||
const enCursoDate = f[config.fields.enCursoEntry] ? new Date(f[config.fields.enCursoEntry]) : null;
|
||||
const spOriginal = f[config.fields.spEstimate] || 0;
|
||||
const spReal = f[config.fields.spFinalReal] != null ? f[config.fields.spFinalReal] : spOriginal;
|
||||
|
||||
if (resolvedDate) {
|
||||
if (createdDate) {
|
||||
deliveryData.totalLeadTimeDays += (resolvedDate - createdDate) / (1000 * 60 * 60 * 24);
|
||||
deliveryData.doneTicketsLead++;
|
||||
}
|
||||
if (enCursoDate) {
|
||||
deliveryData.totalCycleTimeDays += (resolvedDate - enCursoDate) / (1000 * 60 * 60 * 24);
|
||||
deliveryData.doneTicketsCycle++;
|
||||
}
|
||||
deliveryData.doneSpOriginal += spOriginal;
|
||||
deliveryData.doneSpFinal += spReal;
|
||||
}
|
||||
}
|
||||
|
||||
const issueWarnings = [];
|
||||
|
||||
if (isInProgress && f.updated) {
|
||||
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
|
||||
if (days > 14) {
|
||||
const w = { type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) };
|
||||
warnings.push(w);
|
||||
issueWarnings.push(w);
|
||||
}
|
||||
}
|
||||
if (!isDone && f.duedate) {
|
||||
const dueDate = new Date(f.duedate);
|
||||
if (dueDate < now) {
|
||||
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
|
||||
const w = { type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue };
|
||||
warnings.push(w);
|
||||
issueWarnings.push(w);
|
||||
}
|
||||
}
|
||||
if (isDone && sp === 0) {
|
||||
const w = { type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 };
|
||||
warnings.push(w);
|
||||
issueWarnings.push(w);
|
||||
}
|
||||
if (!isDone && devs.includes('Sin asignar') && sp > 0) {
|
||||
const w = { type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp };
|
||||
warnings.push(w);
|
||||
issueWarnings.push(w);
|
||||
}
|
||||
|
||||
issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth, warnings: issueWarnings });
|
||||
|
||||
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
||||
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
||||
let timesRet = f[config.fields.timesReturned] ?? 0;
|
||||
|
||||
// Ignorar valores erróneos acumulados por la automatización vieja
|
||||
const automationFixDate = new Date('2026-04-07T00:00:00-03:00');
|
||||
const issueCreated = f.created ? new Date(f.created) : null;
|
||||
if (issueCreated && issueCreated < automationFixDate) {
|
||||
timesRet = 1; // Lo forzamos a 1 (no devuelto) para que no rompa las métricas con data sucia
|
||||
}
|
||||
const qaMonth = resMonth || creMonth;
|
||||
|
||||
const timeCrMin = f[config.fields.crTotalTime] ?? 0;
|
||||
const timeDevMin = f[config.fields.enCursoTotalTime] ?? 0;
|
||||
|
||||
if (timeQaMin > 0) {
|
||||
qaData.tickets++;
|
||||
qaData.totalTimeQa += timeQaMin;
|
||||
qaData.timesQa.push(timeQaMin);
|
||||
if (timesRet > 1) qaData.ticketsReturned++;
|
||||
if (qaMonth) {
|
||||
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
|
||||
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
|
||||
}
|
||||
qaData.issues.push({
|
||||
key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin),
|
||||
timesReturned: timesRet, status: statusKey, resMonth: qaMonth,
|
||||
timeCr: minsToHrs(timeCrMin), timeDev: minsToHrs(timeDevMin)
|
||||
});
|
||||
}
|
||||
|
||||
if (timeRtqaMin > 0) {
|
||||
qaData.timesRtqa.push(timeRtqaMin);
|
||||
if (qaMonth) {
|
||||
qaData.totalTimeRtqa += timeRtqaMin;
|
||||
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
|
||||
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeCrMin > 0) {
|
||||
crData.tickets++;
|
||||
crData.totalTimeCr += timeCrMin;
|
||||
crData.times.push(timeCrMin);
|
||||
if (qaMonth) {
|
||||
crData.monthlyTotalCr[qaMonth] = (crData.monthlyTotalCr[qaMonth] || 0) + timeCrMin;
|
||||
crData.monthlyCountCr[qaMonth] = (crData.monthlyCountCr[qaMonth] || 0) + 1;
|
||||
}
|
||||
crData.issues.push({ key: issue.key, summary: f.summary, timeCr: minsToHrs(timeCrMin), status: statusKey });
|
||||
}
|
||||
|
||||
const crEntryDate = f[config.fields.crEntry];
|
||||
if (crEntryDate && !isDone && !f[config.fields.qaEntryRtqa]) {
|
||||
const daysInCr = (now - new Date(crEntryDate)) / (1000 * 60 * 60 * 24);
|
||||
if (daysInCr > 2) {
|
||||
crData.stalledIssues.push({ key: issue.key, summary: f.summary, days: Math.round(daysInCr * 10)/10 });
|
||||
}
|
||||
}
|
||||
|
||||
// Las alertas de salud se generan y empujan arriba (junto con issueWarnings)
|
||||
|
||||
} catch (e) {
|
||||
console.warn(`Error procesando issue ${issue.key}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyAvgQa = {};
|
||||
for (const m of Object.keys(qaData.monthlyTotalQa)) monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
|
||||
const monthlyAvgRtqa = {};
|
||||
for (const m of Object.keys(qaData.monthlyTotalRtqa)) monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
|
||||
|
||||
const EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca'];
|
||||
|
||||
for (const dev of Object.keys(assigneeData)) {
|
||||
if (!EXCLUDED_FROM_ALERTS.includes(dev) && assigneeData[dev].pending_count === 0) {
|
||||
warnings.push({ type: 'free_dev', summary: `${dev} no tiene tickets pendientes ni en curso`, devs: [dev] });
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyAvgCr = {};
|
||||
for (const m of Object.keys(crData.monthlyTotalCr)) monthlyAvgCr[m] = minsToHrs(crData.monthlyTotalCr[m] / crData.monthlyCountCr[m]);
|
||||
|
||||
const crTimesSorted = crData.times.sort((a, b) => a - b);
|
||||
function getPercentile(sortedArr, p) {
|
||||
if (sortedArr.length === 0) return 0;
|
||||
if (p <= 0) return sortedArr[0];
|
||||
if (p >= 1) return sortedArr[sortedArr.length - 1];
|
||||
const index = (sortedArr.length - 1) * p;
|
||||
const lower = Math.floor(index);
|
||||
const upper = lower + 1;
|
||||
const weight = index % 1;
|
||||
if (upper >= sortedArr.length) return sortedArr[lower];
|
||||
return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight;
|
||||
}
|
||||
const p50Cr = minsToHrs(getPercentile(crTimesSorted, 0.5));
|
||||
const p90Cr = minsToHrs(getPercentile(crTimesSorted, 0.9));
|
||||
const p99Cr = minsToHrs(getPercentile(crTimesSorted, 0.99));
|
||||
|
||||
const qaTimesSorted = qaData.timesQa.sort((a, b) => a - b);
|
||||
const p50Qa = minsToHrs(getPercentile(qaTimesSorted, 0.5));
|
||||
const p90Qa = minsToHrs(getPercentile(qaTimesSorted, 0.9));
|
||||
const p99Qa = minsToHrs(getPercentile(qaTimesSorted, 0.99));
|
||||
|
||||
const rtqaTimesSorted = qaData.timesRtqa.sort((a, b) => a - b);
|
||||
const p50Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.5));
|
||||
const p90Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.9));
|
||||
const p99Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.99));
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
assigneeData, monthlyDone, issuesList, warnings, currentStates,
|
||||
deliveryData: {
|
||||
avgLeadTime: deliveryData.doneTicketsLead > 0 ? Math.round(deliveryData.totalLeadTimeDays / deliveryData.doneTicketsLead) : 0,
|
||||
avgCycleTime: deliveryData.doneTicketsCycle > 0 ? Math.round(deliveryData.totalCycleTimeDays / deliveryData.doneTicketsCycle) : 0,
|
||||
spOriginal: deliveryData.doneSpOriginal,
|
||||
spFinal: deliveryData.doneSpFinal,
|
||||
spDeviationPct: deliveryData.doneSpOriginal > 0 ? ((deliveryData.doneSpFinal - deliveryData.doneSpOriginal) / deliveryData.doneSpOriginal * 100).toFixed(1) : 0
|
||||
},
|
||||
crData: {
|
||||
...crData, monthlyAvgCr, p50Cr, p90Cr, p99Cr,
|
||||
avgTimeCr: crData.tickets > 0 ? minsToHrs(crData.totalTimeCr / crData.tickets) : 0
|
||||
},
|
||||
qaData: {
|
||||
...qaData, monthlyAvgQa, monthlyAvgRtqa,
|
||||
p50Qa, p90Qa, p99Qa, p50Rtqa, p90Rtqa, p99Rtqa,
|
||||
avgTimeQa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0,
|
||||
avgTimeRtqa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Handler Netlify ───
|
||||
exports.handler = async (event) => {
|
||||
try {
|
||||
const queryParams = event.queryStringParameters || {};
|
||||
const forceRefresh = queryParams.refresh === '1';
|
||||
const period = queryParams.period || '90d';
|
||||
|
||||
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ..._cache[period], fromCache: true })
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.jira.email || !config.jira.token) throw new Error('Faltan credenciales JIRA en variables de entorno');
|
||||
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
|
||||
const data = processIssues(jiraData.issues, period);
|
||||
|
||||
data.isTruncated = jiraData.isTruncated;
|
||||
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
|
||||
data.period = period;
|
||||
|
||||
_cache[period] = data;
|
||||
_cacheTime[period] = Date.now();
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...data, fromCache: false })
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: err.message })
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
flask
|
||||
requests
|
||||
python-dotenv
|
||||
@@ -0,0 +1,494 @@
|
||||
"""
|
||||
server.py — Servidor Flask para Dashboard LABS/Infra — Story Points
|
||||
Reemplaza las Netlify Functions. Sirve el frontend y el endpoint /api/data
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
import requests
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Cargar variables de entorno desde .env
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, static_folder='.')
|
||||
|
||||
# ─── Configuración ─────────────────────────────────────────────────────────
|
||||
USE_TICKET_COUNT = os.getenv('USE_TICKET_COUNT', 'false').lower() == 'true'
|
||||
|
||||
config = {
|
||||
'jira': {
|
||||
'host': os.getenv('JIRA_HOST', 'invera.atlassian.net'),
|
||||
'project': os.getenv('JIRA_PROJECT', 'IO'),
|
||||
'email': os.getenv('JIRA_EMAIL'),
|
||||
'token': os.getenv('JIRA_TOKEN'),
|
||||
},
|
||||
'fields': {
|
||||
'spEstimate': os.getenv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||
'spFinal': os.getenv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||
'developer': os.getenv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||
'qaAssignee': 'customfield_10632',
|
||||
'qaEntryRtqa': 'customfield_10821',
|
||||
'qaTotalTimeRtqa': 'customfield_10822',
|
||||
'qaLastDurRtqa': 'customfield_10823',
|
||||
'qaEntryQa': 'customfield_10824',
|
||||
'qaTotalTimeQa': 'customfield_10825',
|
||||
'qaLastDurQa': 'customfield_10826',
|
||||
'timesReturned': 'customfield_11217',
|
||||
'spFinalReal': 'customfield_11216',
|
||||
'crEntry': 'customfield_10818',
|
||||
'crTotalTime': 'customfield_10819',
|
||||
'enCursoEntry': 'customfield_10815',
|
||||
'enCursoTotalTime':'customfield_10816',
|
||||
},
|
||||
'cacheMsec': int(os.getenv('CACHE_MS', 3600000)),
|
||||
}
|
||||
|
||||
# ─── Cache en memoria ──────────────────────────────────────────────────────
|
||||
_cache = {}
|
||||
_cache_time = {}
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
def mins_to_hrs(mins):
|
||||
if not mins:
|
||||
return 0
|
||||
return round((mins / 60) * 10) / 10
|
||||
|
||||
def get_percentile(sorted_arr, p):
|
||||
if not sorted_arr:
|
||||
return 0
|
||||
if p <= 0:
|
||||
return sorted_arr[0]
|
||||
if p >= 1:
|
||||
return sorted_arr[-1]
|
||||
index = (len(sorted_arr) - 1) * p
|
||||
lower = math.floor(index)
|
||||
upper = lower + 1
|
||||
weight = index % 1
|
||||
if upper >= len(sorted_arr):
|
||||
return sorted_arr[lower]
|
||||
return sorted_arr[lower] * (1 - weight) + sorted_arr[upper] * weight
|
||||
|
||||
def build_fields_list():
|
||||
f = config['fields']
|
||||
return ','.join([
|
||||
'summary', 'status', 'assignee', 'reporter', 'resolutiondate', 'created', 'updated', 'duedate',
|
||||
f['spEstimate'], f['spFinal'], f['developer'],
|
||||
f['qaAssignee'], f['qaEntryRtqa'], f['qaTotalTimeRtqa'], f['qaLastDurRtqa'],
|
||||
f['qaEntryQa'], f['qaTotalTimeQa'], f['qaLastDurQa'],
|
||||
f['timesReturned'], f['spFinalReal'], f['crEntry'], f['crTotalTime'],
|
||||
f['enCursoEntry'], f['enCursoTotalTime'],
|
||||
])
|
||||
|
||||
# ─── Fetch de Jira ─────────────────────────────────────────────────────────
|
||||
def fetch_jira_issues(period):
|
||||
email = config['jira']['email']
|
||||
token = config['jira']['token']
|
||||
host = config['jira']['host']
|
||||
project = config['jira']['project']
|
||||
|
||||
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||
headers = {
|
||||
'Authorization': f'Basic {credentials}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
max_results_wanted = 1500
|
||||
all_issues = []
|
||||
|
||||
from datetime import timedelta
|
||||
jql = f'project = {project} ORDER BY updated DESC'
|
||||
|
||||
print(f" → Email: {email}")
|
||||
print(f" → Token: {token[:20] if token else 'NO TOKEN'}...")
|
||||
print(f" → JQL: {jql}")
|
||||
|
||||
print(f"📡 Consultando JIRA: {project} (periodo: {period})")
|
||||
|
||||
fields_param = build_fields_list()
|
||||
next_page_token = None
|
||||
|
||||
while len(all_issues) < max_results_wanted:
|
||||
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=100&fields=summary,status,assignee,reporter,resolutiondate,created,updated,duedate"
|
||||
if next_page_token:
|
||||
url += f"&nextPageToken={requests.utils.quote(next_page_token)}"
|
||||
print(f" → URL: {url[:200]}")
|
||||
res = requests.get(url, headers=headers)
|
||||
|
||||
print(f" → HTTP {res.status_code} — respuesta: {res.text[:200]}")
|
||||
|
||||
if not res.ok:
|
||||
raise Exception(f"JIRA API {res.status_code}: {res.text[:200]}")
|
||||
|
||||
data = res.json()
|
||||
items = data.get('issues', [])
|
||||
all_issues.extend(items)
|
||||
next_page_token = data.get('nextPageToken')
|
||||
|
||||
print(f" → {len(items)} tickets en esta página | Total acumulado: {len(all_issues)}")
|
||||
|
||||
if not items or not next_page_token:
|
||||
break
|
||||
|
||||
print(f"✅ Total tickets obtenidos de Jira: {len(all_issues)}")
|
||||
return {'issues': all_issues, 'isTruncated': bool(next_page_token)}
|
||||
|
||||
# ─── Procesamiento de Issues ───────────────────────────────────────────────
|
||||
def process_issues(issues, period='90d'):
|
||||
f = config['fields']
|
||||
assignee_data = {}
|
||||
monthly_done = {}
|
||||
issues_list = []
|
||||
warnings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
cutoff_date = None
|
||||
if period != 'all':
|
||||
period_days = 1 if period == '1d' else int(period.replace('d', ''))
|
||||
if period == '1d':
|
||||
cutoff_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
cutoff_date = datetime.fromtimestamp(time.time() - period_days * 86400, tz=timezone.utc)
|
||||
|
||||
qa_data = {
|
||||
'tickets': 0, 'ticketsReturned': 0, 'totalTimeQa': 0, 'totalTimeRtqa': 0,
|
||||
'monthlyTotalQa': {}, 'monthlyCountQa': {},
|
||||
'monthlyTotalRtqa': {}, 'monthlyCountRtqa': {},
|
||||
'issues': [], 'timesQa': [], 'timesRtqa': []
|
||||
}
|
||||
|
||||
tickets_created = 0 # Contador de tickets creados en el período
|
||||
reporter_data = {} # Tickets solicitados por persona
|
||||
|
||||
delivery_data = {
|
||||
'totalLeadTimeDays': 0, 'totalCycleTimeDays': 0,
|
||||
'doneTicketsLead': 0, 'doneTicketsCycle': 0,
|
||||
'doneSpOriginal': 0, 'doneSpFinal': 0
|
||||
}
|
||||
|
||||
cr_data = {
|
||||
'tickets': 0, 'totalTimeCr': 0, 'issues': [],
|
||||
'monthlyTotalCr': {}, 'monthlyCountCr': {},
|
||||
'stalledIssues': [], 'times': []
|
||||
}
|
||||
|
||||
current_states = {
|
||||
'codeReview': {'count': 0, 'sp': 0},
|
||||
'readyToQa': {'count': 0, 'sp': 0},
|
||||
'inQa': {'count': 0, 'sp': 0},
|
||||
}
|
||||
|
||||
INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno', 'Josefina Vera Ocampo', 'Isidro Emir Pelli']
|
||||
|
||||
# Normalizar nombres de usuario a nombre real
|
||||
NAME_MAP = {
|
||||
'facundo.devoto': 'Devoto Facundo',
|
||||
}
|
||||
|
||||
for issue in issues:
|
||||
try:
|
||||
fields = issue['fields']
|
||||
|
||||
# Devs
|
||||
devs = []
|
||||
if fields.get(f['developer']):
|
||||
devs = [d['displayName'] for d in fields[f['developer']]]
|
||||
elif fields.get('assignee'):
|
||||
devs = [fields['assignee']['displayName']]
|
||||
else:
|
||||
devs = ['Sin asignar']
|
||||
|
||||
devs = [NAME_MAP.get(d, d) for d in devs]
|
||||
devs = [d for d in devs if d not in INACTIVE_DEVS]
|
||||
if not devs:
|
||||
continue
|
||||
|
||||
if USE_TICKET_COUNT:
|
||||
sp = 1
|
||||
else:
|
||||
sp = (fields.get(f['spFinal']) if fields.get(f['spFinal']) is not None
|
||||
else fields.get(f['spEstimate'])) or 0
|
||||
|
||||
status_key = (fields.get('status') or {}).get('statusCategory', {}).get('key', 'new')
|
||||
status_name = ((fields.get('status') or {}).get('name') or '').lower()
|
||||
is_done = status_key == 'done'
|
||||
is_in_progress = status_key == 'indeterminate'
|
||||
|
||||
# Contar tickets creados dentro del período + reporter
|
||||
if fields.get('created'):
|
||||
created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00'))
|
||||
if cutoff_date is None or created_dt >= cutoff_date:
|
||||
tickets_created += 1
|
||||
reporter = (fields.get('reporter') or {}).get('displayName', 'Sin asignar')
|
||||
reporter_data[reporter] = reporter_data.get(reporter, 0) + 1
|
||||
|
||||
# Filtrar done tickets fuera del período
|
||||
if is_done and fields.get('resolutiondate') and cutoff_date:
|
||||
res_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00'))
|
||||
if res_date < cutoff_date:
|
||||
continue
|
||||
|
||||
# Estado actual (colas)
|
||||
if not is_done:
|
||||
if 'code review' in status_name or status_name == 'cr':
|
||||
current_states['codeReview']['count'] += 1
|
||||
current_states['codeReview']['sp'] += sp
|
||||
elif 'ready to qa' in status_name or status_name == 'listo para qa':
|
||||
current_states['readyToQa']['count'] += 1
|
||||
current_states['readyToQa']['sp'] += sp
|
||||
elif status_name == 'qa' or 'in qa' in status_name or status_name == 'en qa':
|
||||
current_states['inQa']['count'] += 1
|
||||
current_states['inQa']['sp'] += sp
|
||||
|
||||
res_month = fields['resolutiondate'][:7] if fields.get('resolutiondate') else None
|
||||
cre_month = fields['created'][:7] if fields.get('created') else None
|
||||
|
||||
for dev in devs:
|
||||
if dev not in assignee_data:
|
||||
assignee_data[dev] = {'done': 0, 'in_progress': 0, 'todo': 0, 'pending_count': 0}
|
||||
if is_done:
|
||||
assignee_data[dev]['done'] += sp
|
||||
elif is_in_progress:
|
||||
assignee_data[dev]['in_progress'] += sp
|
||||
assignee_data[dev]['pending_count'] += 1
|
||||
else:
|
||||
assignee_data[dev]['todo'] += sp
|
||||
assignee_data[dev]['pending_count'] += 1
|
||||
|
||||
if is_done:
|
||||
month = res_month or cre_month
|
||||
if month:
|
||||
if month not in monthly_done:
|
||||
monthly_done[month] = {}
|
||||
for dev in devs:
|
||||
monthly_done[month][dev] = monthly_done[month].get(dev, 0) + sp
|
||||
|
||||
# Delivery metrics
|
||||
created_date = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||
resolved_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) if fields.get('resolutiondate') else None
|
||||
en_curso_date = datetime.fromisoformat(fields[f['enCursoEntry']].replace('Z', '+00:00')) if fields.get(f['enCursoEntry']) else None
|
||||
sp_original = fields.get(f['spEstimate']) or 0
|
||||
sp_real = fields.get(f['spFinalReal']) if fields.get(f['spFinalReal']) is not None else sp_original
|
||||
|
||||
if resolved_date:
|
||||
if created_date:
|
||||
delivery_data['totalLeadTimeDays'] += (resolved_date - created_date).total_seconds() / 86400
|
||||
delivery_data['doneTicketsLead'] += 1
|
||||
if en_curso_date:
|
||||
delivery_data['totalCycleTimeDays'] += (resolved_date - en_curso_date).total_seconds() / 86400
|
||||
delivery_data['doneTicketsCycle'] += 1
|
||||
delivery_data['doneSpOriginal'] += sp_original
|
||||
delivery_data['doneSpFinal'] += sp_real
|
||||
|
||||
# Warnings
|
||||
issue_warnings = []
|
||||
|
||||
if is_in_progress and fields.get('updated'):
|
||||
updated = datetime.fromisoformat(fields['updated'].replace('Z', '+00:00'))
|
||||
days = (now - updated).total_seconds() / 86400
|
||||
if days > 14:
|
||||
w = {'type': 'stalled', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': round(days)}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if not is_done and fields.get('duedate'):
|
||||
due_date = datetime.fromisoformat(fields['duedate'] + 'T00:00:00+00:00')
|
||||
if due_date < now:
|
||||
days_overdue = (now - due_date).days
|
||||
w = {'type': 'expired', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': days_overdue}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if is_done and sp == 0:
|
||||
w = {'type': 'done_no_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': 0}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
if not is_done and 'Sin asignar' in devs and sp > 0:
|
||||
w = {'type': 'unassigned_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp}
|
||||
warnings.append(w)
|
||||
issue_warnings.append(w)
|
||||
|
||||
issues_list.append({
|
||||
'id': issue['id'], 'key': issue['key'],
|
||||
'summary': fields['summary'], 'status': status_key,
|
||||
'sp': sp, 'devs': devs, 'creMonth': cre_month,
|
||||
'warnings': issue_warnings
|
||||
})
|
||||
|
||||
# QA & CR
|
||||
time_qa_min = fields.get(f['qaTotalTimeQa']) or 0
|
||||
time_rtqa_min = fields.get(f['qaTotalTimeRtqa']) or 0
|
||||
time_cr_min = fields.get(f['crTotalTime']) or 0
|
||||
time_dev_min = fields.get(f['enCursoTotalTime']) or 0
|
||||
times_ret = fields.get(f['timesReturned']) or 0
|
||||
qa_month = res_month or cre_month
|
||||
|
||||
# Fix datos sucios anteriores al 7-Apr-2026
|
||||
automation_fix_date = datetime(2026, 4, 7, tzinfo=timezone.utc)
|
||||
issue_created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||
if issue_created_dt and issue_created_dt < automation_fix_date:
|
||||
times_ret = 1
|
||||
|
||||
if time_qa_min > 0:
|
||||
qa_data['tickets'] += 1
|
||||
qa_data['totalTimeQa'] += time_qa_min
|
||||
qa_data['timesQa'].append(time_qa_min)
|
||||
if times_ret > 1:
|
||||
qa_data['ticketsReturned'] += 1
|
||||
if qa_month:
|
||||
qa_data['monthlyTotalQa'][qa_month] = qa_data['monthlyTotalQa'].get(qa_month, 0) + time_qa_min
|
||||
qa_data['monthlyCountQa'][qa_month] = qa_data['monthlyCountQa'].get(qa_month, 0) + 1
|
||||
qa_data['issues'].append({
|
||||
'key': issue['key'], 'summary': fields['summary'],
|
||||
'timeQa': mins_to_hrs(time_qa_min), 'timeRtqa': mins_to_hrs(time_rtqa_min),
|
||||
'timesReturned': times_ret, 'status': status_key, 'resMonth': qa_month,
|
||||
'timeCr': mins_to_hrs(time_cr_min), 'timeDev': mins_to_hrs(time_dev_min)
|
||||
})
|
||||
|
||||
if time_rtqa_min > 0:
|
||||
qa_data['timesRtqa'].append(time_rtqa_min)
|
||||
if qa_month:
|
||||
qa_data['totalTimeRtqa'] += time_rtqa_min
|
||||
qa_data['monthlyTotalRtqa'][qa_month] = qa_data['monthlyTotalRtqa'].get(qa_month, 0) + time_rtqa_min
|
||||
qa_data['monthlyCountRtqa'][qa_month] = qa_data['monthlyCountRtqa'].get(qa_month, 0) + 1
|
||||
|
||||
if time_cr_min > 0:
|
||||
cr_data['tickets'] += 1
|
||||
cr_data['totalTimeCr'] += time_cr_min
|
||||
cr_data['times'].append(time_cr_min)
|
||||
if qa_month:
|
||||
cr_data['monthlyTotalCr'][qa_month] = cr_data['monthlyTotalCr'].get(qa_month, 0) + time_cr_min
|
||||
cr_data['monthlyCountCr'][qa_month] = cr_data['monthlyCountCr'].get(qa_month, 0) + 1
|
||||
cr_data['issues'].append({'key': issue['key'], 'summary': fields['summary'], 'timeCr': mins_to_hrs(time_cr_min), 'status': status_key})
|
||||
|
||||
cr_entry_date = fields.get(f['crEntry'])
|
||||
if cr_entry_date and not is_done and not fields.get(f['qaEntryRtqa']):
|
||||
cr_entry_dt = datetime.fromisoformat(cr_entry_date.replace('Z', '+00:00'))
|
||||
days_in_cr = (now - cr_entry_dt).total_seconds() / 86400
|
||||
if days_in_cr > 2:
|
||||
cr_data['stalledIssues'].append({'key': issue['key'], 'summary': fields['summary'], 'days': round(days_in_cr * 10) / 10})
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error procesando {issue.get('key', '?')}: {e}")
|
||||
|
||||
# Alertas de devs libres
|
||||
EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca']
|
||||
for dev, ad in assignee_data.items():
|
||||
if dev not in EXCLUDED_FROM_ALERTS and ad['pending_count'] == 0:
|
||||
warnings.append({'type': 'free_dev', 'summary': f'{dev} no tiene tickets pendientes ni en curso', 'devs': [dev]})
|
||||
|
||||
# Promedios mensuales QA/RTQA/CR
|
||||
monthly_avg_qa = {m: mins_to_hrs(qa_data['monthlyTotalQa'][m] / qa_data['monthlyCountQa'][m]) for m in qa_data['monthlyTotalQa']}
|
||||
monthly_avg_rtqa = {m: mins_to_hrs(qa_data['monthlyTotalRtqa'][m] / qa_data['monthlyCountRtqa'][m]) for m in qa_data['monthlyTotalRtqa']}
|
||||
monthly_avg_cr = {m: mins_to_hrs(cr_data['monthlyTotalCr'][m] / cr_data['monthlyCountCr'][m]) for m in cr_data['monthlyTotalCr']}
|
||||
|
||||
# Percentiles
|
||||
cr_sorted = sorted(cr_data['times'])
|
||||
qa_sorted = sorted(qa_data['timesQa'])
|
||||
rtqa_sorted = sorted(qa_data['timesRtqa'])
|
||||
|
||||
avg_lead = round(delivery_data['totalLeadTimeDays'] / delivery_data['doneTicketsLead']) if delivery_data['doneTicketsLead'] else 0
|
||||
avg_cycle = round(delivery_data['totalCycleTimeDays'] / delivery_data['doneTicketsCycle']) if delivery_data['doneTicketsCycle'] else 0
|
||||
sp_orig = delivery_data['doneSpOriginal']
|
||||
sp_fin = delivery_data['doneSpFinal']
|
||||
sp_dev_pct = round((sp_fin - sp_orig) / sp_orig * 100, 1) if sp_orig else 0
|
||||
|
||||
return {
|
||||
'lastUpdated': datetime.now(timezone.utc).isoformat(),
|
||||
'assigneeData': assignee_data,
|
||||
'monthlyDone': monthly_done,
|
||||
'issuesList': issues_list,
|
||||
'warnings': warnings,
|
||||
'currentStates': current_states,
|
||||
'ticketsCreated': tickets_created,
|
||||
'reporterData': reporter_data,
|
||||
'deliveryData': {
|
||||
'avgLeadTime': avg_lead,
|
||||
'avgCycleTime': avg_cycle,
|
||||
'spOriginal': sp_orig,
|
||||
'spFinal': sp_fin,
|
||||
'spDeviationPct': sp_dev_pct,
|
||||
},
|
||||
'crData': {
|
||||
**cr_data,
|
||||
'monthlyAvgCr': monthly_avg_cr,
|
||||
'p50Cr': mins_to_hrs(get_percentile(cr_sorted, 0.5)),
|
||||
'p90Cr': mins_to_hrs(get_percentile(cr_sorted, 0.9)),
|
||||
'p99Cr': mins_to_hrs(get_percentile(cr_sorted, 0.99)),
|
||||
'avgTimeCr': mins_to_hrs(cr_data['totalTimeCr'] / cr_data['tickets']) if cr_data['tickets'] else 0,
|
||||
},
|
||||
'qaData': {
|
||||
**qa_data,
|
||||
'monthlyAvgQa': monthly_avg_qa,
|
||||
'monthlyAvgRtqa': monthly_avg_rtqa,
|
||||
'p50Qa': mins_to_hrs(get_percentile(qa_sorted, 0.5)),
|
||||
'p90Qa': mins_to_hrs(get_percentile(qa_sorted, 0.9)),
|
||||
'p99Qa': mins_to_hrs(get_percentile(qa_sorted, 0.99)),
|
||||
'p50Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.5)),
|
||||
'p90Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.9)),
|
||||
'p99Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.99)),
|
||||
'avgTimeQa': mins_to_hrs(qa_data['totalTimeQa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||
'avgTimeRtqa': mins_to_hrs(qa_data['totalTimeRtqa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||
},
|
||||
'config': {'host': config['jira']['host'], 'useTicketCount': USE_TICKET_COUNT},
|
||||
}
|
||||
|
||||
# ─── Rutas Flask ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/data')
|
||||
def api_data():
|
||||
try:
|
||||
period = request.args.get('period', '90d')
|
||||
force_refresh = request.args.get('refresh') == '1'
|
||||
|
||||
cache_valid = (
|
||||
not force_refresh
|
||||
and period in _cache
|
||||
and (time.time() * 1000 - _cache_time.get(period, 0)) < config['cacheMsec']
|
||||
)
|
||||
|
||||
if cache_valid:
|
||||
return jsonify({**_cache[period], 'fromCache': True})
|
||||
|
||||
if not config['jira']['email'] or not config['jira']['token']:
|
||||
return jsonify({'error': 'Faltan credenciales JIRA en el archivo .env'}), 500
|
||||
|
||||
jira_result = fetch_jira_issues(period)
|
||||
data = process_issues(jira_result['issues'], period)
|
||||
|
||||
data['isTruncated'] = jira_result['isTruncated']
|
||||
data['jiraTotal'] = '1500+' if jira_result['isTruncated'] else len(jira_result['issues'])
|
||||
data['period'] = period
|
||||
data['fromCache'] = False
|
||||
|
||||
_cache[period] = data
|
||||
_cache_time[period] = time.time() * 1000
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# Servir archivos estáticos (CSS, JS)
|
||||
@app.route('/css/<path:filename>')
|
||||
def css(filename):
|
||||
return send_from_directory('css', filename)
|
||||
|
||||
@app.route('/js/<path:filename>')
|
||||
def js(filename):
|
||||
return send_from_directory('js', filename)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory('.', 'index.html')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 Dashboard corriendo en http://localhost:8888")
|
||||
app.run(host='0.0.0.0', port=8888, debug=True)
|
||||
@@ -5,6 +5,14 @@
|
||||
"src": "/api/(.*)",
|
||||
"dest": "/api/$1"
|
||||
},
|
||||
{
|
||||
"src": "/css/(.*)",
|
||||
"dest": "/css/$1"
|
||||
},
|
||||
{
|
||||
"src": "/js/(.*)",
|
||||
"dest": "/js/$1"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/index.html"
|
||||
|
||||
Reference in New Issue
Block a user