m2/08 w/o Swarm, and w/o solutions

pull/3/head
Dmitry Ignatiev 12 months ago
parent 7640839e06
commit 2a44802fed

@ -10,6 +10,6 @@ trim_trailing_whitespace = true
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.{py}] [*.{py,cs}]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4

8
.gitignore vendored

@ -1,2 +1,8 @@
*~ *~
.DS_Store .DS_Store
bin/
obj/
.vs/
.vscode/
*.exe
*.dll

@ -1,9 +1,34 @@
## Инструменты Docker ## Инструменты Docker
### 1. Многоконтейнерные приложения ### 1. Многоступенчатая сборка образов
Docker Compose — это инструмент для запуска многоконтейнерных приложений в Docker, определенных с использованием формата файлов Compose. Файл Compose используется для определения того, как настроены один или несколько контейнеров, составляющих ваше приложение. Когда у вас есть файл Compose, вы можете создать и запустить приложение с помощью одной команды: docker compose up. В качестве типичного примера приложения можно привести веб-сайт, состоящий из контейнера фронтэнда и контейнера бэкенда. Docker позволяет задействовать несколько исходных образов при сборке результирующего, при этом используя только один Dockerfile.
### 2. Кластерные приложения Каждая стадия сборки может обмениваться файлами с предыдущими.
Эта возможность бывает особенно полезна при создании крупных приложений, состоящих из нескольких разных частей, использующих разные языки программирования и разные платформы.
### 2. Политики перезапуска и базовый мониторинг
Для контейнеров можно установить политики перезапуска, таким образом, чтобы Docker перезапускал контейнер в случае ошибки, или же в случае перезагрузки демона или даже операционной системы.
Docker также предоставляет инструмент HEALTHCHECK - возможность базового мониторинга состояния контейнера. Его можно указать как при сборке образа так и при запуске контейнера.
HEALTHCHECK **не** связан с политиками перезапуска напрямую. Для имплементации перезапуска при провале команды проверки, нужно реализовывать эту логику вручную.
### 3. Ограничение ресурсов
Docker позволяет ограничивать ресурсы компьютера, доступные контейнерам. Это бывает особенно полезно в случае некорректно написанных приложений, которые, к примеру, нерационально расходуют память системы. Таким образом можно как обезопасить хост-систему от переполнения памяти, так и более рационально распорядиться доступной памятью(или ресурсами CPU) в случае нескольких контейнеров.
### 4. Docker Compose
Как вы могли заметить, запуск команд Docker вручную - довольно утомительное занятие.
К счастью, Docker предоставляет плагин Docker Compose, который позволяет декларативно описывать конфигурацию Docker на локальной машине, включая контейнеры, сети, и все остальное. Продвинутые возможности Docker, такие как политики перезапуска, или ограничения ресурсов, в нем выглядят особенно просто и наглядно.
Docker Compose использует YAML в качестве языка конфигурации. По умолчанию, файл конфигурации называется `docker-compose.yml`
### 5. Docker Swarm
Docker Swarm это простой оркестратор для контейнеров, который доступен из коробки. Позволяет объединить докер демоны на разных машинах в кластер, что даёт возможность поставки распределённых приложений упакованных в контейнеры. Swarm, так же как и Compose, имеет декларативную модель описания кластерного приложения. Типичными задачами для такого типа приложений являются организация высокой доступности, балансировки нагрузки и канареечного обновления сервиса. Также вы можете добиться эластичности распределённого приложения, которое создаёт и удаляет контейнеры в зависимости от поступающей нагрузки. Docker Swarm это простой оркестратор для контейнеров, который доступен из коробки. Позволяет объединить докер демоны на разных машинах в кластер, что даёт возможность поставки распределённых приложений упакованных в контейнеры. Swarm, так же как и Compose, имеет декларативную модель описания кластерного приложения. Типичными задачами для такого типа приложений являются организация высокой доступности, балансировки нагрузки и канареечного обновления сервиса. Также вы можете добиться эластичности распределённого приложения, которое создаёт и удаляет контейнеры в зависимости от поступающей нагрузки.

@ -1,2 +1,12 @@
1. Для чего используется инструмент docker compose? 1. Для чего нужна многоступенчатая сборка образов?
2. Как создать образ из нескольких образов?
3. Какие бывают политики перезапуска в Docker?
4. Что такое healthcheck и для чего он нужен? Как его можно включить/указать?
5. Как можно ограничить доступную память и ресурсы процессора контейнеру?
6. Может ли Docker выделять ядро процессора специально под контейнер?
7. Для чего используется инструмент docker compose?
8. Как называется файл конфигурации docker compose по умолчанию?
9. Как в compose файле можно создать и примонтировать том данных в контейнер?
10. Как форсировать сборку образов при старте конфигурации docker compose?
11. Чем отличаются bridge и overlay сети?

