feat(settings): add about system information

This commit is contained in:
Deploy
2026-08-25 22:08:20 +02:00
parent 3917c8d6de
commit ed031f8a94
14 changed files with 255 additions and 27 deletions

View File

@@ -1 +1 @@
0.1.0
0.2.0

View File

@@ -1,12 +1,12 @@
{
"name": "@mercadodevida/admin",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mercadodevida/admin",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@lexical/history": "^0.49.0",
"@lexical/html": "^0.49.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@mercadodevida/admin",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { settingsApi, type StoreSettings } from '@/lib/api-client';
import { settingsApi, type AboutInfo, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings;
@@ -12,6 +12,7 @@ const TABS = [
{ id: 'ai', label: 'IA para SEO', icon: '✨' },
{ id: 'smtp', label: 'SMTP / Email', icon: '✉️' },
{ id: 'couriers', label: 'Transportistas', icon: '🚚' },
{ id: 'about', label: 'About', icon: '' },
] as const;
type TabId = (typeof TABS)[number]['id'];
@@ -24,12 +25,28 @@ export default function SettingsPage() {
const [err, setErr] = useState('');
const [tab, setTab] = useState<TabId>('general');
const [couriersText, setCouriersText] = useState('');
const [about, setAbout] = useState<AboutInfo | null>(null);
const [aboutLoading, setAboutLoading] = useState(false);
const loadAbout = async () => {
setAboutLoading(true);
try {
setAbout(await settingsApi.about());
} catch {
setErr('Error al cargar información del sistema');
} finally {
setAboutLoading(false);
}
};
useEffect(() => {
Promise.all([
settingsApi.get().then(d => {
setData(d); setForm(d);
setCouriersText((d.couriers ?? []).join('\n'));
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}),
loadAbout(),
]).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}, []);
const handleCouriersChange = (text: string) => {
@@ -38,6 +55,22 @@ export default function SettingsPage() {
setForm(f => f ? { ...f, couriers: list } : f);
};
const formatDuration = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const infoRow = (label: string, value: string | number) => (
<div className="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">{label}</p>
<p className="mt-1 break-words text-sm font-semibold text-gray-800">{value}</p>
</div>
);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
@@ -228,6 +261,81 @@ export default function SettingsPage() {
</>
)}
{tab === 'about' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-gray-800">About / Sistema</h2>
<p className="text-xs text-gray-400 mt-0.5">Versiones de servicios e información segura del servidor.</p>
</div>
<button
type="button"
onClick={() => void loadAbout()}
disabled={aboutLoading}
className="rounded-xl border border-gray-300 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-white disabled:opacity-50"
>
{aboutLoading ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
<div className="p-6 space-y-6">
{about ? (
<>
<div className="rounded-2xl border border-[#2D6A4F]/20 bg-[#2D6A4F]/5 p-5">
<p className="text-xs font-medium uppercase tracking-wide text-[#2D6A4F]">Versión producto</p>
<p className="mt-1 text-3xl font-bold text-[#1B4332]">v{about.productVersion}</p>
<p className="mt-2 text-xs text-gray-500">Generado: {new Date(about.generatedAt).toLocaleString('es-ES')}</p>
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servicios</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{about.services.map(service => (
<div key={service.id} className="rounded-xl border border-gray-200 p-4">
<p className="text-sm font-semibold text-gray-900">{service.name}</p>
<p className="mt-1 font-mono text-xs text-gray-500">{service.id}</p>
<p className="mt-3 inline-flex rounded-full bg-gray-100 px-2.5 py-1 font-mono text-xs font-semibold text-gray-700">v{service.version}</p>
</div>
))}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servidor</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('Host', about.server.hostname)}
{infoRow('Sistema', `${about.server.platform} ${about.server.arch}`)}
{infoRow('Kernel / Release', about.server.release)}
{infoRow('Node.js', about.server.nodeVersion)}
{infoRow('PID backend', about.server.pid)}
{infoRow('Zona horaria', about.server.timezone)}
{infoRow('Uptime host', formatDuration(about.server.uptimeSeconds))}
{infoRow('Uptime proceso', formatDuration(about.server.processUptimeSeconds))}
{infoRow('Arranque proceso', new Date(about.server.processStartedAt).toLocaleString('es-ES'))}
{infoRow('CPU cores', about.server.cpuCount)}
{infoRow('RAM total', `${about.server.totalMemoryMb} MB`)}
{infoRow('RAM libre', `${about.server.freeMemoryMb} MB`)}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Proceso y base de datos</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('RSS proceso', `${about.server.processMemoryMb.rss} MB`)}
{infoRow('Heap usado', `${about.server.processMemoryMb.heapUsed} MB`)}
{infoRow('Heap total', `${about.server.processMemoryMb.heapTotal} MB`)}
{infoRow('PostgreSQL', about.database.postgresVersion)}
</div>
</section>
</>
) : (
<div className="rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-500">
{aboutLoading ? 'Cargando información del sistema…' : 'No se pudo cargar la información del sistema.'}
</div>
)}
</div>
</>
)}
{tab === 'footer' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
@@ -244,12 +352,14 @@ export default function SettingsPage() {
</>
)}
{tab !== 'about' && (
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
<button type="submit" disabled={saving || !form}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
)}
</form>
</div>
)}

