RUST + AXUM: СОЗДАЁМ КРАСИВУЮ KANBAN-ДОСКУ С НУЛЯ(без Docker и Node.js)


Цель

Создать красивую Kanban-доску с веб-интерфейсом на Rust + Axum, без Docker и без Node.js, с аккуратной панелью, перетаскиванием задач, и кнопкой удаления.

Всё приложение собирается в один бинарник и запускается как systemd-сервис.


Что будем использовать

  • Rust — язык программирования
  • Axum — веб-фреймворк
  • Askama — шаблонизатор
  • SQLx — работа с SQLite
  • Tailwind CSS — стили (в виде обычного CSS)
  • Vanilla JS — перетаскивание задач и удаление
  • systemd — запуск как сервис

Структура проекта

/srv/apps/kanban-board/
├── Cargo.toml
├── sqlx-data.json
├── migrations/
│   └── 001_initial.sql
├── src/
│   ├── main.rs
│   ├── state.rs
│   ├── models.rs
│   ├── db.rs
│   └── routes/
│       ├── mod.rs
│       ├── index.rs
│       └── api.rs
├── templates/
│   ├── base.html
│   └── index.html
└── static/
    ├── style.css
    └── script.js

Подготовка окружения

1. Создаём системного пользователя kanban

sudo adduser --system --group --home /srv/apps/kanban kanban
sudo mkdir -p /srv/apps/kanban
sudo chown kanban:kanban /srv/apps/kanban

2. Устанавливаем зависимости

sudo apt update
sudo apt install -y build-essential pkg-config libssl-dev libsqlite3-dev

3. Переходим под пользователя kanban

sudo -u kanban -H bash

3. Устанавливаем Rust для пользователя kanban

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
rustc --version
cargo --version

Создание проекта и структуры файлов

1. Создаём проект

cd /srv/apps/kanban
cargo new kanban-board
cd kanban-board

2. Создаём структуру каталогов

mkdir -p migrations src/routes templates static

Все файлы проекта


1. Cargo.toml

[package]
name = "kanban-board"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
askama = "0.12"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }

2. sqlx-data.json

touch sqlx-data.json

3. migrations/001_initial.sql

CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT,
    status TEXT NOT NULL DEFAULT 'todo',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

4. src/models.rs

use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: String,
    pub title: String,
    pub description: Option<String>,
    pub status: TaskStatus,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
    Todo,
    InProgress,
    Done,
}

impl Default for TaskStatus {
    fn default() -> Self {
        TaskStatus::Todo
    }
}

#[derive(Deserialize)]
pub struct CreateTask {
    pub title: String,
    pub description: Option<String>,
}

#[derive(Deserialize)]
pub struct UpdateTask {
    pub status: Option<TaskStatus>,
    pub title: Option<String>,
    pub description: Option<String>,
}

src/state.rs

use sqlx::SqlitePool;

#[derive(Clone)]
pub struct AppState {
    pub db_pool: SqlitePool,
}

src/db.rs

use sqlx::{SqlitePool, Row};
use crate::models::{Task, TaskStatus};

pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS tasks (
            id TEXT PRIMARY KEY,
            title TEXT NOT NULL,
            description TEXT,
            status TEXT NOT NULL DEFAULT 'todo',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )"
    )
    .execute(pool)
    .await?;
    Ok(())
}

pub async fn get_tasks(pool: &SqlitePool) -> Result<Vec<Task>, sqlx::Error> {
    let rows = sqlx::query(
        "SELECT id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC"
    )
    .fetch_all(pool)
    .await?;

    let mut tasks = Vec::new();
    for row in rows {
        let status_str: String = row.try_get("status")?;
        let status = match status_str.as_str() {
            "in_progress" => TaskStatus::InProgress,
            "done" => TaskStatus::Done,
            _ => TaskStatus::Todo,
        };

        tasks.push(Task {
            id: row.try_get("id")?,
            title: row.try_get("title")?,
            description: row.try_get("description")?,
            status,
            created_at: row.try_get("created_at")?,
            updated_at: row.try_get("updated_at")?,
        });
    }

    Ok(tasks)
}