@ -0,0 +1,199 @@
# Задания
### 1. Многоступенчатая сборка образов
В полученной вами директории `todo_app` находятся исходные коды Enterprise Quality™® системы для менеджмента напоминаний.
Она состоит из следующих частей:
- `TodoApi` - API-сервис, на платформе `.NET 7`.
- `todo_ui` - веб-интерфейс к этому сервису, Single-Page `JavaScript` приложение(`SPA`), написанное с использованием `Vue.js 3`.
- API-сервис использует `PostgreSQL` для хранения данных.
Необходимо будет контейнеризировать эту систему.
Сервис TodoApi имеет следующие шаги сборки:
- Во-первых, для его сборки необходим образ `mcr.microsoft.com/dotnet/sdk:7.0`
- Далее, из директории, в которой находятся исходные коды сервиса(см. `todo_app/TodoApi`), \
нужно сделать `dotnet restore TodoApi.csproj` - эта команда скачает зависимости сервиса.
- Следующим шагом идет непосредственно сборка сервиса. \
Она производится командой `dotnet build TodoApi.csproj -c Release -o <output_directory>`, \
в которой вместо `<output_directory>` необходимо подставить имя директории, которое будет \
содержать бинарные файлы сервиса, например `/app/build`
- Последняя вещь, которую необходимо сделать - это вызывать команду \
`dotnet publish TodoApi.csproj -c Release -o <publish_directory>`, \
которая опубликует результирующие бинарные файлы, а также зависимости сервиса \
в `<publish_directory>`(например в `/app/publish`).
Для запуска процесса сервиса, образ SDK не нужен, достаточно лишь образа, содержащего соответствующий фреймворк \
`ASP.NET` - в данном случае `mcr.microsoft.com/dotnet/aspnet:7.0`. \
Запуск осуществляется вызовом исполняемого файла `./TodoApi` из директории, которая хранит опубликованные \
бинарные файлы сервиса и его зависимостей.
`JavaScript` приложение собирается следующим образом:
- Прежде всего, необходим образ `NodeJS` - в данном случае подойдет `node:21`.
- Далее, из директории, в которой находятся исходные коды приложения(см. `todo_app/todo_ui`), \
нужно вызывать `npm install` - это команда скачает зависимости приложения.
- После этого, в этой же директории необходимо выполнить команду `npm run build`, в результате чего, \
в этой директории появится директория `wwwroot`, содержащая файл `index.html`, \
результирующий код на JS, а также его ресурсы, такие как картинки и файлы стилей(css).
Для запуска `JavaScript` приложения `NodeJS` не нужен, достаточно лишь любого веб-сервера, и в \
нашем случае, в API-сервисе такой имеется(он называется `Kestrel`).
Для хостинга JS-приложения в API-сервисе, необходимо всего лишь скопировать упомянутую выше директорию \
`wwwroot` внутрь результирующей директории API-сервиса(которая получается после выполнения `dotnet publish ...`).
#### Задачи.
1. Напишите `Dockerfile`, из которого можно было бы создать образ, который отвечал бы следующим требованиям:
- Образ должен основываться на образе `mcr.microsoft.com/dotnet/aspnet:7.0`
- Готовое приложение API-сервиса вместе с зависимостями должно находиться в директории `/app`
- `JavaScript` приложение должно находиться в директории `/app/wwwroot`
- При старте контейнера из такого образа, должен запускаться приложение API-сервиса.
Документация по многоступенчатой сборке образов находится тут: https://docs.docker.com/build/building/multi-stage/
2. Создайте образ из полученного `Dockerfile` и назовите его `todo-bundle`
3. Перед запуском самого приложения, необходимо запустить и подготовить `PostgreSQL` в сети `todo`.
Создайте именованную сеть типа `bridge`, в которой будет работать вся система, назовите ее `todo`.
Создайте том данных, в котором `PostgreSQL` будет хранить БД. Назовите его `todo_pgdata`.
Запустите именованный контейнер `todo_postgres` из образа `postgres:16`, при этом:
1. При запуске установите следующие переменные окружения контейнера в значение `todo`:
- `POSTGRES_DB` - имя базы данных, к которой будет обращаться API-сервис
- `POSTGRES_USER` - пользователь БД, через которого будет работать сервис
- `POSTGRES_PASSWORD` - пароль для пользователя
2. Установите значение переменной среды `PGDATA` в значение `/var/lib/postgresql/data` - здесь `PostgreSQL` будет хранить данные.
3. Примонтируйте том данных `todo_pgdata` в качестве директории `/var/lib/postgresql/data`.
4. Примонтируйте директорию `initdb` из `todo_app` в качестве директории `/docker-entrypoint-initdb.d` внутри контейнера. \
Там находится скрипт `init_db.sql`, который используется для инициализации базы данных при первом старте контейнера.
4. Запустите контейнер `todo_bundle` в сети `todo` из созданного вами образа `todo-bundle`, при этом:
- Установите значение переменной среды `ConnectionStrings__PostgreSQL` в контейнере равной \
`Host=todo_postgres;Port=5432;Database=todo;Username=todo;Password=todo`.
Можете выбрать другой `Host` в этой строке, если вы назвали контейнер с PostgreSQL по-другому.
Аналогично - и другие параметры.
- Пробросьте порт 80 на хост-систему(например на порт 8080). \
API-сервис работает на 80м порту по умолчанию, но это можно изменить, установив значение \
переменной `ASPNETCORE_URLS` например в `http://*:5000` - тогда внутри контейнера сервис \
будет слушать порт 5000.
Убедитесь, что приложение работает и доступно на выбранном вами порту на локальной машине.
При возникновении сложностей и необходимости отладки, вы можете также установить значение переменной \
`ASPNETCORE_ENVIRONMENT` в `Development` - таким образом сервис будет выдавать больше логов.
5. Несмотря на то что API-сервис поддерживает хостинг `JavaScript` приложения, в реальном(или скорее, идеальном) мире \
никто так не делает. Над интерфейсом и API часто работают разные команды, у них может быть разный график работы, \
разное версионирование приложений, и тем более разные репозитарии, и разный подход к разработке.
Кроме того, хотя `Kestrel` - хороший веб-сервер, все же раздача статических файлов - не основная его специализация.
Поэтому, руководствуясь принципом разделения ответственности, вам необходимо разделить контейнеризацию \
API-сервиса и `JavaScript` приложения.
Остановите контейнер `todo_bundle`, удалите его и образ `todo-bundle`
Модифицируйте `Dockerfile` для API-сервиса, таким образом, чтобы убрать из него все упоминание `JavaScript` приложения \
и его этапов сборки.
Создайте образ `todo-api` на основе нового докер-файла.
Напишите конфигурацию `Nginx` для использования его как в качестве обратного прокси для API-сервиса, так и для \
раздачи файлов из `wwwroot`, получаемой после сборки `JavaScript` приложения. \
При этом:
- Учтите, что все методы API-сервера имеют префикс `/api`
- Не забудьте о том, что приложение на `JS` является `SPA`, и веб-сервер должен перенаправлять все нераспознанные пути \
на `index.html` (используйте директиву `try_files`)
- Содержимое `wwwroot` должно лежать в `/var/www/todo`
Напишите `Dockerfile` для `JavaScript` приложения, взяв за основу образ `nginx`. \
При сборке образа копируйте написанную вами конфигурацию Nginx в `/etc/nginx/nginx.conf`, \
а результат сборки `JavaScript` приложения в `/var/www/todo`.
Создайте образ `todo-ui` из этого докер-файла
6. Запустите в сети `todo` контейнер `todo_api` из образа `todo-api`, при этом:
- Не забудьте про переменную среды `ConnectionStrings__PostgreSQL`.
- Убедитесь что контейнер недоступен из внешней сети.
Запустите в сети `todo` контейнер `todo_ui` из образа `todo-ui`, при этом:
- Пробросьте порт контейнера 80(или любой другой, который вы использовали при \
написании конфигурации Nginx) на локальную машину(например, опять же на порт 8080)
Убедитесь что приложение доступно извне и работает.
7. Платформа `.NET`, на самом деле, позволяет собирать приложения, отвязанные от "внешней" предустановленной \
среды выполнения.
Хотя рекомендованный способ докеризации приложений на `.NET` - отталкиваться от образа с `mcr.microsoft.com`,
иногда возникает необходимость именно в `self-contained` приложениях.
Модифицируйте `Dockerfile` для `todo-api`, следующим образом:
- Поменяйте образ SDK для сборки на `mcr.microsoft.com/dotnet/sdk:7.0-bullseye-slim`
- Замените команду публикации приложения на \
`dotnet publish TodoApi.csproj --self-contained -r linux-x64 -c Release -o /app/publish` \
(замените `/app/publish` на директорию которую использовали вы, если она у вас отличается)
- Поменяйте результирующий исходный образ на `debian:bullseye-slim`
- В образе `debian:bullseye-slim` не хватает `ICU`, необходимого для запуска полноценного `.NET` приложения. \
Установите `libicu` в этом образе при сборке: `apt-get update && apt-get install libicu67`
Пересоберите образ `todo-api`, и перезапустите контейнер `todo_api` на его основе, проверьте что всё работает.
### 2. Политики перезапуска и базовый мониторинг
1. Запустите `todo_ui`, `todo_api` и `todo_postgres` так, чтобы они перезапускались при ошибке или же \
при перезапуске демона Docker.
Перезапустите демон Docker, или же вообще, сделайте перезагрузку машины, \
и проверьте что контейнеры запустились при старте Docker.
2. Перезапустите контейнер `todo_postges`, так чтобы healthcheck:
- Запускался командой `pg_isready -U todo -d todo`
- Имел интервал 10 секунд
- Имел timeout 5 секунд
- Проверка имела бы до 5 попыток старта в случае неудачи
- Начиналась бы после 10 секунд после старта контейнера
### 3. Ограничение ресурсов
1. Перезапустите контейнеры `todo_ui`, `todo_api` и `todo_postgres`, так они имели ограничение по памяти в \
300, 500 и 800 мегабайт соответственно. Поэкспериментируйте с ограничениями и посмотрите что выходит, \
в случае их превышения.
2. Перезапустите контейнеры `todo_ui`, `todo_api` и `todo_postgres`, так они имели ограничение по ресурсам процессора в \
1, 1 и 2 соответственно
### 4. Docker Compose
1. Убедитесь что docker compose установлен, проверьте версию.
2. Напишите `docker-compose.yml` для контейнера на основе `busybox`, который бы печатал `Hello, World` при запуске. Запустите.
3. Напишите конфигурацию compose для приложения `cats_app`. \
Сделайте так, чтобы образ `cats_app` собирался, если его еще не существует. \
Запустите приложение через docker compose, используя эту конфигурацию. \
Убедитесь в том, что приложение работает.
4. Напишите конфигурацию compose для вышеописанной системы todo. При этом:
- Добавьте туда как healthcheck, так и ограничения ресурсов описанные выше
- Сделайте так чтобы контейнер `todo_api` зависел от `todo_postgres`, а `todo_ui` - от `todo_api`. \
Сделайте так, чтобы образы `todo-ui` и `todo-api` собирались, если они еще не существуют. \
Запустите систему через docker compose, и убедитесь что все работает.
5. Добавьте контейнер из образа `dpage/pgadmin4:7` в compose файл к вышеописанной системе, так чтобы он подключался к `PostgreSQL`\
и позволял смотреть данные приложения `todo`. \
При этом установите следующие переменные среды в контейнере в соответствующие значения:
- `PGADMIN_DEFAULT_EMAIL` = `todo@example.com`
- `PGADMIN_DEFAULT_PASSWORD` = `todo`
- `PGADMIN_CONFIG_SERVER_MODE` = `False`
### 5. Docker Swarm

