feat(devops): admin-only info, public version, dep checks, role hardening
Backend: - /api/devops/info now requires admin role (was: any authenticated user). Exposes env keys, dep checks, and socket counts — admin-only by design. - New /api/devops/version (public, no auth) returns build SHA/branch/image for ops/CI rollback verification. - Dep checks: live ping for Cosmos (trading_users) and platform-service. - Service version read dynamically via readServiceVersion(import.meta.url) — no more hardcoded '0.1.0'. - extra: socketIoConnections + tradingApiUrl for runtime debugging. - saveCurrentUserProfile no longer accepts client-supplied role — prevents drift with platform JWT (which is authoritative). Web: - DevOps tab is now admin-only (gated behind isAdmin like Bot Config and Admin Panel). Both the section list and content render are guarded. - Service version baked into bundle via Vite `define` (__WEB_SERVICE_VERSION__) read from web/package.json — no more hardcoded VERSION constant. - Bumps @bytelyst/devops dep to ^0.1.2. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
74400fda70
commit
4763a9a9d1
@ -77,7 +77,7 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"@bytelyst/telemetry-client": "*",
|
"@bytelyst/telemetry-client": "*",
|
||||||
"@bytelyst/devops": "^0.1.1"
|
"@bytelyst/devops": "^0.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { createServer } from 'http';
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { collectDevopsInfo } from '@bytelyst/devops/server';
|
import { collectDevopsInfo, dependencyCheck, getBuildInfo, httpDependencyCheck, readServiceVersion } from '@bytelyst/devops/server';
|
||||||
|
import { getContainer } from '@bytelyst/cosmos';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -2365,13 +2366,31 @@ export class ApiServer {
|
|||||||
res.json(flags);
|
res.json(flags);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DevOps info: build, runtime, config (auth required) ──────────────
|
// ── DevOps version: PUBLIC, no auth (build SHA + branch + image) ─────
|
||||||
this.app.get('/api/devops/info', this.requireAuth, async (_req, res) => {
|
// Useful for ops/CI health checks and rollback verification.
|
||||||
|
this.app.get('/api/devops/version', (_req, res) => {
|
||||||
|
res.json(getBuildInfo());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DevOps info: full payload (admin-only — exposes env keys, deps) ──
|
||||||
|
this.app.get('/api/devops/info', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const info = await collectDevopsInfo({
|
const info = await collectDevopsInfo({
|
||||||
productId: config.PRODUCT_ID || 'invttrdg',
|
productId: config.PRODUCT_ID || 'invttrdg',
|
||||||
serviceName: 'trading-backend',
|
serviceName: 'trading-backend',
|
||||||
serviceVersion: process.env.npm_package_version || '0.1.0',
|
serviceVersion: readServiceVersion(import.meta.url),
|
||||||
|
dependencyChecks: [
|
||||||
|
() => dependencyCheck('cosmos', async () => {
|
||||||
|
// Lightweight ping: read database properties.
|
||||||
|
const c = getContainer('trading_users');
|
||||||
|
await c.items.query('SELECT TOP 1 c.id FROM c').fetchNext();
|
||||||
|
}),
|
||||||
|
() => httpDependencyCheck('platform-service', `${config.PLATFORM_API_URL}/health`),
|
||||||
|
],
|
||||||
|
extra: {
|
||||||
|
socketIoConnections: this.io?.engine?.clientsCount ?? 0,
|
||||||
|
tradingApiUrl: config.PLATFORM_API_URL,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
res.json(info);
|
res.json(info);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -719,7 +719,10 @@ export async function saveCurrentUserProfile(
|
|||||||
...input,
|
...input,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
email: String(input.email ?? existing.email ?? fallback.email ?? ''),
|
email: String(input.email ?? existing.email ?? fallback.email ?? ''),
|
||||||
role: String(input.role ?? existing.role ?? fallback.role ?? 'member'),
|
// Role is intentionally NOT persisted from client input — JWT role is the
|
||||||
|
// authoritative source. We keep the existing stored role for backward
|
||||||
|
// compatibility but it's overridden on read at the API layer.
|
||||||
|
role: String(existing.role ?? fallback.role ?? 'member'),
|
||||||
first_name: String(input.first_name ?? existing.first_name ?? fallback.first_name ?? ''),
|
first_name: String(input.first_name ?? existing.first_name ?? fallback.first_name ?? ''),
|
||||||
last_name: String(input.last_name ?? existing.last_name ?? fallback.last_name ?? ''),
|
last_name: String(input.last_name ?? existing.last_name ?? fallback.last_name ?? ''),
|
||||||
trade_enable: Boolean(input.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true),
|
trade_enable: Boolean(input.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true),
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"@bytelyst/devops": "^0.1.1"
|
"@bytelyst/devops": "^0.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import { DevopsPanel, type DevopsInfo } from '@bytelyst/devops/ui';
|
|||||||
import { getPlatformAccessToken } from '../lib/authSession';
|
import { getPlatformAccessToken } from '../lib/authSession';
|
||||||
import { tradingRuntime } from '../lib/runtime';
|
import { tradingRuntime } from '../lib/runtime';
|
||||||
|
|
||||||
const VERSION = '0.1.0';
|
// Injected by Vite at build time (see web/vite.config.ts → define).
|
||||||
|
declare const __WEB_SERVICE_VERSION__: string;
|
||||||
|
const VERSION = typeof __WEB_SERVICE_VERSION__ !== 'undefined' ? __WEB_SERVICE_VERSION__ : '0.0.0';
|
||||||
|
|
||||||
async function fetchBackendInfo(): Promise<DevopsInfo> {
|
async function fetchBackendInfo(): Promise<DevopsInfo> {
|
||||||
const token = await getPlatformAccessToken();
|
const token = await getPlatformAccessToken();
|
||||||
|
|||||||
@ -17,13 +17,13 @@ export function SettingsView() {
|
|||||||
'Account',
|
'Account',
|
||||||
...(isAdmin ? ['Bot Config' as SettingsSection] : []),
|
...(isAdmin ? ['Bot Config' as SettingsSection] : []),
|
||||||
...(isAdmin ? ['Admin Panel' as SettingsSection] : []),
|
...(isAdmin ? ['Admin Panel' as SettingsSection] : []),
|
||||||
'DevOps',
|
...(isAdmin ? ['DevOps' as SettingsSection] : []),
|
||||||
];
|
];
|
||||||
const requestedSection = searchParams.get('section');
|
const requestedSection = searchParams.get('section');
|
||||||
const initialSection = useMemo<SettingsSection>(() => {
|
const initialSection = useMemo<SettingsSection>(() => {
|
||||||
if (requestedSection === 'Bot Config' && isAdmin) return 'Bot Config';
|
if (requestedSection === 'Bot Config' && isAdmin) return 'Bot Config';
|
||||||
if (requestedSection === 'Admin Panel' && isAdmin) return 'Admin Panel';
|
if (requestedSection === 'Admin Panel' && isAdmin) return 'Admin Panel';
|
||||||
if (requestedSection === 'DevOps') return 'DevOps';
|
if (requestedSection === 'DevOps' && isAdmin) return 'DevOps';
|
||||||
return 'Account';
|
return 'Account';
|
||||||
}, [requestedSection, isAdmin]);
|
}, [requestedSection, isAdmin]);
|
||||||
const [section, setSection] = useState<SettingsSection>(initialSection);
|
const [section, setSection] = useState<SettingsSection>(initialSection);
|
||||||
@ -64,7 +64,7 @@ export function SettingsView() {
|
|||||||
{section === 'Account' && <SettingsTab botState={botState} />}
|
{section === 'Account' && <SettingsTab botState={botState} />}
|
||||||
{section === 'Bot Config' && isAdmin && <ConfigTab />}
|
{section === 'Bot Config' && isAdmin && <ConfigTab />}
|
||||||
{section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />}
|
{section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />}
|
||||||
{section === 'DevOps' && <DevOpsTab />}
|
{section === 'DevOps' && isAdmin && <DevOpsTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
|||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Read service version from package.json so DevOps panel reflects the real version.
|
||||||
|
const webPackageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')) as { version?: string };
|
||||||
|
const webServiceVersion = webPackageJson.version || '0.0.0';
|
||||||
|
|
||||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||||
const monacoEditorPath = path.resolve(__dirname, 'node_modules/monaco-editor');
|
const monacoEditorPath = path.resolve(__dirname, 'node_modules/monaco-editor');
|
||||||
const workspaceRoot = path.resolve(__dirname, '..', '..');
|
const workspaceRoot = path.resolve(__dirname, '..', '..');
|
||||||
@ -32,6 +36,9 @@ function bytelystAlias(pkg: string): string {
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__WEB_SERVICE_VERSION__: JSON.stringify(webServiceVersion),
|
||||||
|
},
|
||||||
// Shared files (../shared/*.ts) live outside web/ so Vite resolves their imports
|
// Shared files (../shared/*.ts) live outside web/ so Vite resolves their imports
|
||||||
// from the repo root where @bytelyst/* are not installed. Redirect all @bytelyst/*
|
// from the repo root where @bytelyst/* are not installed. Redirect all @bytelyst/*
|
||||||
// imports first to web/node_modules, then fall back to the monorepo vendor/ dir.
|
// imports first to web/node_modules, then fall back to the monorepo vendor/ dir.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user