pub async fn create_task(pool: &SqlitePool, title: &str, description: Option<&str>) -> Result<Task, sqlx::Error> {
    let id = uuid::Uuid::new_v4().to_string();
    sqlx::query(
        "INSERT INTO tasks (id, title, description, status) VALUES (?, ?, ?, ?)"
    )
    .bind(&id)
    .bind(title)
    .bind(description)
    .bind("todo")
    .execute(pool)
    .await?;

    let task = get_task_by_id(pool, &id).await?;
    Ok(task)
}

pub async fn update_task(
    pool: &SqlitePool,
    id: &str,
    status: Option<TaskStatus>,
    title: Option<&str>,
    description: Option<&str>,
) -> Result<Task, sqlx::Error> {
    if let Some(s) = status {
        let status_str = match s {
            TaskStatus::Todo => "todo",
            TaskStatus::InProgress => "in_progress",
            TaskStatus::Done => "done",
        };
        sqlx::query("UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
            .bind(status_str)
            .bind(id)
            .execute(pool)
            .await?;
    }

    if let Some(t) = title {
        sqlx::query("UPDATE tasks SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
            .bind(t)
            .bind(id)
            .execute(pool)
            .await?;
    }

    if let Some(desc) = description {
        sqlx::query("UPDATE tasks SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
            .bind(desc)
            .bind(id)
            .execute(pool)
            .await?;
    }

    let task = get_task_by_id(pool, id).await?;
    Ok(task)
}

async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Task, sqlx::Error> {
    let row = sqlx::query("SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await?;

    let status_str: String = row.try_get("status")?;
    let status = match status_str.as_str() {
        "in_progress" => TaskStatus::InProgress,
        "done" => TaskStatus::Done,
        _ => TaskStatus::Todo,
    };

    Ok(Task {
        id: row.try_get("id")?,
        title: row.try_get("title")?,
        description: row.try_get("description")?,
        status,
        created_at: row.try_get("created_at")?,
        updated_at: row.try_get("updated_at")?,
    })
}

pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
    sqlx::query("DELETE FROM tasks WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await?;
    Ok(())
}

src/routes/mod.rs

pub mod index;
pub mod api;

src/routes/index.rs

use axum::{extract::State, response::Html};
use askama::Template;
use crate::{state::AppState, db};

#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
    tasks: Vec<crate::models::Task>,
}

pub async fn index(State(state): State<AppState>) -> Html<String> {
    let tasks = db::get_tasks(&state.db_pool).await.unwrap_or_default();
    let tpl = IndexTemplate { tasks };
    Html(tpl.render().unwrap())
}

src/routes/api.rs


use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use crate::{db, models::{CreateTask, UpdateTask}, state::AppState};

pub async fn get_tasks(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Task>>, StatusCode> {
    let tasks = db::get_tasks(&state.db_pool).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(tasks))
}

pub async fn create_task(
    State(state): State<AppState>,
    Json(payload): Json<CreateTask>,
) -> Result<Json<crate::models::Task>, StatusCode> {
    let task = db::create_task(&state.db_pool, &payload.title, payload.description.as_deref())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(task))
}

pub async fn update_task(
    Path(id): Path<String>,
    State(state): State<AppState>,
    Json(payload): Json<UpdateTask>,
) -> Result<Json<crate::models::Task>, StatusCode> {
    let task = db::update_task(
        &state.db_pool,
        &id,
        payload.status,
        payload.title.as_deref(),
        payload.description.as_deref(),
    ).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(task))
}

pub async fn delete_task(
    Path(id): Path<String>,
    State(state): State<AppState>,
) -> Result<StatusCode, StatusCode> {
    db::delete_task(&state.db_pool, &id)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(StatusCode::NO_CONTENT)
}

src/main.rs

use axum::{Router, routing::{get, put}};
use sqlx::{SqlitePool};
use tower_http::services::ServeDir;

mod models;
mod db;
mod state;
mod routes;

use crate::state::AppState;
use crate::routes::{index::index, api};

#[tokio::main]
async fn main() {
    let database_url = "sqlite:data.db";
    let pool = SqlitePool::connect(database_url).await.expect("Failed to connect to DB");
    db::init_db(&pool).await.expect("Failed to init DB");

    let app_state = AppState { db_pool: pool };

    let app = Router::new()
        .route("/", get(index))
        .route("/api/tasks", get(api::get_tasks).post(api::create_task))
        .route("/api/tasks/:id", put(api::update_task))
        .route("/api/tasks/:id", axum::routing::delete(api::delete_task))
        .nest_service("/static", ServeDir::new("static"))
        .with_state(app_state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8082")
        .await
        .unwrap();

    println!("🚀 Kanban Board running on http://0.0.0.0:8082");

    axum::serve(listener, app).await.unwrap();
}

templates/base.html

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kanban Board</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        {% block content %}{% endblock %}
    </div>
    <script src="/static/script.js"></script>
</body>
</html>

templates/index.html

{% extends "base.html" %}

{% block content %}
<div class="board">
    <h1>Моя Kanban-доска</h1>

    <div class="add-task">
        <input type="text" id="newTaskTitle" placeholder="Название задачи">
        <textarea id="newTaskDesc" placeholder="Описание"></textarea>
        <button onclick="addTask()">Добавить задачу</button>
    </div>

    <div class="columns">
        <div class="column">
            <h2>📋 В работе</h2>
            <div class="tasks" id="todo" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
        </div>
        <div class="column">
            <h2>🔄 Выполняется</h2>
            <div class="tasks" id="in_progress" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
        </div>
        <div class="column">
            <h2>✅ Выполнено</h2>
            <div class="tasks" id="done" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
        </div>
    </div>
</div>
{% endblock %}

static/style.css

:root {
    --bg-dark: #0f172a;
    --bg-card: #1e293b;
    --text-light: #f1f5f9;
    --accent-blue: #3b82f6;
    --accent-green: #10b981;
    --accent-red: #ef4444;
}

body {
    margin: 0;
    font-family: system-ui, sans-serif;
    background-color: var(--bg-dark);
    color: var(--text-light);
}

.container {
    padding: 20px;
}

.board h1 {
    text-align: center;
    margin-bottom: 30px;
    color: #93c5fd;
}

.add-task {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-bottom: 30px;
    max-width: 500px;
    margin-left: auto;
    margin-right: auto;
}

.add-task input, .add-task textarea {
    padding: 10px;
    border-radius: 6px;
    border: 1px solid #475569;
    background-color: var(--bg-card);
    color: var(--text-light);
}

.add-task button {
    padding: 10px 15px;
    background-color: var(--accent-blue);
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
}

.columns {
    display: flex;
    gap: 20px;
    justify-content: center;
}

.column {
    background-color: #334155;
    border-radius: 8px;
    width: 300px;
    padding: 15px;
    min-height: 500px;
}

.column h2 {
    margin-top: 0;
    text-align: center;
    color: #c7d2fe;
}

.tasks {
    min-height: 450px;
    padding: 10px;
}

.task {
    background-color: var(--bg-card);
    border-radius: 6px;
    padding: 12px;
    margin-bottom: 10px;
    cursor: grab;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    border-left: 4px solid var(--accent-blue);
    position: relative;
}

.task[data-status="in_progress"] {
    border-left-color: var(--accent-green);
}

.task[data-status="done"] {
    border-left-color: var(--accent-red);
}

.task:hover {
    opacity: 0.9;
}

.task-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 5px;
}

.task-title {
    font-weight: bold;
    margin-bottom: 0;
    flex-grow: 1;
}

.delete-btn {
    background: none;
    border: none;
    color: #94a3b8;
    cursor: pointer;
    font-size: 16px;
    padding: 0;
    margin-left: 8px;
}

.delete-btn:hover {
    color: var(--accent-red);
}

.task-desc {
    font-size: 0.9em;
    color: #cbd5e1;
}

static/script.js

let tasks = [];

async function loadTasks() {
    const response = await fetch('/api/tasks');
    tasks = await response.json();
    renderTasks();
}

function renderTasks() {
    const columns = { todo: [], in_progress: [], done: [] };
    tasks.forEach(task => {
        columns[task.status].push(task);
    });

    ['todo', 'in_progress', 'done'].forEach(status => {
        const container = document.getElementById(status);
        container.innerHTML = '';
        columns[status].forEach(task => {
            const taskEl = document.createElement('div');
            taskEl.className = 'task';
            taskEl.draggable = true;
            taskEl.dataset.id = task.id;
            taskEl.dataset.status = task.status;
            taskEl.ondragstart = drag;

            taskEl.innerHTML = `
                <div class="task-header">
                    <div class="task-title">${task.title}</div>
                    <button class="delete-btn" onclick="deleteTask('${task.id}')">✕</button>
                </div>
                <div class="task-desc">${task.description || ''}</div>
            `;
            container.appendChild(taskEl);
        });
    });
}

async function addTask() {
    const title = document.getElementById('newTaskTitle').value;
    const desc = document.getElementById('newTaskDesc').value;
    if (!title) return;

    const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, description: desc || null })
    });

    if (response.ok) {
        document.getElementById('newTaskTitle').value = '';
        document.getElementById('newTaskDesc').value = '';
        loadTasks();
    }
}

