Merge pull request 'Advanced dockerization' (#3) from v2 into main

Reviewed-on: #3
pull/5/head
Vladimir Protsenko 12 months ago
commit 65d019b91f

@ -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

6
.gitignore vendored

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

@ -134,7 +134,7 @@ https://docs.docker.com/engine/reference/commandline/cli/ (и на соседн
9. Запустите контейнер из образа `registry:2`, примонтировав куда-либо директорию контейнера \ 9. Запустите контейнер из образа `registry:2`, примонтировав куда-либо директорию контейнера \
`/var/lib/registry` чтобы использовать ее как постоянное хранилище, и открыв порт 5000 для этого контейнера, \ `/var/lib/registry` чтобы использовать ее как постоянное хранилище, и открыв порт 5000 для этого контейнера, \
перенаправив его на порт 1235 для локальной машины(перенаправьте именно для локальной машины \ перенаправив его на порт 12345 для локальной машины(перенаправьте именно для локальной машины \
дабы реестр контейнеров не был доступен извне). \ дабы реестр контейнеров не был доступен извне). \
10. Теперь у вас на машине, а именно на `localhost:12345`, запущен Docker Registry, и вы можете сохранять образы туда. \ 10. Теперь у вас на машине, а именно на `localhost:12345`, запущен Docker Registry, и вы можете сохранять образы туда. \

@ -1,9 +1,63 @@
## Инструменты Docker ## Инструменты Docker
### 1. Многоконтейнерные приложения ### 1. Многоступенчатая сборка образов
Docker Compose — это инструмент для запуска многоконтейнерных приложений в Docker, определенных с использованием формата файлов Compose. Файл Compose используется для определения того, как настроены один или несколько контейнеров, составляющих ваше приложение. Когда у вас есть файл Compose, вы можете создать и запустить приложение с помощью одной команды: docker compose up. В качестве типичного примера приложения можно привести веб-сайт, состоящий из контейнера фронтэнда и контейнера бэкенда. Docker позволяет задействовать несколько исходных образов при сборке результирующего, при этом используя только один Dockerfile.
### 2. Кластерные приложения Каждая стадия сборки может обмениваться файлами с предыдущими.
Docker Swarm это простой оркестратор для контейнеров, который доступен из коробки. Позволяет объединить докер демоны на разных машинах в кластер, что даёт возможность поставки распределённых приложений упакованных в контейнеры. Swarm, так же как и Compose, имеет декларативную модель описания кластерного приложения. Типичными задачами для такого типа приложений являются организация высокой доступности, балансировки нагрузки и канареечного обновления сервиса. Также вы можете добиться эластичности распределённого приложения, которое создаёт и удаляет контейнеры в зависимости от поступающей нагрузки. Эта возможность бывает особенно полезна при создании крупных приложений, состоящих из нескольких разных частей, использующих разные языки программирования и разные платформы.
### 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 позволяет настраивать кластеризованные приложения как с помощью интерфейса командной строки, так и с декларативно, наподобие Docker Compose. Типичными задачами для такого типа приложений являются организация высокой доступности, балансировки нагрузки и канареечного обновления сервиса. Также вы можете добиться эластичности распределённого приложения, которое создаёт и удаляет контейнеры в зависимости от поступающей нагрузки.
Имейте ввиду, что разделение томов данных в кластере имеет свои особенности, о которых подробно можно почитать по ссылкам ниже, но вот некоторые из них.
Во-первых, стандартный драйвер томов данных в Docker имеет говорящее название `local`, и таким образом, при использовании его в кластере,
и при монтировании тома с помощью, к примеру, команды `docker service create --mount type=volume...`,
том данных будет локально создаваться на каждом узле(node, т.е. инсталляции Docker) Swarm, на котором запускается сервис. \
Это часто является нежелательным поведением, поэтому для кластеров часто используются специфические драйвера томов данных, такие как `NFS` или `GlusterFS`.
Во-вторых, тому данных можно задать область видимости(scope) - `single` или `multi`, что означает соответственно возможность доступа из одного или нескольких узлов.
В-третьих, тому данных можно задать ограничения на возможности чтения и записи, такие как
- `none` - доступ к тому возможен только из одного узла
- `readonly` - доступ к тому возможен только на чтение
- `onewriter` - доступ к тому на запись возможен только с одного узла одновременно, доступ на чтение - отовсюду
- `all` - никаких ограничений на чтение и запись
Также, в кластере Docker Swarm часто используются специальные `overlay` сети, которые позволяют разделять виртуальную сеть между разными машинами.
## Ссылки:
- https://docs.docker.com/engine/reference/builder/
- https://docs.docker.com/compose/
- https://docs.docker.com/compose/compose-file/
- https://docs.docker.com/get-started/swarm-deploy/
- https://docs.docker.com/engine/swarm/swarm-tutorial/
- https://docs.docker.com/storage/volumes/#share-data-between-machines
- https://www.optimum-web.com/shared-storage-volumes-in-docker-swarm/
- https://github.com/moby/moby/blob/master/docs/cluster_volumes.md
- https://github.com/moby/moby/issues/39624
- https://thenewstack.io/tutorial-create-a-docker-swarm-with-persistent-storage-using-glusterfs/

@ -1,2 +1,16 @@
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 сети?
12. Какие бывают виды узлов(node) Docker Swarm и чем они отличаются?
13. Чем отличаются сервис, таск и контейнер?
14. Какие существуют режимы(mode) запуска сервисов, и чем они отличаются?
15. Как привязать сервис к конкретному узлу Docker Swarm?

@ -0,0 +1,251 @@
# Задания
## 1. Многоступенчатая сборка образов
В полученной вами директории `todo_app` находятся исходные коды Enterprise Quality™® системы для менеджмента напоминаний.
Она состоит из следующих частей:
- `TodoApi` - API-сервис, на платформе `.NET 7`,
- `todo_ui` - веб-интерфейс к этому сервису, Single-Page `JavaScript` приложение(`SPA`), написанное с использованием `Vue.js 3`,
- `PostgreSQL` - API-сервис использует `PostgreSQL` для хранения данных.
Необходимо будет контейнеризировать эту систему.
В этот раз, к сожалению, программисты не оставили никаких скриптов, но вы можете написать их сами, по ходу работы. Некоторые команды и их аргументы будут довольно длинные.
### 1.1 Контейнеризация приложения
#### 1.1.1 Подготовка образа для `TodoApi` и `todo_ui`
Используя подход к многоступенчатой сборке образов, напишите `Dockerfile`, из которого можно было бы создать образ,
и который отвечал бы следующим требованиям:
- Фаза сборки API-сервиса должна использовать образ `mcr.microsoft.com/dotnet/sdk:7.0`
- Фаза сборки `JavaScript` приложения должна использовать образ `node:21`
- Результирующий образ должен основываться на `mcr.microsoft.com/dotnet/aspnet:7.0`
- Готовое приложение API-сервиса вместе с зависимостями должно находиться в директории `/app`
- `JavaScript` приложение должно находиться в директории `/app/wwwroot`
- При старте контейнера из такого образа, должно запускаться приложение API-сервиса.
Документация по многоступенчатой сборке образов находится тут: https://docs.docker.com/build/building/multi-stage/.
Фаза сборки сервиса `TodoApi` состоит из следующих шагов:
- Во-первых, для его сборки необходим .NET 7 SDK
- Далее, из директории, в которой находятся исходные коды сервиса(см. `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` - в данном случае версии `7.0`. Запуск осуществляется вызовом исполняемого файла `./TodoApi` из директории, которая хранит опубликованные бинарные файлы сервиса и его зависимостей.
Фаза сборки `JavaScript` приложения состоит из следующих шагов:
- Прежде всего, необходим `NodeJS` - в данном случае подойдет версия `21`.
- Далее, из директории, в которой находятся исходные коды приложения(см. `todo_app/todo_ui`),
нужно вызывать `npm install` - это команда скачает зависимости приложения.
- После этого, в этой же директории необходимо выполнить команду `npm run build`, в результате чего,
в этой директории появится директория `wwwroot`, содержащая файл `index.html`,
результирующий код на JS, а также его ресурсы, такие как картинки и файлы стилей(css). \
Перед выполнением этой команды, также можно установить переменную среды `BASE_PATH`, в значение, \
являющееся префиксом в пути URL, по которому приложение будет находится. По умолчанию, `BASE_PATH` \
трактуется как имеющее значение `/`, таким образом приложение должно раздаваться веб-сервером из корня сайта. \
Если же вы хотите, чтобы приложение находилось например по адресу `http://<домен>/todo/`, тогда необходимо \
установить `BASE_PATH` в значение `/todo/` соответственно. Для установки переменной среды, доступной во время \
сборки образа, используйте директиву `ARG` внутри докер-файла(и соответственно опцию `--build-arg` при вызове `docker build`).
Для запуска `JavaScript` приложения `NodeJS` не нужен, достаточно лишь любого веб-сервера, и в
нашем случае, в API-сервисе такой имеется(он называется `Kestrel`). Для хостинга JS-приложения в API-сервисе, необходимо всего лишь скопировать упомянутую выше директорию `wwwroot` внутрь результирующей директории API-сервиса(которая получается после выполнения `dotnet publish ...`).
Создайте образ из полученного `Dockerfile` и назовите его `todo-bundle`.
#### 1.1.2 Подготовка образа PostgreSQL
Перед запуском самого приложения, необходимо запустить и подготовить `PostgreSQL` в сети `todo`.
Создайте именованную сеть типа `bridge`, в которой будет работать вся система, назовите ее `todo`.
Создайте том данных, в котором `PostgreSQL` будет хранить БД. Назовите его `todo_pgdata`.
Запустите именованный контейнер `todo_postgres` из образа `postgres:16`.
При этом:
- При запуске установите следующие переменные окружения контейнера в значение `todo`:
- `POSTGRES_DB` - имя базы данных, к которой будет обращаться API-сервис
- `POSTGRES_USER` - пользователь БД, через которого будет работать сервис
- `POSTGRES_PASSWORD` - пароль для пользователя
- Установите значение переменной среды `PGDATA` в значение `/var/lib/postgresql/data` - здесь `PostgreSQL` будет хранить данные.
- Примонтируйте том данных `todo_pgdata` в качестве директории `/var/lib/postgresql/data`.
- Примонтируйте директорию `initdb` из `todo_app` в качестве директории `/docker-entrypoint-initdb.d` внутри контейнера.
Там находится скрипт `init_db.sql`, который используется для инициализации базы данных при первом старте контейнера.
#### 1.1.3 Проверка работоспособности контейнеризованного приложения
Запустите контейнер `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` - таким образом сервис будет выдавать больше логов.
### 1.2 Рефакторинг контейнеризованного приложения
#### 1.2.1 Декомпозиция образа `todo-bundle`
Несмотря на то что 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` из этого докер-файла.
#### 1.2.2 Проверка работоспособности контейнеризованного приложения
Запустите в сети `todo` контейнер `todo_api` из образа `todo-api`, при этом:
- Не забудьте про переменную среды `ConnectionStrings__PostgreSQL`.
- Убедитесь что контейнер недоступен из внешней сети.
Запустите в сети `todo` контейнер `todo_ui` из образа `todo-ui`, при этом:
- Пробросьте порт контейнера 80(или любой другой, который вы использовали при написании конфигурации Nginx) на локальную машину(например, опять же на порт 8080)
Убедитесь что приложение доступно извне и работает.
#### 1.2.3 Рефакторинг образа `todo-api`
Платформа `.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`
#### 1.2.4 Проверка работоспособности контейнеризованного приложения
Пересоберите образ `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`
Убедитесь что консоль администрирования недоступна из интернета, и чтобы проверить ее работоспособность, можно, например, \
прокинуть SSH-туннель от вашей машины, до машины с Docker, например таким образом: \
`ssh -L 5051:localhost:5050 stud@studN.myoffice.ru`. \
где вместо 5050 - подставьте порт, на котором pgadmin доступен изнутри машины `studN.myoffice.ru`. \
После выполнения этой команды, pgadmin будет доступен на вашей машине, на http://localhost:5051
Про SSH туннели подробнее можете почитать здесь: \
https://unix.stackexchange.com/questions/115897/whats-ssh-port-forwarding-and-whats-the-difference-between-ssh-local-and-remot
### 5. Docker Swarm
1. Инициализируйте Docker в режим Swarm
2. Выведите на экран список нод Docker Swarm
3. Выведите токен для присоединения к Swarm в качестве worker
4. Создайте сеть Docker типа `overlay` с именем `todo_swarm`
5. Создайте том данных `todo_swarm_pgdata` так, чтобы в него мог писать только 1 сервис одновременно
6. Запустите образ `postgres:16` как сервис Swarm с именем `todo_postgres`, подключенный к сети `todo_swarm`, с количеством реплик равному 1, при этом:
- При запуске установите следующие переменные окружения контейнера в значение `todo`:
- `POSTGRES_DB` - имя базы данных, к которой будет обращаться API-сервис
- `POSTGRES_USER` - пользователь БД, через которого будет работать сервис
- `POSTGRES_PASSWORD` - пароль для пользователя
- Установите значение переменной среды `PGDATA` в значение `/var/lib/postgresql/data` - здесь `PostgreSQL` будет хранить данные.
- Примонтируйте том данных `todo_swarm_pgdata` в качестве директории `/var/lib/postgresql/data`.
- Примонтируйте директорию `initdb` из `todo_app` в качестве директории `/docker-entrypoint-initdb.d` внутри контейнера.\
Там находится скрипт `init_db.sql`, который используется для инициализации базы данных при первом старте контейнера.
7. Запустите образ `todo-api` как сервис Swarm с именем `todo_api`, подключенный к сети `todo_swarm`, с количеством реплик равному 1, при этом:
- Установите переменную окружения `ConnectionStrings__PostgreSQL` \
в значение `Host=todo_postgres;Port=5432;Database=todo;Username=todo;Password=todo`
- Установите переменную окружения `ASPNETCORE_URLS` в `http://*:80`
8. Запустите образ `todo-ui` как сервис Swarm с именем `todo_ui`, подключенный к сети `todo_swarm`, с количеством реплик равному 1, при этом:
- Пробросьте порт 80 на внешний порт 8080
9. Убедитесь, что приложение доступно на порту 8080
10. Увеличьте количество реплик каждого из сервисов `todo_ui` и `todo_api` до 3. Убедитесь что приложение работает.
11. Выведите листинг реплик сервисов `todo_api` и `todo_ui`
12. Просмотрите логи сервиса `todo_api`. Убедитесь что запросы подхватываются разными репликами.
13. Удалите все три сервиса
14. Напишите compose-файл для этих сервисов, который бы также запускал по 3 реплики `todo_ui` и `todo_api` и 1 реплику `todo_postgres`
- Отталкивайтесь от ранее вами написанного файла для docker compose
15. Запустите docker stack, исходя из написанной вами конфигурации, и убедитесь в работоспособности системы

@ -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,58 @@
import axios from 'axios'
const TIMEOUT_MS = 5000
const BASE_URL = import.meta.env.BASE_URL ?? '/';
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', `${BASE_URL}api/todo`, {
params: {
search,
skip,
take,
completed
}
})
return result.data
},
async status() {
let result = await send('GET', `${BASE_URL}api/todo/status`)
return result.data
},
async create(description) {
let result = await send('POST', `${BASE_URL}api/todo`, {
data: {
description
}
})
return result.data
},
async toggle(isCompleted, ids = []) {
let result = await send('POST', `${BASE_URL}api/todo/toggle`, {
data: {
isCompleted,
ids
}
})
return result.data
},
async delete(id) {
let result = await send('DELETE', `${BASE_URL}api/todo/${id}`)
return result.data
},
async deleteCompleted() {
let result = await send('DELETE', `${BASE_URL}api/todo/completed`)
return result.data
}
}

@ -0,0 +1,32 @@
import * as path from 'node:path'
import * as process from 'node:process'
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')
},
base: ((process.env.BASE_PATH ?? '').replace(/\/$/, '') ?? '') + '/',
server: {
port: 8080,
hot: true,
proxy: {
'/api': {
target: 'http://localhost:5000'
}
}
}
})
Loading…
Cancel
Save