View File

@@ -395,7 +395,31 @@ export interface StoreSettings {
couriers?: string[];
}
export interface AboutInfo {
productVersion: string;
generatedAt: string;
services: Array<{ id: string; name: string; version: string }>;
server: {
hostname: string;
platform: string;
arch: string;
release: string;
uptimeSeconds: number;
processUptimeSeconds: number;
processStartedAt: string;
nodeVersion: string;
pid: number;
cpuCount: number;
totalMemoryMb: number;
freeMemoryMb: number;
processMemoryMb: { rss: number; heapUsed: number; heapTotal: number };
timezone: string;
};
database: { postgresVersion: string };
}
export const settingsApi = {
get: () => api.get<StoreSettings>('/api/admin/settings'),
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
about: () => api.get<AboutInfo>('/api/admin/about'),
};

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-pos",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-pos",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"next": "^16.3.1",
"react": "^19.2.8",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-pos",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",

View File

@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-backend",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-backend",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-backend",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "mercadodevida vNext backend - modular monolith skeleton",

View File

@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
import type { FastifySchema } from 'fastify';
import type pg from 'pg';
@@ -62,6 +65,50 @@ export function parseCouriers(raw: string | undefined | null): string[] {
}
}
type ServiceVersion = {
id: string;
name: string;
version: string;
};
function readTextIfExists(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf8').trim();
} catch {
return null;
}
}
function readPackageVersion(id: string, label: string, packagePath: string): ServiceVersion {
try {
const raw = readFileSync(packagePath, 'utf8');
const pkg = JSON.parse(raw) as { name?: string; version?: string };
return {
id,
name: pkg.name ?? label,
version: pkg.version ?? 'unknown',
};
} catch {
return { id, name: label, version: 'unknown' };
}
}
function collectServiceVersions(): { productVersion: string; services: ServiceVersion[] } {
const root = process.cwd();
return {
productVersion: readTextIfExists(path.join(root, 'VERSION')) ?? 'unknown',
services: [
readPackageVersion('backend', 'Backend API', path.join(root, 'package.json')),
readPackageVersion('admin', 'Admin', path.join(root, 'apps/admin/package.json')),
readPackageVersion('tpv', 'TPV', path.join(root, 'apps/pos/package.json')),
readPackageVersion('frontend', 'Frontend', path.join(root, 'frontend/package.json')),
readPackageVersion('storefront', 'Storefront SEO', path.join(root, 'storefront/package.json')),
],
};
}
const PROCESS_STARTED_AT = new Date();
const SETTING_KEYS: Record<string, string> = {
storeName: 'store_name',
storeTagline: 'store_tagline',
@@ -96,6 +143,53 @@ export async function registerStoreSettingsRoutes(
app: FastifyInstance,
deps: StoreSettingsRoutesDeps,
): Promise<void> {
// GET /admin/about — versions and safe server diagnostics for admin UI
const getAboutSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Get service versions and server info (admin)',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/about', { schema: getAboutSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { productVersion, services } = collectServiceVersions();
const dbVersion = await deps.pool
.query<{ server_version: string }>('SHOW server_version')
.then((result) => result.rows[0]?.server_version ?? 'unknown')
.catch(() => 'unavailable');
const memory = process.memoryUsage();
return reply.send({
productVersion,
generatedAt: new Date().toISOString(),
services,
server: {
hostname: os.hostname(),
platform: os.platform(),
arch: os.arch(),
release: os.release(),
uptimeSeconds: Math.floor(os.uptime()),
processUptimeSeconds: Math.floor(process.uptime()),
processStartedAt: PROCESS_STARTED_AT.toISOString(),
nodeVersion: process.version,
pid: process.pid,
cpuCount: os.cpus().length,
totalMemoryMb: Math.round(os.totalmem() / 1024 / 1024),
freeMemoryMb: Math.round(os.freemem() / 1024 / 1024),
processMemoryMb: {
rss: Math.round(memory.rss / 1024 / 1024),
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
database: {
postgresVersion: dbVersion,
},
});
});
// GET /admin/settings — fetch all settings
const getSettingsSchema: FastifySchema = {
tags: ['Admin'],

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-storefront",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-storefront",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tailwindcss/postcss": "^4.1.17",
"next": "^16.0.5",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-storefront",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "mercadodevida customer storefront shell",