async function deleteTask(id) {
    if (!confirm('Вы уверены, что хотите удалить задачу?')) return;

    const response = await fetch(`/api/tasks/${id}`, {
        method: 'DELETE'
    });

    if (response.ok) {
        loadTasks();
    }
}

function allowDrop(ev) {
    ev.preventDefault();
}

function drag(ev) {
    ev.dataTransfer.setData("text", ev.target.closest('.task').dataset.id);
}

function drop(ev) {
    ev.preventDefault();
    const id = ev.dataTransfer.getData("text");
    const newStatus = ev.target.id;

    fetch(`/api/tasks/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: newStatus })
    }).then(() => loadTasks());
}

loadTasks();

Сборка и запуск

Собираем проект

cargo build --release

✅ Если вы видите предупреждение field tasks is never read, это нормальноaskama использует поле в шаблоне.

Убеждаемся, что все файлы на месте

ls -la templates/
ls -la static/

Создаём файл базы данных заранее

⚠️ Важно: SQLite не может создать файл data.db, если он не существует и нет прав на запись. Поэтому создадим его заранее:

touch /srv/apps/kanban/kanban-board/data.db
chmod 664 /srv/apps/kanban/kanban-board/data.db

Запуск вручную

./target/release/kanban-board

После запуска приложение будет доступно по адресу:

🚀 Kanban Board running on http://0.0.0.0:8082

Откройте в браузере http://192.168.1.xxx:8082 — доска готова!

  • ✅ Кнопка удаления задач
  • ✅ Перетаскивание задач

systemd-сервис

Создаём файл сервиса:

sudo nano /etc/systemd/system/kanban-board.service
[Unit]
Description=Kanban Board
After=network.target

[Service]
Type=simple
User=kanban
WorkingDirectory=/srv/apps/kanban/kanban-board
ExecStart=/srv/apps/kanban/kanban-board/target/release/kanban-board
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable kanban-board
sudo systemctl start kanban-board

Проверяем статус:

sudo systemctl status kanban-board

✅ Готово!

  • ✅ Rust-бинарник
  • ✅ Без Docker
  • ✅ Без Node.js
  • ✅ Красивый UI
  • ✅ Перетаскивание задач
  • ✅ Удаление задач
  • ✅ Запуск как сервис
  • ✅ Все файлы на месте

Теперь у вас есть красивая Kanban-доска, которую можно использовать для управления задачами, как часть домашнего сервера или отдельно.