diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 2d60326..dd7f935 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -7,27 +7,39 @@ on: branches: [master] jobs: - # ── Build & push Docker image ──────────────────────────────────────────── - build: + # ── Type check ─────────────────────────────────────────────────────────────── + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run build + + # ── Build & push Docker image ───────────────────────────────────────────── + docker: + needs: typecheck + if: github.ref == 'refs/heads/master' && github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Login to Forgejo Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.csrx.ru -u ${{ github.actor }} --password-stdin + + - name: Build & push run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - docker login git.csrx.ru -u ${{ github.actor }} --password-stdin + docker build -t git.csrx.ru/jack/discord-bot:latest . + docker push git.csrx.ru/jack/discord-bot:latest - - name: Build image - run: docker build -t git.csrx.ru/jack/discord-bot:latest . - - - name: Push image - run: docker push git.csrx.ru/jack/discord-bot:latest - - # ── Deploy to server (master only) ─────────────────────────────────────── + # ── Deploy to server ─────────────────────────────────────────────────────── deploy: - needs: build - if: github.ref == 'refs/heads/master' && github.event_name == 'push' + needs: docker runs-on: ubuntu-latest steps: - name: Configure SSH @@ -38,7 +50,7 @@ jobs: ssh-keyscan -p 22 87.249.49.32 >> ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts - - name: Pull & restart on server + - name: Pull & restart run: | ssh deploy@87.249.49.32 " docker pull git.csrx.ru/jack/discord-bot:latest && diff --git a/.gitignore b/.gitignore index b6cf5f0..8a6488f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -__pycache__/ -*.pyc +node_modules/ +dist/ .env +*.js.map diff --git a/Dockerfile b/Dockerfile index 77c9573..00784b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,22 @@ -FROM python:3.12-slim - +# ── Build stage ────────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder WORKDIR /app -# Install dependencies first (cached layer) -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY package*.json ./ +RUN npm ci -# Copy bot code -COPY bot.py . +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm run build -CMD ["python", "-u", "bot.py"] +# ── Production stage ────────────────────────────────────────────────────────── +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist + +CMD ["node", "dist/index.js"] diff --git a/bot.py b/bot.py deleted file mode 100644 index 21bc228..0000000 --- a/bot.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""Discord bot for csrx.ru infrastructure management.""" - -import os -import asyncio -import logging -from datetime import datetime, timezone - -import discord -from discord import app_commands -import docker -import httpx - -# --------------------------------------------------------------------------- -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s", -) -log = logging.getLogger(__name__) - -DISCORD_TOKEN = os.environ["DISCORD_TOKEN"] -FORGEJO_TOKEN = os.environ["FORGEJO_TOKEN"] -FORGEJO_URL = os.environ.get("FORGEJO_URL", "https://git.csrx.ru") -FORGEJO_REPO = os.environ.get("FORGEJO_REPO", "jack/infra") -PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://prometheus:9090") - -# --------------------------------------------------------------------------- -intents = discord.Intents.default() -client = discord.Client(intents=intents) -tree = app_commands.CommandTree(client) - -docker_client = docker.DockerClient(base_url="unix:///var/run/docker.sock") - - -# --------------------------------------------------------------------------- -# Helpers - -def _icon(status: str) -> str: - if status == "running": return "🟢" - if status == "paused": return "🟡" - return "🔴" - -def _trunc(text: str, limit: int = 1900) -> str: - text = text.strip() - if len(text) <= limit: - return text - return "…\n" + text[-limit:] - -def _gb(b) -> str: - return f"{b / 1e9:.1f} GB" if b else "?" - - -# --------------------------------------------------------------------------- -# /status — list all containers - -@tree.command(name="status", description="Статус всех Docker-контейнеров") -async def cmd_status(interaction: discord.Interaction): - await interaction.response.defer() - try: - containers = sorted(docker_client.containers.list(all=True), key=lambda c: c.name) - lines = [f"{_icon(c.status)} `{c.name}` — {c.status}" for c in containers] - embed = discord.Embed( - title="🖥️ Контейнеры", - description="\n".join(lines) or "Нет контейнеров", - color=0x5865F2, - timestamp=datetime.now(timezone.utc), - ) - await interaction.followup.send(embed=embed) - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- -# /logs — last 40 lines of container logs - -@tree.command(name="logs", description="Последние логи контейнера") -@app_commands.describe(service="Имя контейнера (forgejo, plane-web, traefik, …)") -async def cmd_logs(interaction: discord.Interaction, service: str): - await interaction.response.defer() - try: - c = docker_client.containers.get(service) - raw = c.logs(tail=40, timestamps=False).decode("utf-8", errors="replace") - await interaction.followup.send( - f"**Логи `{service}` (последние 40 строк):**\n```\n{_trunc(raw)}\n```" - ) - except docker.errors.NotFound: - await interaction.followup.send(f"❌ Контейнер `{service}` не найден") - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- -# /restart — restart a container - -@tree.command(name="restart", description="Перезапустить контейнер") -@app_commands.describe(service="Имя контейнера") -async def cmd_restart(interaction: discord.Interaction, service: str): - await interaction.response.defer() - try: - c = docker_client.containers.get(service) - await interaction.followup.send(f"🔄 Перезапускаю `{service}`…") - c.restart(timeout=30) - await interaction.channel.send(f"✅ `{service}` перезапущен") - except docker.errors.NotFound: - await interaction.followup.send(f"❌ Контейнер `{service}` не найден") - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- -# /deploy — trigger Forgejo CI pipeline via workflow_dispatch - -@tree.command(name="deploy", description="Запустить деплой через Forgejo CI") -async def cmd_deploy(interaction: discord.Interaction): - await interaction.response.defer() - try: - async with httpx.AsyncClient(timeout=15) as http: - r = await http.post( - f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_REPO}/actions/workflows/deploy.yml/dispatches", - headers={"Authorization": f"token {FORGEJO_TOKEN}"}, - json={"ref": "master"}, - ) - if r.status_code in (200, 204): - repo_url = f"{FORGEJO_URL}/{FORGEJO_REPO}/actions" - await interaction.followup.send( - f"🚀 **Деплой запущен!**\nПрогресс: {repo_url}" - ) - else: - await interaction.followup.send( - f"⚠️ Forgejo ответил `{r.status_code}`:\n```{r.text[:300]}```" - ) - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- -# /metrics — CPU / RAM / disk from Prometheus - -@tree.command(name="metrics", description="Метрики сервера (CPU / RAM / диск)") -async def cmd_metrics(interaction: discord.Interaction): - await interaction.response.defer() - try: - info = docker_client.info() - running = info.get("ContainersRunning", "?") - stopped = info.get("ContainersStopped", "?") - - async with httpx.AsyncClient(timeout=6) as http: - async def prom(q: str): - try: - r = await http.get( - f"{PROMETHEUS_URL}/api/v1/query", params={"query": q} - ) - d = r.json() - if d["status"] == "success" and d["data"]["result"]: - return float(d["data"]["result"][0]["value"][1]) - except Exception: - pass - return None - - cpu = await prom('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)') - mem_total = await prom("node_memory_MemTotal_bytes") - mem_avail = await prom("node_memory_MemAvailable_bytes") - disk_total = await prom('node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}') - disk_free = await prom('node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"}') - - mem_pct = (mem_total - mem_avail) / mem_total * 100 if mem_total and mem_avail else None - disk_pct = (disk_total - disk_free) / disk_total * 100 if disk_total and disk_free else None - - embed = discord.Embed( - title="📊 Метрики сервера", - color=0x2ecc71, - timestamp=datetime.now(timezone.utc), - ) - embed.add_field(name="CPU", value=f"`{cpu:.1f}%`" if cpu is not None else "—", inline=True) - embed.add_field( - name="RAM", - value=f"`{mem_pct:.1f}%` ({_gb(mem_total - mem_avail if mem_total and mem_avail else None)} / {_gb(mem_total)})" - if mem_pct is not None else "—", - inline=True, - ) - embed.add_field( - name="Диск /", - value=f"`{disk_pct:.1f}%` ({_gb(disk_total - disk_free if disk_total and disk_free else None)} / {_gb(disk_total)})" - if disk_pct is not None else "—", - inline=True, - ) - embed.add_field( - name="Контейнеры", - value=f"🟢 {running} running / 🔴 {stopped} stopped", - inline=False, - ) - await interaction.followup.send(embed=embed) - - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- -# /backup — last backup container logs - -@tree.command(name="backup", description="Логи последнего бэкапа") -async def cmd_backup(interaction: discord.Interaction): - await interaction.response.defer() - try: - containers = docker_client.containers.list( - all=True, filters={"name": "backup"} - ) - if not containers: - await interaction.followup.send( - "ℹ️ Контейнер `backup` не найден (запускается по расписанию каждые 6 часов)" - ) - return - c = containers[0] - raw = c.logs(tail=30).decode("utf-8", errors="replace") - lower = raw.lower() - icon = "🟢" if any(w in lower for w in ("uploaded", "complete", "done")) else \ - "🔴" if any(w in lower for w in ("error", "fail", "traceback")) else "🟡" - await interaction.followup.send( - f"{icon} **Логи бэкапа** (`{c.name}`, статус: `{c.status}`):\n" - f"```\n{_trunc(raw, 1500)}\n```" - ) - except Exception as e: - await interaction.followup.send(f"❌ Ошибка: `{e}`") - - -# --------------------------------------------------------------------------- - -@client.event -async def on_ready(): - await tree.sync() - log.info("Logged in as %s (ID: %s). Slash commands synced.", client.user, client.user.id) - - -client.run(DISCORD_TOKEN, log_handler=None) diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d4a7e2 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "discord-bot", + "version": "1.0.0", + "description": "Infrastructure management bot for csrx.ru", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "discord.js": "^14.16.3", + "dockerode": "^4.0.2" + }, + "devDependencies": { + "@types/dockerode": "^3.3.32", + "@types/node": "^22.0.0", + "tsx": "^4.19.2", + "typescript": "^5.6.0" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3a18574..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -discord.py==2.4.0 -docker==7.1.0 -httpx==0.28.1 diff --git a/src/commands/backup.ts b/src/commands/backup.ts new file mode 100644 index 0000000..c5135fb --- /dev/null +++ b/src/commands/backup.ts @@ -0,0 +1,36 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { docker, parseLogs, trunc } from '../lib/docker'; + +export const data = new SlashCommandBuilder() + .setName('backup') + .setDescription('Логи последнего бэкапа'); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const containers = await docker.listContainers({ + all: true, + filters: JSON.stringify({ name: ['backup'] }), + }); + + if (containers.length === 0) { + await interaction.editReply( + 'ℹ️ Контейнер `backup` не найден — запускается автоматически каждые 6 часов.', + ); + return; + } + + const info = containers[0]; + const name = info.Names[0].replace(/^\//, ''); + const container = docker.getContainer(info.Id); + const buf = await container.logs({ tail: 30, stdout: true, stderr: true, follow: false }) as Buffer; + const text = parseLogs(buf).toLowerCase(); + + const icon = + text.includes('error') || text.includes('fail') ? '🔴' : + text.includes('upload') || text.includes('done') || text.includes('complete') ? '🟢' : '🟡'; + + await interaction.editReply( + `${icon} **Логи бэкапа** (\`${name}\`, статус: \`${info.State}\`):\n\`\`\`\n${trunc(parseLogs(buf), 1500)}\n\`\`\``, + ); +} diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts new file mode 100644 index 0000000..be71163 --- /dev/null +++ b/src/commands/deploy.ts @@ -0,0 +1,18 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { actionsUrl, dispatchDeploy } from '../lib/forgejo'; + +export const data = new SlashCommandBuilder() + .setName('deploy') + .setDescription('Запустить деплой через Forgejo CI'); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const { ok } = await dispatchDeploy(); + + if (ok) { + await interaction.editReply(`🚀 **Деплой запущен!**\nПрогресс: ${actionsUrl}`); + } else { + await interaction.editReply('⚠️ Не удалось запустить деплой. Проверь логи Forgejo.'); + } +} diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..e48c2a8 --- /dev/null +++ b/src/commands/logs.ts @@ -0,0 +1,38 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + SlashCommandBuilder, +} from 'discord.js'; +import { docker, parseLogs, trunc } from '../lib/docker'; + +export const data = new SlashCommandBuilder() + .setName('logs') + .setDescription('Последние логи контейнера') + .addStringOption(o => + o.setName('service') + .setDescription('Имя контейнера') + .setRequired(true) + .setAutocomplete(true), + ); + +export async function autocomplete(interaction: AutocompleteInteraction): Promise { + const focused = interaction.options.getFocused().toLowerCase(); + const containers = await docker.listContainers({ all: true }); + const choices = containers + .map(c => c.Names[0].replace(/^\//, '')) + .filter(n => n.includes(focused)) + .slice(0, 25) + .map(n => ({ name: n, value: n })); + await interaction.respond(choices); +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const name = interaction.options.getString('service', true); + const container = docker.getContainer(name); + const buf = await container.logs({ tail: 40, stdout: true, stderr: true, follow: false }) as Buffer; + const text = trunc(parseLogs(buf)); + + await interaction.editReply(`**Логи \`${name}\`:**\n\`\`\`\n${text || '(пусто)'}\n\`\`\``); +} diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts new file mode 100644 index 0000000..a5811f1 --- /dev/null +++ b/src/commands/metrics.ts @@ -0,0 +1,44 @@ +import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { docker, bytesToGb } from '../lib/docker'; +import { scalar } from '../lib/prometheus'; + +export const data = new SlashCommandBuilder() + .setName('metrics') + .setDescription('Метрики сервера: CPU / RAM / диск'); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const [info, cpu, memTotal, memAvail, diskTotal, diskFree] = await Promise.all([ + docker.info(), + scalar('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'), + scalar('node_memory_MemTotal_bytes'), + scalar('node_memory_MemAvailable_bytes'), + scalar('node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}'), + scalar('node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"}'), + ]); + + const memUsed = memTotal && memAvail ? memTotal - memAvail : null; + const diskUsed = diskTotal && diskFree ? diskTotal - diskFree : null; + + const fmt = (val: number | null, total: number | null, used: number | null) => + val !== null && total && used + ? `\`${val.toFixed(1)}%\` (${bytesToGb(used)} / ${bytesToGb(total)})` + : '—'; + + const memPct = memUsed && memTotal ? (memUsed / memTotal) * 100 : null; + const diskPct = diskUsed && diskTotal ? (diskUsed / diskTotal) * 100 : null; + + const embed = new EmbedBuilder() + .setTitle('📊 Метрики сервера') + .setColor(0x2ecc71) + .setTimestamp() + .addFields( + { name: 'CPU', value: cpu !== null ? `\`${cpu.toFixed(1)}%\`` : '—', inline: true }, + { name: 'RAM', value: fmt(memPct, memTotal, memUsed), inline: true }, + { name: 'Диск /', value: fmt(diskPct, diskTotal, diskUsed), inline: true }, + { name: 'Контейнеры', value: `🟢 ${info.ContainersRunning} running / 🔴 ${info.ContainersStopped} stopped`, inline: false }, + ); + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/commands/restart.ts b/src/commands/restart.ts new file mode 100644 index 0000000..5a8e539 --- /dev/null +++ b/src/commands/restart.ts @@ -0,0 +1,39 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + SlashCommandBuilder, +} from 'discord.js'; +import { docker } from '../lib/docker'; + +export const data = new SlashCommandBuilder() + .setName('restart') + .setDescription('Перезапустить контейнер') + .addStringOption(o => + o.setName('service') + .setDescription('Имя контейнера') + .setRequired(true) + .setAutocomplete(true), + ); + +export async function autocomplete(interaction: AutocompleteInteraction): Promise { + const focused = interaction.options.getFocused().toLowerCase(); + // Only suggest running containers for restart + const containers = await docker.listContainers(); + const choices = containers + .map(c => c.Names[0].replace(/^\//, '')) + .filter(n => n.includes(focused)) + .slice(0, 25) + .map(n => ({ name: n, value: n })); + await interaction.respond(choices); +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const name = interaction.options.getString('service', true); + const container = docker.getContainer(name); + + await interaction.editReply(`🔄 Перезапускаю \`${name}\`…`); + await container.restart({ t: 30 }); + await interaction.editReply(`✅ \`${name}\` перезапущен`); +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..52e1011 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,26 @@ +import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { docker, statusEmoji } from '../lib/docker'; + +export const data = new SlashCommandBuilder() + .setName('status') + .setDescription('Статус всех Docker-контейнеров'); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const containers = await docker.listContainers({ all: true }); + containers.sort((a, b) => a.Names[0].localeCompare(b.Names[0])); + + const lines = containers.map(c => { + const name = c.Names[0].replace(/^\//, ''); + return `${statusEmoji(c.State)} \`${name}\` — ${c.Status}`; + }); + + const embed = new EmbedBuilder() + .setTitle('🖥️ Контейнеры') + .setDescription(lines.join('\n') || 'Нет контейнеров') + .setColor(0x5865F2) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6b84dad --- /dev/null +++ b/src/index.ts @@ -0,0 +1,71 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + Client, + Collection, + GatewayIntentBits, + Interaction, + REST, + Routes, + SlashCommandBuilder, +} from 'discord.js'; + +import * as backup from './commands/backup'; +import * as deploy from './commands/deploy'; +import * as logs from './commands/logs'; +import * as metrics from './commands/metrics'; +import * as restart from './commands/restart'; +import * as status from './commands/status'; + +// --------------------------------------------------------------------------- + +const TOKEN = process.env.DISCORD_TOKEN ?? ''; +const APP_ID = process.env.DISCORD_APP_ID ?? ''; + +if (!TOKEN || !APP_ID) { + console.error('DISCORD_TOKEN and DISCORD_APP_ID must be set'); + process.exit(1); +} + +// --------------------------------------------------------------------------- + +type Command = { + data: SlashCommandBuilder; + execute(i: ChatInputCommandInteraction): Promise; + autocomplete?(i: AutocompleteInteraction): Promise; +}; + +const allCommands: Command[] = [status, logs, restart, deploy, metrics, backup]; +const commands = new Collection(); +for (const cmd of allCommands) commands.set(cmd.data.name, cmd); + +// Register slash commands with Discord (runs once on start) +const rest = new REST().setToken(TOKEN); +rest + .put(Routes.applicationCommands(APP_ID), { body: allCommands.map(c => c.data.toJSON()) }) + .then(() => console.log(`Registered ${allCommands.length} slash commands`)) + .catch(console.error); + +// --------------------------------------------------------------------------- + +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +client.on('interactionCreate', async (interaction: Interaction) => { + if (interaction.isChatInputCommand()) { + const cmd = commands.get(interaction.commandName); + if (!cmd) return; + await cmd.execute(interaction).catch(err => { + console.error(`[${interaction.commandName}]`, err); + const msg = { content: `❌ Ошибка: \`${(err as Error).message}\``, ephemeral: true }; + interaction.replied || interaction.deferred + ? interaction.followUp(msg) + : interaction.reply(msg); + }); + } else if (interaction.isAutocomplete()) { + const cmd = commands.get(interaction.commandName); + if (cmd?.autocomplete) await cmd.autocomplete(interaction).catch(console.error); + } +}); + +client.once('ready', c => console.log(`Logged in as ${c.user.tag}`)); +client.login(TOKEN); diff --git a/src/lib/docker.ts b/src/lib/docker.ts new file mode 100644 index 0000000..70c1680 --- /dev/null +++ b/src/lib/docker.ts @@ -0,0 +1,41 @@ +import Docker from 'dockerode'; + +export const docker = new Docker({ socketPath: '/var/run/docker.sock' }); + +export type ContainerInfo = Docker.ContainerInfo; + +/** Strip multiplexed 8-byte headers from Docker log buffers (non-TTY containers). */ +export function parseLogs(buf: Buffer): string { + const parts: Buffer[] = []; + let offset = 0; + + while (offset + 8 <= buf.length) { + const size = buf.readUInt32BE(offset + 4); + if (size === 0) { offset += 8; continue; } + if (offset + 8 + size > buf.length) break; + parts.push(buf.subarray(offset + 8, offset + 8 + size)); + offset += 8 + size; + } + + // If no valid headers found, fall back to raw text + const decoded = parts.length > 0 + ? Buffer.concat(parts).toString('utf8') + : buf.toString('utf8'); + + // Strip ANSI escape codes + return decoded.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** Truncate to Discord's message limit. */ +export function trunc(text: string, limit = 1900): string { + const t = text.trim(); + return t.length <= limit ? t : `…\n${t.slice(-limit)}`; +} + +export function statusEmoji(state: string): string { + return ({ running: '🟢', paused: '🟡', exited: '🔴', dead: '💀' } as Record)[state] ?? '⚪'; +} + +export function bytesToGb(b: number): string { + return `${(b / 1e9).toFixed(1)} GB`; +} diff --git a/src/lib/forgejo.ts b/src/lib/forgejo.ts new file mode 100644 index 0000000..d6793bd --- /dev/null +++ b/src/lib/forgejo.ts @@ -0,0 +1,18 @@ +const BASE = process.env.FORGEJO_URL ?? 'https://git.csrx.ru'; +const TOKEN = process.env.FORGEJO_TOKEN ?? ''; +const REPO = process.env.FORGEJO_REPO ?? 'jack/infra'; + +export const actionsUrl = `${BASE}/${REPO}/actions`; + +export async function dispatchDeploy(): Promise<{ ok: boolean }> { + const res = await fetch( + `${BASE}/api/v1/repos/${REPO}/actions/workflows/deploy.yml/dispatches`, + { + method: 'POST', + headers: { Authorization: `token ${TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ref: 'master' }), + signal: AbortSignal.timeout(15_000), + }, + ); + return { ok: res.ok || res.status === 204 }; +} diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts new file mode 100644 index 0000000..8c38cb3 --- /dev/null +++ b/src/lib/prometheus.ts @@ -0,0 +1,19 @@ +const BASE = process.env.PROMETHEUS_URL ?? 'http://prometheus:9090'; + +interface PromResponse { + status: string; + data: { result: Array<{ value: [number, string] }> }; +} + +/** Run an instant query and return the scalar value, or null on error. */ +export async function scalar(q: string): Promise { + try { + const url = `${BASE}/api/v1/query?query=${encodeURIComponent(q)}`; + const res = await fetch(url, { signal: AbortSignal.timeout(6_000) }); + const json = await res.json() as PromResponse; + if (json.status === 'success' && json.data.result.length > 0) { + return parseFloat(json.data.result[0].value[1]); + } + } catch { /* unreachable */ } + return null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a3d4610 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}