@ -0,0 +1,7 @@
**/node_modules
**/.vs
**/.vscode
**/wwwroot
**/bin
**/obj
**/publish

@ -0,0 +1,3 @@
Dockerfile
docker-compose.yml
nginx.conf

@ -0,0 +1,7 @@
**/node_modules
**/.vs
**/.vscode
**/wwwroot
**/bin
**/obj
**/publish

@ -0,0 +1,18 @@
*~
.DS_Store
bin/
obj/
publish/
.vs/
wwwroot/
.vscode/
!.vscode/extensions.json
*.exe
*.dll
.idea
*.suo
*.ntvs*
*.njsproj
*proj.user
*.sw?
coverage

@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TodoApi.Model;
namespace TodoApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class TodoController : ControllerBase
{
private const int MaxResultCount = 100;
private readonly TodoDbContext _db;
private readonly ILogger<TodoController> _logger;
public TodoController(TodoDbContext db, ILogger<TodoController> logger)
{
_db = db;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] int? skip,
[FromQuery] int? take,
[FromQuery] string? search,
[FromQuery] bool? completed)
{
var q = _db.Todos.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
q = q.Where(e => e.Description.ToLower().Contains(search.ToLower()));
}
if (completed.HasValue)
{
q = q.Where(e => e.IsCompleted == completed.Value);
}
var totalCount = await q.CountAsync();
if (skip is { } s)
{
q = q.Skip(s);
}
if (take is { } t)
{
q = q.Take(Math.Min(t, MaxResultCount));
}
var result = new
{
Items = await q.ToArrayAsync(),
totalCount
};
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] NewTodo input)
{
var todo = new Todo
{
Description = input.Description,
CreatedAt = DateTimeOffset.UtcNow,
IsCompleted = false
};
await _db.AddAsync(todo);
await _db.SaveChangesAsync();
return Ok(todo);
}
[HttpPost("toggle")]
public async Task<IActionResult> Toggle(ToggleModel vm)
{
var now = DateTimeOffset.UtcNow;
await _db.Todos.Where(e => vm.Ids.Contains(e.Id))
.ExecuteUpdateAsync(setters =>
setters
.SetProperty(e => e.IsCompleted, e => vm.IsCompleted)
.SetProperty(e => e.UpdatedAt, now));
return Ok();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
await _db.Todos.Where(e => e.Id == id).ExecuteDeleteAsync();
return Ok();
}
[HttpDelete("completed")]
public async Task<IActionResult> DeleteCompleted()
{
await _db.Todos.Where(e => e.IsCompleted)
.ExecuteDeleteAsync();
return Ok();
}
[HttpGet("status")]
public async Task<IActionResult> Status()
{
var result = await
(from todo in _db.Todos
group todo by 1 into g
select new
{
totalCount = g.Count(),
left = g.Count(e => !e.IsCompleted)
}).FirstOrDefaultAsync();
return Ok(result);
}
}
}

