refactor: rewrite in TypeScript (discord.js v14)
- Proper modular structure: src/commands/ + src/lib/ - Autocomplete for /logs and /restart (live container names) - All metrics queries run in parallel (Promise.all) - Multi-stage Docker build (builder + production) - TypeScript type check as CI job before docker build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e036908edb
commit
3ac39d03ce
17 changed files with 434 additions and 261 deletions
|
|
@ -7,27 +7,39 @@ on:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Build & push Docker image ────────────────────────────────────────────
|
# ── Type check ───────────────────────────────────────────────────────────────
|
||||||
build:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Forgejo Container Registry
|
- 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: |
|
run: |
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
docker build -t git.csrx.ru/jack/discord-bot:latest .
|
||||||
docker login git.csrx.ru -u ${{ github.actor }} --password-stdin
|
docker push git.csrx.ru/jack/discord-bot:latest
|
||||||
|
|
||||||
- name: Build image
|
# ── Deploy to server ───────────────────────────────────────────────────────
|
||||||
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:
|
deploy:
|
||||||
needs: build
|
needs: docker
|
||||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Configure SSH
|
- name: Configure SSH
|
||||||
|
|
@ -38,7 +50,7 @@ jobs:
|
||||||
ssh-keyscan -p 22 87.249.49.32 >> ~/.ssh/known_hosts
|
ssh-keyscan -p 22 87.249.49.32 >> ~/.ssh/known_hosts
|
||||||
chmod 600 ~/.ssh/known_hosts
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Pull & restart on server
|
- name: Pull & restart
|
||||||
run: |
|
run: |
|
||||||
ssh deploy@87.249.49.32 "
|
ssh deploy@87.249.49.32 "
|
||||||
docker pull git.csrx.ru/jack/discord-bot:latest &&
|
docker pull git.csrx.ru/jack/discord-bot:latest &&
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
__pycache__/
|
node_modules/
|
||||||
*.pyc
|
dist/
|
||||||
.env
|
.env
|
||||||
|
*.js.map
|
||||||
|
|
|
||||||
26
Dockerfile
26
Dockerfile
|
|
@ -1,12 +1,22 @@
|
||||||
FROM python:3.12-slim
|
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies first (cached layer)
|
COPY package*.json ./
|
||||||
COPY requirements.txt .
|
RUN npm ci
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy bot code
|
COPY tsconfig.json ./
|
||||||
COPY bot.py .
|
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"]
|
||||||
|
|
|
||||||
234
bot.py
234
bot.py
|
|
@ -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 <service> — 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 <service> — 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)
|
|
||||||
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
discord.py==2.4.0
|
|
||||||
docker==7.1.0
|
|
||||||
httpx==0.28.1
|
|
||||||
36
src/commands/backup.ts
Normal file
36
src/commands/backup.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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\`\`\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/commands/deploy.ts
Normal file
18
src/commands/deploy.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const { ok } = await dispatchDeploy();
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
await interaction.editReply(`🚀 **Деплой запущен!**\nПрогресс: ${actionsUrl}`);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply('⚠️ Не удалось запустить деплой. Проверь логи Forgejo.');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/commands/logs.ts
Normal file
38
src/commands/logs.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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\`\`\``);
|
||||||
|
}
|
||||||
44
src/commands/metrics.ts
Normal file
44
src/commands/metrics.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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] });
|
||||||
|
}
|
||||||
39
src/commands/restart.ts
Normal file
39
src/commands/restart.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}\` перезапущен`);
|
||||||
|
}
|
||||||
26
src/commands/status.ts
Normal file
26
src/commands/status.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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] });
|
||||||
|
}
|
||||||
71
src/index.ts
Normal file
71
src/index.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
autocomplete?(i: AutocompleteInteraction): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allCommands: Command[] = [status, logs, restart, deploy, metrics, backup];
|
||||||
|
const commands = new Collection<string, Command>();
|
||||||
|
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);
|
||||||
41
src/lib/docker.ts
Normal file
41
src/lib/docker.ts
Normal file
|
|
@ -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<string, string>)[state] ?? '⚪';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToGb(b: number): string {
|
||||||
|
return `${(b / 1e9).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
18
src/lib/forgejo.ts
Normal file
18
src/lib/forgejo.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
19
src/lib/prometheus.ts
Normal file
19
src/lib/prometheus.ts
Normal file
|
|
@ -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<number | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue