1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:18:42 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-04 18:27:30 +02:00
parent afb165cbfc
commit 44bdfbb939
4 changed files with 491 additions and 77 deletions

View File

@@ -1301,11 +1301,104 @@ a:hover { text-decoration: underline; }
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
.tag-editor--modal { margin: 0; border: none; border-radius: 0; }
.graphs-app .graphs-quick-links {
.graphs-app .graphs-quick-links,
.console-quick-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
align-items: center;
}
.console-quick-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 8px;
text-decoration: none;
color: var(--text);
background: var(--surface-2, rgba(255, 255, 255, 0.06));
font-size: 0.9rem;
line-height: 1.2;
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
}
.console-quick-link:hover {
background: var(--row-hover, rgba(255, 255, 255, 0.1));
color: var(--text);
}
.console-quick-link .nav-icon {
flex-shrink: 0;
}
.nav-icon--git {
width: 18px;
height: 18px;
}
.nav-icon--git::before {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 7px;
height: 7px;
border: 2px solid currentColor;
border-radius: 50%;
box-sizing: border-box;
}
.nav-icon--git::after {
content: "";
position: absolute;
left: 8px;
top: 5px;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-left: none;
border-bottom: none;
border-radius: 0 6px 0 0;
box-sizing: border-box;
}
.nav-icon--builder {
width: 18px;
height: 18px;
border: 2px solid currentColor;
border-radius: 3px;
box-sizing: border-box;
position: relative;
}
.nav-icon--builder::before {
content: "";
position: absolute;
left: 3px;
top: 7px;
width: 10px;
height: 2px;
background: currentColor;
box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor;
}
.nav-icon--remote {
width: 18px;
height: 18px;
}
.nav-icon--remote::before {
content: "";
position: absolute;
left: 2px;
top: 8px;
width: 14px;
height: 2px;
background: currentColor;
border-radius: 1px;
}
.nav-icon--remote::after {
content: "";
position: absolute;
left: 5px;
top: 3px;
width: 8px;
height: 8px;
border: 2px solid currentColor;
border-radius: 2px;
box-sizing: border-box;
}
.graph-quick-link { font-size: 0.9rem; }
.graphs-scope { margin-top: 20px; }
@@ -1315,12 +1408,98 @@ a:hover { text-decoration: underline; }
font-weight: 600;
color: var(--text);
}
.graphs-grid {
.graphs-app .graphs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
grid-template-columns: repeat(var(--graphs-columns, 2), minmax(0, 1fr));
gap: 16px;
width: 100%;
}
.graphs-grid .card.card--lift.graph-brick--clickable {
cursor: pointer;
min-height: 12rem;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.graphs-grid .card.card--lift.graph-brick--clickable:hover,
.graphs-grid .card.card--lift.graph-brick--clickable:focus-visible {
box-shadow: 0 0 0 1px var(--accent, #3d8bfd);
outline: none;
}
.graphs-grid .card.card--lift.graph-brick--clickable h3::after {
content: " ↗";
font-size: 0.75em;
color: var(--muted, #94a3b8);
font-weight: 400;
}
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; }
.graph-detail-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(8, 12, 20, 0.92);
display: flex;
flex-direction: column;
}
.graph-detail-overlay[hidden] {
display: none !important;
}
.graph-detail-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100vh;
background: var(--bg, #0f1419);
}
.graph-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1));
flex-shrink: 0;
}
.graph-detail-header h2 {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
}
.graph-detail-canvas-wrap {
position: relative;
flex: 1;
overflow: auto;
padding: 16px 20px 24px;
}
.graph-detail-canvas-wrap canvas {
display: block;
width: 100%;
max-width: 100%;
height: auto;
}
.graph-detail-body {
max-width: 960px;
}
.graph-detail-kpi {
font-size: 3rem;
}
.graph-detail-tooltip {
position: absolute;
z-index: 3;
margin: 0;
padding: 6px 10px;
border-radius: 6px;
background: var(--surface-2, #1e293b);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
color: var(--text);
font-size: 0.85rem;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.graph-detail-table {
font-size: 1rem;
max-width: 720px;
}
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; }
.graph-kpi {
font-size: 2rem;
font-weight: 700;

View File

@@ -1,6 +1,29 @@
(function () {
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
const BRICK_DEFS = {
sessions: { type: 'line', field: 'sessions_per_day', title: 'Sessions / day', color: COLORS[0] },
devices: { type: 'line', field: 'unique_devices_per_day', title: 'Unique devices / day', color: COLORS[1] },
duration: { type: 'line', field: 'avg_duration_s_per_day', title: 'Avg duration (s) / day', color: COLORS[2] },
send: { type: 'line', field: 'send_sessions_per_day', title: 'Send sessions / day', color: COLORS[3] },
recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] },
bitrate: { type: 'line', field: 'avg_bitrate_kbps_per_day', title: 'Outbound kbps / day', color: COLORS[0] },
'recv-kbps': { type: 'line', field: 'avg_recv_kbps_per_day', title: 'Inbound kbps / day', color: COLORS[1] },
crashes: { type: 'line', field: 'crashes_per_day', title: 'Crashes / day', color: '#f87171' },
tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' },
'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' },
'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' },
transport: { type: 'breakdown', field: 'transport_mix', title: 'Transport mix' },
versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' },
success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' },
'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' },
fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top crash fingerprints' },
'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Crashes by app version' },
};
let graphScopes = { user: null, slug: null, platform: null };
let detailState = null;
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
@@ -9,10 +32,32 @@
return document.getElementById(id);
}
function drawLineChart(canvas, series, color) {
if (!canvas) return;
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function parseCanvasId(id) {
const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
if (!m) return null;
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null };
}
function scopeData(scopeKey) {
if (scopeKey === 'user') return graphScopes.user;
if (scopeKey === 'slug') return graphScopes.slug;
return graphScopes.platform;
}
function drawLineChart(canvas, series, color, opts) {
if (!canvas) return null;
const interactive = opts && opts.interactive;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (!ctx) return null;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
@@ -23,7 +68,7 @@
ctx.fillStyle = '#94a3b8';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('No data', 24, h / 2);
return;
return { points: [], padL: 36, padT: 12, plotW: w - 48, plotH: h - 40, maxY: 1 };
}
let maxY = 0;
points.forEach((p) => {
@@ -31,42 +76,62 @@
if (y > maxY) maxY = y;
});
if (maxY <= 0) maxY = 1;
const padL = 36;
const padR = 12;
const padT = 12;
const padB = 28;
const padL = interactive ? 52 : 36;
const padR = interactive ? 120 : 12;
const padT = 16;
const padB = 32;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
ctx.lineWidth = 1;
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
for (let i = 0; i <= 4; i++) {
const y = padT + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(w - padR, y);
ctx.stroke();
if (interactive) {
const tickVal = Math.round((maxY * (4 - i)) / 4);
ctx.fillText(String(tickVal), 4, y + 4);
}
}
const plotPoints = points.map((p, idx) => {
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
return { x, y, raw: p };
});
ctx.strokeStyle = color || '#3d8bfd';
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, idx) => {
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
plotPoints.forEach((pt, idx) => {
if (idx === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
});
ctx.stroke();
if (interactive) {
plotPoints.forEach((pt) => {
ctx.beginPath();
ctx.fillStyle = color || '#3d8bfd';
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2);
ctx.fill();
});
}
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
if (points.length) {
ctx.fillText(String(points[0].x || ''), padL, h - 8);
const last = points[points.length - 1];
const lx = padL + plotW - 48;
ctx.fillText(String(last.x || ''), lx, h - 8);
ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8);
}
return { points: plotPoints, padL, padT, plotW, plotH, maxY, w, h };
}
function drawPie(canvas, rows) {
@@ -159,14 +224,6 @@
.join('');
}
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderScope(prefix, scope) {
if (!scope) return;
drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]);
@@ -197,29 +254,6 @@
drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version);
}
function renderLinks(links) {
const nav = el('graphs-quick-links');
if (!nav || !links) return;
const items = [
['Hub', links.hub],
['Crashes', links.crashes],
['Tickets', links.tickets],
['Builder', links.build],
['Git', links.git],
];
nav.innerHTML = items
.filter((pair) => pair[1])
.map(
(pair) =>
'<a class="btn btn-secondary graph-quick-link" href="' +
escapeHtml(pair[1]) +
'">' +
escapeHtml(pair[0]) +
'</a>'
)
.join('');
}
function setScopeVisibility(viewer) {
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
const need = section.getAttribute('data-graph-scope');
@@ -231,11 +265,178 @@
});
}
function applyColumns(cols) {
const app = el('graphs-app');
if (!app) return;
const n = Math.min(4, Math.max(1, Number(cols) || 2));
app.style.setProperty('--graphs-columns', String(n));
}
function markGraphBricks() {
document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => {
const canvas = card.querySelector('canvas');
const breakdown = card.querySelector('.graph-breakdown');
const kpi = card.querySelector('.graph-kpi');
if (!canvas && !breakdown && !kpi) return;
card.classList.add('graph-brick--clickable');
card.setAttribute('tabindex', '0');
card.setAttribute('role', 'button');
const title = card.querySelector('h3');
if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent);
});
}
function closeGraphDetail() {
const overlay = el('graph-detail-overlay');
if (overlay) overlay.hidden = true;
detailState = null;
const tip = el('graph-detail-tooltip');
if (tip) tip.hidden = true;
}
function openGraphDetail(card) {
const canvas = card.querySelector('canvas');
const breakdown = card.querySelector('.graph-breakdown');
const kpi = card.querySelector('.graph-kpi');
let parsed = null;
if (canvas && canvas.id) parsed = parseCanvasId(canvas.id);
if (!parsed && breakdown && breakdown.id) parsed = parseCanvasId(breakdown.id);
if (!parsed && kpi && kpi.id) parsed = parseCanvasId(kpi.id);
if (!parsed || !parsed.def) return;
const scope = scopeData(parsed.scopeKey);
if (!scope) return;
const def = parsed.def;
const fieldVal = scope[def.field];
const titleEl = el('graph-detail-title');
const canvasEl = el('graph-detail-canvas');
const bodyEl = el('graph-detail-body');
const overlay = el('graph-detail-overlay');
if (!overlay || !titleEl || !bodyEl) return;
titleEl.textContent = def.title;
bodyEl.innerHTML = '';
if (canvasEl) canvasEl.hidden = true;
if (def.type === 'line' && canvasEl) {
canvasEl.hidden = false;
canvasEl.width = 960;
canvasEl.height = 420;
const meta = drawLineChart(canvasEl, fieldVal, def.color, { interactive: true });
detailState = { type: 'line', meta, def };
attachLineHover(canvasEl, meta, def);
} else if (def.type === 'pie' && canvasEl) {
canvasEl.hidden = false;
canvasEl.width = 720;
canvasEl.height = 400;
drawPie(canvasEl, fieldVal);
detailState = { type: 'pie' };
} else if (def.type === 'breakdown' || def.type === 'toplist') {
const wrap = document.createElement('div');
wrap.className = 'graph-detail-table graph-breakdown';
if (!Array.isArray(fieldVal) || !fieldVal.length) {
wrap.textContent = 'No data';
} else if (def.type === 'toplist') {
renderTopList(wrap, fieldVal);
} else {
renderBreakdownList(wrap, fieldVal);
}
bodyEl.appendChild(wrap);
detailState = { type: def.type };
} else if (def.type === 'kpi') {
const p = document.createElement('p');
p.className = 'graph-kpi graph-detail-kpi';
let text = '—';
if (typeof fieldVal === 'number') {
text = String(fieldVal) + (def.suffix || '');
} else if (fieldVal != null) {
text = String(fieldVal);
}
p.textContent = text;
bodyEl.appendChild(p);
detailState = { type: 'kpi' };
}
overlay.hidden = false;
}
function attachLineHover(canvas, meta, def) {
const tip = el('graph-detail-tooltip');
const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap');
if (!tip || !wrap || !meta || !meta.points.length) return;
canvas.onmousemove = function (ev) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const x = (ev.clientX - rect.left) * scaleX;
let best = null;
let bestD = Infinity;
meta.points.forEach((pt) => {
const d = Math.abs(pt.x - x);
if (d < bestD) {
bestD = d;
best = pt;
}
});
if (!best || bestD > 28) {
tip.hidden = true;
return;
}
const raw = best.raw || {};
tip.hidden = false;
const unit =
def && def.title && def.title.indexOf('kbps') >= 0
? ' kbps'
: def && def.title && def.title.indexOf('duration') >= 0
? ' s'
: '';
tip.textContent = String(raw.x || '') + ': ' + Number(raw.y || 0) + unit;
const wrapRect = wrap.getBoundingClientRect();
tip.style.left = ev.clientX - wrapRect.left + 12 + 'px';
tip.style.top = ev.clientY - wrapRect.top - 8 + 'px';
};
canvas.onmouseleave = function () {
tip.hidden = true;
};
}
function bootGraphs() {
const app = el('graphs-app');
if (!app) return;
const status = el('graphs-status');
const daysSel = el('graphs-days');
const colsSel = el('graphs-columns');
const overlay = el('graph-detail-overlay');
if (overlay) {
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) closeGraphDetail();
});
const closeBtn = el('graph-detail-close');
if (closeBtn) closeBtn.addEventListener('click', closeGraphDetail);
}
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') closeGraphDetail();
});
if (colsSel) {
applyColumns(colsSel.value);
colsSel.addEventListener('change', () => applyColumns(colsSel.value));
} else {
applyColumns(2);
}
app.addEventListener('click', (ev) => {
const card = ev.target.closest('.graph-brick--clickable');
if (card && app.contains(card)) openGraphDetail(card);
});
app.addEventListener('keydown', (ev) => {
const card = ev.target.closest('.graph-brick--clickable');
if (card && (ev.key === 'Enter' || ev.key === ' ')) {
ev.preventDefault();
openGraphDetail(card);
}
});
function load() {
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
@@ -256,12 +457,16 @@
}
const data = payload.data || {};
const viewer = data.viewer || 'user';
renderLinks(data.links || {});
graphScopes.user = data.user || null;
graphScopes.slug = data.slug_admin || null;
graphScopes.platform = data.platform_admin || null;
setScopeVisibility(viewer);
renderScope('user', data.user);
if (data.slug_admin) renderScope('slug', data.slug_admin);
if (data.platform_admin) renderScope('platform', data.platform_admin);
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd';
renderScope('user', graphScopes.user);
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
markGraphBricks();
const cols = colsSel && colsSel.value ? colsSel.value : '2';
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
};
xhr.onerror = function () {
status.textContent = 'Network error';

View File

@@ -102,7 +102,7 @@
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
aria-label="Remote access"
title="Remote access">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-icon nav-icon--remote" aria-hidden="true"></span>
<span class="nav-label">Remote access</span>
</a>
</li>
@@ -123,7 +123,7 @@
class="nav-link"
aria-label="Builder"
title="Builder">
<span class="nav-icon nav-icon--git" aria-hidden="true"></span>
<span class="nav-icon nav-icon--builder" aria-hidden="true"></span>
<span class="nav-label">Builder</span>
</a>
</li>
@@ -303,9 +303,17 @@
<option value="30">30d</option>
</select>
</label>
<label class="toolbar-select">
<span>Columns</span>
<select id="graphs-columns" aria-label="Graph grid columns">
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="4">4</option>
</select>
</label>
</div>
</div>
<nav id="graphs-quick-links" class="graphs-quick-links" aria-label="Related services"></nav>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope" data-graph-scope="user">
@@ -350,12 +358,19 @@
</div>
</section>
<p class="muted graphs-footnote">
Direct DB metrics (no ETL). Ingest:
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
· Read:
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
</p>
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
<header class="graph-detail-header">
<h2 id="graph-detail-title">Graph</h2>
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
</header>
<div class="graph-detail-canvas-wrap">
<canvas id="graph-detail-canvas" hidden></canvas>
<div id="graph-detail-body" class="graph-detail-body"></div>
<p id="graph-detail-tooltip" class="graph-detail-tooltip" hidden></p>
</div>
</div>
</div>
</div>
<?php elseif (($view ?? '') === 'remote_access'): ?>
<div id="remote-access-app" class="reports-app">
@@ -371,19 +386,8 @@
</label>
</div>
</div>
<nav class="graphs-quick-links" aria-label="Related services">
<a href="<?= h(Auth::basePath()) ?>/?view=home">Console home</a>
<a href="<?= h(Auth::basePath()) ?>/?view=reports">Crash reports</a>
<a href="<?= h(Auth::basePath()) ?>/?view=tickets">Tickets</a>
<a href="/app/androidcast_project/graphs/">Graphs</a>
<a href="/app/androidcast_project/build/">Builder</a>
<a href="/app/androidcast_project/">Hub</a>
</nav>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Device poll: <code><?= h(Auth::basePath()) ?>/api/heartbeat.php</code> (<code>type: ra</code>).
No nginx changes required — same crashes vhost. Configure <code>remote_access.*</code> in BE <code>config.php</code>.
</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Whitelisted devices</h2>

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* Horizontal service links with the same icons as the left nav (8px gap).
* @var string|null $extra_class optional class on <nav>
*/
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
$items = [
['Console home', $bp . '/?view=home', 'nav-icon--home'],
['Crash reports', $bp . '/?view=reports', 'nav-icon--reports'],
['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'],
['Graphs', $proj . '/graphs/', 'nav-icon--graphs'],
['Builder', $proj . '/build/', 'nav-icon--builder'],
['Hub', $proj . '/', 'nav-icon--home'],
];
?>
<nav class="<?= h($navClass) ?>" aria-label="Related services">
<?php foreach ($items as [$label, $href, $icon]): ?>
<a href="<?= h($href) ?>" class="console-quick-link">
<span class="nav-icon <?= h($icon) ?>" aria-hidden="true"></span>
<span><?= h($label) ?></span>
</a>
<?php endforeach; ?>
</nav>