@ -0,0 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TodoApi.Model
{
public record NewTodo([Required] string Description);
}

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace TodoApi.Model
{
public class Todo
{
[Required]
public int Id { get; set; }
[Required]
public bool IsCompleted { get; set; }
[Required]
public string Description { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}
}

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
namespace TodoApi.Model
{
public class TodoDbContext : DbContext
{
public TodoDbContext(DbContextOptions options)
: base(options)
{ }
public DbSet<Todo> Todos { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("todo_mvc");
var entityBuilder = modelBuilder.Entity<Todo>();
entityBuilder.ToTable("todo");
entityBuilder.Property(e => e.Id)
.IsRequired()
.HasColumnName("id")
.HasColumnType("int")
.UseIdentityColumn();
entityBuilder.Property(e => e.IsCompleted)
.IsRequired()
.HasColumnName("is_completed")
.HasColumnType("boolean");
entityBuilder.Property(e => e.Description)
.IsRequired()
.HasColumnName("description")
.HasColumnType("text");
entityBuilder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at")
.HasColumnType("timestamptz");
entityBuilder.Property(e => e.UpdatedAt)
.IsRequired(false)
.HasColumnName("updated_at")
.HasColumnType("timestamptz");
}
}
}

@ -0,0 +1,8 @@
using System.ComponentModel.DataAnnotations;
namespace TodoApi.Model
{
public record ToggleModel(
[Required] bool IsCompleted,
[Required] int[] Ids);
}

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using TodoApi.Model;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
// Add services to the container.
builder.Services.AddDbContext<TodoDbContext>(o =>
{
o.UseNpgsql(config.GetConnectionString("PostgreSQL"), opt =>
{
opt.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
});
});
builder.Services.AddControllers()
.AddJsonOptions(o =>
{
var enumConverter = new JsonStringEnumConverter();
o.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.JsonSerializerOptions.AllowTrailingCommas = true;
o.JsonSerializerOptions.Converters.Add(enumConverter);
});
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseStaticFiles();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://127.0.0.1:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" />
</ItemGroup>
</Project>

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34221.43
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApi", "TodoApi.csproj", "{4A79D341-9D8E-42E4-91EE-CC97434DEE6A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4A79D341-9D8E-42E4-91EE-CC97434DEE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A79D341-9D8E-42E4-91EE-CC97434DEE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A79D341-9D8E-42E4-91EE-CC97434DEE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A79D341-9D8E-42E4-91EE-CC97434DEE6A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {331ECF0A-6F52-4E8C-96C4-B9830B05F843}
EndGlobalSection
EndGlobal

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Trace"
}
}
}

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"PostgreSQL": "Host=localhost;Port=5432;Database=todo;Username=todo;Password=todo"
},
"AllowedHosts": "*"
}

