feat: live Docker data — containers, services, logs, metrics via dockerode
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- Backend: rewrote containers/services/logs/metrics routes to use dockerode - docker-compose: mount /var/run/docker.sock, run backend as root - .drone.yml: sync docker-compose.yml from Gitea on deploy - Frontend: Containers page shows real data with wired start/stop/restart - Frontend: Services page shows Docker Compose stacks with health status - Frontend: Metrics page adds disk (docker df) and containers cards + chart legend - Frontend: Logs page replaces text input with container dropdown + auto-refresh
This commit is contained in:
56
backend/src/middleware/oidcAuth.ts
Normal file
56
backend/src/middleware/oidcAuth.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
const ISSUER = process.env.OIDC_ISSUER || 'https://auth.jiosii.com/oidc';
|
||||
const JWKS_URL = `${ISSUER}/jwks`;
|
||||
const AUDIENCE = 'server-manager';
|
||||
|
||||
// Cache the JWKS remote keyset (jose handles internal caching with TTL)
|
||||
const jwks = createRemoteJWKSet(new URL(JWKS_URL), {
|
||||
cacheMaxAge: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Bearer token required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
});
|
||||
|
||||
req.user = {
|
||||
sub: payload.sub as string,
|
||||
email: payload.email as string | undefined,
|
||||
name: payload.name as string | undefined,
|
||||
preferred_username: payload.preferred_username as string | undefined,
|
||||
role: payload.role as string | undefined,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
93
backend/src/routes/auth.ts
Normal file
93
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ISSUER = process.env.OIDC_ISSUER || 'https://auth.jiosii.com/oidc';
|
||||
const CLIENT_ID = 'server-manager';
|
||||
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '';
|
||||
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://sm.jiosii.com/callback';
|
||||
|
||||
// POST /api/auth/exchange
|
||||
// Exchanges an authorization code + PKCE verifier for tokens.
|
||||
// Keeps client_secret server-side for security.
|
||||
router.post('/exchange', async (req: Request, res: Response) => {
|
||||
const { code, code_verifier } = req.body as { code?: string; code_verifier?: string };
|
||||
|
||||
if (!code || !code_verifier) {
|
||||
res.status(400).json({ error: 'code and code_verifier are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch(`${ISSUER}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code_verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await tokenRes.json() as Record<string, unknown>;
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
res.status(400).json({ error: body.error, error_description: body.error_description });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return tokens to the frontend — access_token used as Bearer on subsequent API calls
|
||||
res.json({
|
||||
access_token: body.access_token,
|
||||
id_token: body.id_token,
|
||||
expires_in: body.expires_in,
|
||||
token_type: body.token_type,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('token exchange error', err);
|
||||
res.status(502).json({ error: 'Token exchange failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh
|
||||
router.post('/refresh', async (req: Request, res: Response) => {
|
||||
const { refresh_token } = req.body as { refresh_token?: string };
|
||||
if (!refresh_token) {
|
||||
res.status(400).json({ error: 'refresh_token required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch(`${ISSUER}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await tokenRes.json() as Record<string, unknown>;
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
res.status(401).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
access_token: body.access_token,
|
||||
id_token: body.id_token,
|
||||
expires_in: body.expires_in,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('refresh error', err);
|
||||
res.status(502).json({ error: 'Refresh failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,51 +1,81 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
const router = Router();
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
async function findContainer(nameOrId: string) {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list.find(c =>
|
||||
c.Id.startsWith(nameOrId) ||
|
||||
c.Names.some(n => n.replace(/^\//, '') === nameOrId)
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/containers - List all containers
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement container listing via Docker API
|
||||
res.json({ containers: [] });
|
||||
const list = await docker.listContainers({ all: true });
|
||||
const containers = list.map(c => ({
|
||||
id: c.Id.slice(0, 12),
|
||||
full_id: c.Id,
|
||||
name: c.Names[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12),
|
||||
image: c.Image,
|
||||
status: c.Status,
|
||||
state: c.State,
|
||||
ports: c.Ports.map(p => ({
|
||||
private: p.PrivatePort,
|
||||
public: p.PublicPort,
|
||||
type: p.Type,
|
||||
})).filter(p => p.public),
|
||||
created: new Date(c.Created * 1000).toISOString(),
|
||||
compose_project: c.Labels?.['com.docker.compose.project'] ?? null,
|
||||
compose_service: c.Labels?.['com.docker.compose.service'] ?? null,
|
||||
}));
|
||||
res.json({ containers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch containers' });
|
||||
console.error('Containers error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch containers. Is the Docker socket mounted?' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/containers/:id - Get container details
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement container details
|
||||
res.json({ id: req.params.id });
|
||||
const found = await findContainer(req.params.id);
|
||||
if (!found) return res.status(404).json({ error: 'Container not found' });
|
||||
const info = await docker.getContainer(found.Id).inspect();
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch container' });
|
||||
res.status(404).json({ error: 'Container not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/containers/:id/start - Start container
|
||||
router.post('/:id/start', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement container start
|
||||
const found = await findContainer(req.params.id);
|
||||
if (!found) return res.status(404).json({ error: 'Container not found' });
|
||||
await docker.getContainer(found.Id).start();
|
||||
res.json({ status: 'started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start container' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/containers/:id/stop - Stop container
|
||||
router.post('/:id/stop', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement container stop
|
||||
const found = await findContainer(req.params.id);
|
||||
if (!found) return res.status(404).json({ error: 'Container not found' });
|
||||
await docker.getContainer(found.Id).stop();
|
||||
res.json({ status: 'stopped' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stop container' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/containers/:id/restart - Restart container
|
||||
router.post('/:id/restart', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement container restart
|
||||
const found = await findContainer(req.params.id);
|
||||
if (!found) return res.status(404).json({ error: 'Container not found' });
|
||||
await docker.getContainer(found.Id).restart();
|
||||
res.json({ status: 'restarted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to restart container' });
|
||||
|
||||
@@ -4,14 +4,23 @@ import containersRouter from './containers';
|
||||
import servicesRouter from './services';
|
||||
import logsRouter from './logs';
|
||||
import metricsRouter from './metrics';
|
||||
import authRouter from './auth';
|
||||
import { requireAuth } from '../middleware/oidcAuth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', (req, res) => {
|
||||
// Public — health check and token exchange do not require auth
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() });
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
// Public — OIDC token exchange (code → tokens, keeps client_secret server-side)
|
||||
router.use('/auth', authRouter);
|
||||
|
||||
// Protected — all resource routes require a valid OIDC access token
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json({
|
||||
message: 'Server Manager API',
|
||||
version: '1.0.0',
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
const router = Router();
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
function demuxDockerBuffer(buffer: Buffer): string[] {
|
||||
const lines: string[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const size = buffer.readUInt32BE(offset + 4);
|
||||
offset += 8;
|
||||
if (size > 0 && offset + size <= buffer.length) {
|
||||
const chunk = buffer.slice(offset, offset + size).toString('utf8');
|
||||
lines.push(...chunk.split('\n').filter(l => l.trim()));
|
||||
offset += size;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// GET /api/logs/:type/:id - Get logs for a specific resource
|
||||
router.get('/:type/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
const { lines = 100, follow = false } = req.query;
|
||||
|
||||
// TODO: Implement log streaming (container logs, service logs, system logs)
|
||||
res.json({
|
||||
const lines = parseInt(req.query.lines as string) || 100;
|
||||
|
||||
if (type !== 'container') {
|
||||
return res.json({ type, id, logs: [] });
|
||||
}
|
||||
|
||||
const list = await docker.listContainers({ all: true });
|
||||
const target = list.find(c =>
|
||||
c.Id.startsWith(id) ||
|
||||
c.Names.some(n => n.replace(/^\//, '') === id)
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: `Container '${id}' not found` });
|
||||
}
|
||||
|
||||
const logBuffer = await docker.getContainer(target.Id).logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: lines,
|
||||
timestamps: true,
|
||||
}) as unknown as Buffer;
|
||||
|
||||
const logLines = demuxDockerBuffer(logBuffer);
|
||||
res.json({
|
||||
type,
|
||||
id,
|
||||
logs: [],
|
||||
lines: parseInt(lines as string)
|
||||
logs: logLines,
|
||||
container: target.Names[0]?.replace(/^\//, ''),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logs error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch logs' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import os from 'os';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
const router = Router();
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
// GET /api/metrics - Get system metrics
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
@@ -14,15 +16,38 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
const freeMem = os.freemem();
|
||||
const usedMem = totalMem - freeMem;
|
||||
|
||||
// Docker disk usage and container counts via Docker API
|
||||
let disk = { used: 0, total: 116, unit: 'GB' };
|
||||
let containerStats = { running: 0, total: 0 };
|
||||
try {
|
||||
const [dfData, containers] = await Promise.all([
|
||||
docker.df() as any,
|
||||
docker.listContainers({ all: true }),
|
||||
]);
|
||||
const totalBytes =
|
||||
(dfData.LayersSize ?? 0) +
|
||||
(dfData.Volumes ?? []).reduce((sum: number, v: any) => sum + (v.UsageData?.Size ?? 0), 0) +
|
||||
(dfData.BuildCache ?? []).reduce((sum: number, b: any) => sum + (b.Size ?? 0), 0);
|
||||
disk = {
|
||||
used: Math.round((totalBytes / 1024 / 1024 / 1024) * 10) / 10,
|
||||
total: 116,
|
||||
unit: 'GB',
|
||||
};
|
||||
containerStats = {
|
||||
running: containers.filter(c => c.State === 'running').length,
|
||||
total: containers.length,
|
||||
};
|
||||
} catch { /* Docker not available */ }
|
||||
|
||||
res.json({
|
||||
cpu: { usage: cpuUsage, cores: cpus.length },
|
||||
cpu: { usage: cpuUsage, cores: cpus.length, load1: parseFloat(loadAvg.toFixed(2)) },
|
||||
memory: {
|
||||
used: Math.round(usedMem / 1024 / 1024),
|
||||
total: Math.round(totalMem / 1024 / 1024),
|
||||
percentage: Math.round((usedMem / totalMem) * 100),
|
||||
},
|
||||
disk: { used: 0, total: 0, percentage: 0 },
|
||||
network: { rx: 0, tx: 0 },
|
||||
disk,
|
||||
containers: containerStats,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,55 +1,66 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
const router = Router();
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
// GET /api/services - List all services
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement service listing (systemd, docker compose, etc.)
|
||||
res.json({ services: [] });
|
||||
const list = await docker.listContainers({ all: true });
|
||||
|
||||
const projects = new Map<string, any[]>();
|
||||
const standalone: any[] = [];
|
||||
|
||||
for (const c of list) {
|
||||
const project = c.Labels?.['com.docker.compose.project'];
|
||||
const service = c.Labels?.['com.docker.compose.service'];
|
||||
if (project && service) {
|
||||
if (!projects.has(project)) projects.set(project, []);
|
||||
projects.get(project)!.push({
|
||||
name: service,
|
||||
container: c.Names[0]?.replace(/^\//, ''),
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
image: c.Image,
|
||||
});
|
||||
} else {
|
||||
standalone.push({
|
||||
name: c.Names[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12),
|
||||
container: c.Names[0]?.replace(/^\//, ''),
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
image: c.Image,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stacks = [
|
||||
...Array.from(projects.entries()).map(([project, svcs]) => ({
|
||||
id: project,
|
||||
name: project,
|
||||
type: 'compose',
|
||||
services: svcs,
|
||||
running: svcs.filter(s => s.state === 'running').length,
|
||||
total: svcs.length,
|
||||
status: svcs.every(s => s.state === 'running') ? 'healthy'
|
||||
: svcs.some(s => s.state === 'running') ? 'degraded' : 'stopped',
|
||||
})),
|
||||
...(standalone.length > 0 ? [{
|
||||
id: 'standalone',
|
||||
name: 'standalone',
|
||||
type: 'standalone',
|
||||
services: standalone,
|
||||
running: standalone.filter(s => s.state === 'running').length,
|
||||
total: standalone.length,
|
||||
status: 'unknown',
|
||||
}] : []),
|
||||
];
|
||||
|
||||
res.json({ services: stacks });
|
||||
} catch (error) {
|
||||
console.error('Services error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch services' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/services/:name - Get service status
|
||||
router.get('/:name', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement service status check
|
||||
res.json({ name: req.params.name, status: 'unknown' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch service status' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/services/:name/start - Start service
|
||||
router.post('/:name/start', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement service start
|
||||
res.json({ status: 'started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start service' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/services/:name/stop - Stop service
|
||||
router.post('/:name/stop', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement service stop
|
||||
res.json({ status: 'stopped' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stop service' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/services/:name/restart - Restart service
|
||||
router.post('/:name/restart', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Implement service restart
|
||||
res.json({ status: 'restarted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to restart service' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user