@ -0,0 +1,18 @@
do $$
begin
create schema if not exists todo_mvc;
set search_path to todo_mvc;
end $$;
do $$
begin
create table if not exists todo (
id int not null generated by default as identity,
is_completed boolean not null,
description text not null,
created_at timestamptz not null default now(),
updated_at timestamptz null,
constraint todo_pkey primary key(id)
);
end $$;

@ -0,0 +1,7 @@
**/node_modules
**/.vs
**/.vscode
**/wwwroot
**/bin
**/obj
**/publish

@ -0,0 +1,14 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
wwwroot/
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*proj.user
*.sw?

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

@ -0,0 +1,35 @@
# todo-ui
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicon96.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo List</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,34 @@
{
"name": "todo-ui",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@popperjs/core": "^2.11.8",
"axios": "^1.6.1",
"bootstrap": "^5.3.2",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.5.1",
"@vitejs/plugin-vue": "^4.4.1",
"@vue/eslint-config-prettier": "^8.0.0",
"eslint": "^8.53.0",
"eslint-plugin-vue": "^9.18.1",
"prettier": "^3.0.3",
"sass": "^1.69.5",
"vite": "^4.5.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

@ -0,0 +1,22 @@
<template>
<div class="wrapper h-100 d-flex flex-column">
<div class="content flex-grow-1 flex-shrink-0">
<RouterView></RouterView>
</div>
<footer class="w-100 p-2">
Favicon by <a href="https://icons8.com" class="text-secondary">icons8.com</a>
</footer>
</div>
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>
<style scoped lang="scss">
.wrapper {
>footer {
flex-shrink: 0;
}
}
</style>

@ -0,0 +1,12 @@
@import "~bootstrap/scss/bootstrap.scss";
html,
body,
#app {
height: 100%;
font-size: 1em;
}
body {
background: #f5f5f5;
}

@ -0,0 +1,16 @@
<template>
<div class="container">
<div class="row p-3">
<h1 class="col justify-content-center text-center not-found-header text-secondary">No such page!</h1>
</div>
<div class="row p-3">
<h1 class="col justify-content-center text-center not-found-header text-secondary">404</h1>
</div>
</div>
</template>
<style scoped>
.not-found-header {
font-size: 4rem;
}
</style>

@ -0,0 +1,84 @@
<template>
<div class="card mb-0 rounded-0 border-top-0" @mouseover="hover = true" @mouseleave="hover = false">
<div class="card-body my-0 py-0">
<div class="container p-0 m-0">
<div class="row">
<div class="col-1 d-flex flex-row justify-content-start align-items-center p-0 ps-lg-2">
<div class="form-check form-switch m-0">
<input class="form-check-input" :checked="isCompleted" @change="toggle" type="checkbox"
role="switch" :disabled="disabled">
</div>
</div>
<div class="col-10 d-flex">
<div class="vr me-2 h-100"></div>
<span class="description py-2" :class="{ 'completed': isCompleted }">
{{ description }}
</span>
</div>
<div class="col-1 d-flex flex-row align-items-center" v-if="hover">
<button class="btn btn-trash p-0 m-0 shadow-none border-0" @click="emit('delete')">
<font-awesome-icon icon="fa-solid fa-trash-can fa-5x" class="trash-icon" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, toRef } from 'vue'
const props = defineProps({
'isCompleted': {
type: Boolean,
default: false
},
'description': {
type: String,
default: ""
},
'disabled': {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle', 'delete'])
const hover = ref(false)
const isCompleted = toRef(() => props.isCompleted)
const disabled = toRef(() => props.disabled)
function toggle() {
emit('toggle')
}
</script>
<style scoped lang="scss">
.completed {
text-decoration: line-through;
}
.description {
font-size: 1.3rem;
}
.btn-trash {
color: var(--bs-warning);
&:hover {
color: var(--bs-danger);
}
.trash-icon {
width: 100%;
height: 100%;
}
}
.vr {
min-height: 2rem;
}
</style>

@ -0,0 +1,295 @@
<template>
<div class="container">
<div class="row p-3">
<h1 class="col justify-content-center text-center todo-header text-secondary">todo</h1>
</div>
<div class=row>
<div class="col-lg-5 offset-lg-4">
<div class="card mb-0 rounded-bottom-0">
<div class="card-body py-0">
<div class="container p-0 m-0">
<div class="row">
<div class="col-1 d-flex flex-row justify-content-start align-items-center p-0 ps-lg-2">
<div class="form-check form-switch m-0" v-if="items.length">
<input class="form-check-input" @change="onToggleAll" type="checkbox"
:disabled="isLoading || isEmpty" :checked="isCompleted" role="switch" />
</div>
</div>
<div class="col-11 d-flex">
<div class="vr me-2 h-100"></div>
<form @submit.prevent="onSubmit" class="w-100 h-100">
<input type="text" placeholder="What's next to be done?"
class="form-control shadow-none my-2 ps-0 border-0 new-input"
v-model="newTodo" />
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-5 offset-lg-4">
<TodoItem v-for="item in items" :key="item.id" :isCompleted="item.isCompleted"
:description="item.description" :disabled="isLoading" @delete="deleteItem(item.id)"
@toggle="onToggle(item)">
</TodoItem>
</div>
</div>
<div class="row">
<div class="col-lg-5 offset-lg-4">
<div class="card mb-0 rounded-top-0 border-top-0">
<div class="card-body py-0">
<div class="container p-0 m-0">
<div class="row">
<div class="col-3 col-lg-2 py-2">
<span v-if="left" class="text-secondary">
{{ left }} left
</span>
</div>
<div class="col col-lg-7 d-flex flex-row justify-content-evenly align-items-center py-2">
<RouterLink :to="{ name: 'all' }" class="btn btn-outline-secondary px-2 py-0 mx-1">
All
</RouterLink>
<RouterLink :to="{ name: 'active' }" class="btn btn-outline-secondary px-2 py-0 mx-1">
Active
</RouterLink>
<RouterLink :to="{ name: 'completed' }"
class="btn btn-outline-secondary px-2 py-0 mx-1">
Completed
</RouterLink>
</div>
<div class="col col-lg-3 py-2 text-end">
<a href="#" v-if="totalCount - left" @click.prevent="deleteCompleted"
class="clear-link text-secondary">Clear
completed</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="loading-overlay position-absolute" v-if="isLoading">
<div class="d-flex justify-content-center">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { onBeforeRouteUpdate, useRoute, RouterLink } from 'vue-router'
import svc from '../services/todoService'
import { useDebouncedRef } from '../services/composables'
import TodoItem from './TodoItem.vue';
const route = useRoute()
const totalCount = ref(0)
const left = ref(0)
const search = ref()
const items = ref([])
const newTodo = ref('')
const isCompleted = ref(false)
const isLoading = useDebouncedRef(false, 500)
const routeName = ref(route.name)
const completedFilter = computed(() => {
const name = routeName.value
if (name == 'active') {
return false
} else if (name == 'completed') {
return true
} else {
return null
}
})
const isEmpty = computed(() => !totalCount.value)
async function updateStatus() {
try {
const status = await svc.status()
totalCount.value = status.totalCount
left.value = status.left
if (items.value.length > 0) {
isCompleted.value = items.value.every(todo => todo.isCompleted)
} else {
isCompleted.value = false
}
} catch (e) {
console.log(e)
}
}
async function refresh() {
isLoading.value = true
try {
const rv = await svc.getAll({
search: search.value,
completed: completedFilter.value
})
items.value = rv.items
totalCount.value = rv.totalCount
await updateStatus()
} catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
}
async function deleteItem(id) {
isLoading.value = true
try {
await svc.delete(id)
items.value = items.value.filter(todo => {
return todo.id != id
})
await updateStatus()
} catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
}
async function onSubmit() {
const description = newTodo.value
if (description) {
isLoading.value = true
try {
const todo = await svc.create(description)
newTodo.value = null
if (routeName.value == 'all' || routeName.value == 'active') {
const newItems = [...items.value]
newItems.push(todo)
items.value = newItems
}
await updateStatus()
}
catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
}
}
async function onToggle(todo) {
const idx = items.value.findIndex(v => v.id == todo.id)
if (idx >= 0) {
isLoading.value = true
try {
todo = { ...todo }
todo.isCompleted = !todo.isCompleted
await svc.toggle(todo.isCompleted, [todo.id])
if (route.name == 'all' ||
(route.name == 'active' && !todo.isCompleted) ||
(route.name == 'completed' && todo.isCompleted)) {
items.value[idx] = todo
} else {
items.value.splice(idx, 1)
}
await updateStatus()
} catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
}
}
async function onToggleAll() {
isLoading.value = true
try {
const newValue = !isCompleted.value
const ids = items.value.map(v => v.id)
await svc.toggle(newValue, ids)
if ((routeName.value == 'active' && newValue) ||
(routeName.value == 'completed' && !newValue)) {
items.value.splice(0, items.value.length)
} else {
items.value = items.value.map(todo => {
todo = { ...todo }
todo.isCompleted = newValue
return todo
})
}
await updateStatus()
}
catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
}
async function deleteCompleted() {
isLoading.value = true
try {
await svc.deleteCompleted();
} catch (e) {
console.log(e)
} finally {
isLoading.value = false
}
await refresh()
}
onMounted(refresh)
onBeforeRouteUpdate(async to => {
routeName.value = to.name
await refresh()
})
</script>
<style scoped lang="scss">
.todo-header {
font-size: 4rem;
}
.new-input {
&::placeholder {
font-style: italic;
color: var(--bs-gray-500);
font-weight: 600;
}
}
.loading-overlay {
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 2;
background-color: rgba(0, 0, 0, 0.2);
>div {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.spinner-border {
width: 3rem;
height: 3rem;
}
}
.clear-link {
font-size: 0.9rem;
}
.router-link-active {
background-color: var(--bs-secondary);
color: var(--bs-btn-active-color)
}
</style>

@ -0,0 +1,21 @@
import './assets/main.scss'
// eslint-disable-next-line no-unused-vars
import * as bootstrap from 'bootstrap'
import { library as iconLibrary } from '@fortawesome/fontawesome-svg-core'
import {
faTrashCan
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
iconLibrary.add(faTrashCan)
const app = createApp(App)
app.component("font-awesome-icon", FontAwesomeIcon)
app.use(router)
app.mount('#app')

@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import TodoList from '../components/TodoList.vue'
import NotFound from '../components/NotFound.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: TodoList,
redirect: 'all',
children: [
{
path: 'all',
name: 'all',
component: TodoList
},
{
path: 'active',
name: 'active',
component: TodoList
},
{
path: 'completed',
name: 'completed',
component: TodoList
}
]
},
{ path: '/404', component: NotFound, name: 'NotFound' },
{ path: '/:pathMatch(.*)*', redirect: { name: 'NotFound', params: {} } }
]
})
export default router

@ -0,0 +1,38 @@
import { ref, customRef } from 'vue'
const DEFAULT_DEBOUNCE_TIMEOUT = 1000
function debounce(f, delay = DEFAULT_DEBOUNCE_TIMEOUT, immediate = false) {
let timeout
return (...args) => {
if (immediate && !timeout) {
f(...args)
}
clearTimeout(timeout)
timeout = setTimeout(() => {
f(...args)
}, delay)
}
}
function useDebouncedRef(initialValue, delay = DEFAULT_DEBOUNCE_TIMEOUT, immediate = false) {
const state = ref(initialValue)
const debouncedRef = customRef((track, trigger) => ({
get() {
track()
return state.value
},
set: debounce(value => {
state.value = value
trigger()
},
delay,
immediate)
}))
return debouncedRef
}
export {
useDebouncedRef
}

@ -0,0 +1,56 @@
import axios from 'axios'
const TIMEOUT_MS = 5000
function send(method, url, {params, data} = {}) {
return axios({
method,
url,
params,
data,
timeout: TIMEOUT_MS
})
}
export default {
async getAll({search, skip, take, completed} = {}) {
let result = await send('GET', '/api/todo', {
params: {
search,
skip,
take,
completed
}
})
return result.data
},
async status() {
let result = await send('GET', '/api/todo/status')
return result.data
},
async create(description) {
let result = await send('POST', '/api/todo', {
data: {
description
}
})
return result.data
},
async toggle(isCompleted, ids = []) {
let result = await send('POST', '/api/todo/toggle', {
data: {
isCompleted,
ids
}
})
return result.data
},
async delete(id) {
let result = await send('DELETE', `/api/todo/${id}`)
return result.data
},
async deleteCompleted() {
let result = await send('DELETE', '/api/todo/completed')
return result.data
}
}

@ -0,0 +1,30 @@
import * as path from 'node:path'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const currentDir = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': path.join(currentDir, 'src/'),
'~bootstrap': path.resolve(currentDir, 'node_modules/bootstrap'),
}
},
build: {
outDir: path.resolve(currentDir, 'wwwroot')
},
server: {
port: 8080,
hot: true,
proxy: {
'/api': {
target: 'http://localhost:5000'
}
}
}
})
Loading…
Cancel
Save