Compare commits
No commits in common. "feature/eccm-daily-report-v2" and "master" have entirely different histories.
feature/ec
...
master
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
// TODO:
|
|
||||||
"useForProjects": [
|
|
||||||
"Test project name 1",
|
|
||||||
"Test project name 2"
|
|
||||||
],
|
|
||||||
|
|
||||||
// TODO:
|
|
||||||
"customFields": [
|
|
||||||
{
|
|
||||||
"dateFormat": "yyyy-MM-dd HH:mm",
|
|
||||||
"customFieldName": "Название кастомного поля 1",
|
|
||||||
"alias": "Название кастомного поля 1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"descriptionCalendarParams": {
|
|
||||||
"title": "Календарь:",
|
|
||||||
"lineRegexp": "(?<=\\*\\ ).+"
|
|
||||||
},
|
|
||||||
"calendarEventsKey": "calendar"
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"New": "dev",
|
|
||||||
"In Progress": "dev",
|
|
||||||
"Re-opened": "dev",
|
|
||||||
"Code Review": "cr",
|
|
||||||
"Resolved": "qa",
|
|
||||||
"Testing": "qa",
|
|
||||||
"Wait Release": "dev",
|
|
||||||
"Pending": "dev",
|
|
||||||
"Feedback": "qa",
|
|
||||||
"Closed": "dev",
|
|
||||||
"Rejected": "dev",
|
|
||||||
"*": "dev"
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// TODO:
|
|
||||||
{
|
|
||||||
"currentVersions": ["1", "0.1"],
|
|
||||||
"projectName": "Test project name 1",
|
|
||||||
"currentIssuesStatuses": ["In Progress", "Re-opened", "Code Review", "Resolved", "Testing", "Feedback"],
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"name": "Dev backend",
|
|
||||||
"people": [
|
|
||||||
"User Developer"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "QA",
|
|
||||||
"people": [
|
|
||||||
"Redmine Admin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dailyTime": {
|
|
||||||
"hour": 10,
|
|
||||||
"minute": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{
|
|
||||||
|
|
||||||
// "mailListener": {
|
|
||||||
// "issueNumberParser": "\\b(?<=#)\\d+\\b",
|
|
||||||
// "imapSimpleConfig": {
|
|
||||||
// "imap": {
|
|
||||||
// "user": "",
|
|
||||||
// "password": "",
|
|
||||||
// "host": "",
|
|
||||||
// "port": 143,
|
|
||||||
// // tls: true,
|
|
||||||
// "autotls": "always",
|
|
||||||
// "authTimeout": 5000
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// "updateInterval": 180000, // 3 min
|
|
||||||
// "boxName": "INBOX"
|
|
||||||
// },
|
|
||||||
|
|
||||||
"rssListener": {
|
|
||||||
"subscriptions": [
|
|
||||||
{
|
|
||||||
"url": "https://REDMINE_HOST/projects/proj/activity.atom?key=....", // TODO
|
|
||||||
"issueNumberParser": "\\b(?<=#)\\d+\\b"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://REDMINE_HOST/activity.atom?key=....", // TODO
|
|
||||||
"issueNumberParser": "\\b(?<=#)\\d+\\b"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"updateInterval": 10000 // 10 sec
|
|
||||||
},
|
|
||||||
|
|
||||||
"issueChangesQueue": {
|
|
||||||
"updateInterval": 5000, // 5 sec
|
|
||||||
"itemsLimit": 3
|
|
||||||
},
|
|
||||||
"redmineUrlPrefix": "https://REDMINE_API_TOKEN@REDMINE_HOST", // TODO
|
|
||||||
"redmineUrlPublic": "https://REDMINE_HOST", // TODO
|
|
||||||
"webhooks": [],
|
|
||||||
|
|
||||||
"couchDb": {
|
|
||||||
"url": "http://admin:password@localhost:5984", // <- TODO: Указать host, port, username и password для доступа к couchdb
|
|
||||||
"dbs": {
|
|
||||||
"users": "redmine_users",
|
|
||||||
"issues": "redmine_issues",
|
|
||||||
"dashboards": "dashboards"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"couchDb": {
|
|
||||||
"dbs": {
|
|
||||||
"changes": "redmine_changes",
|
|
||||||
"userMetaInfo": "user_meta_info",
|
|
||||||
"eccmDailyReports": "eccm_daily_reports",
|
|
||||||
"eccmDailyReportsUserComments": "eccm_daily_reports_user_comments"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"telegramBotToken": "....", // <- TODO: Указать token от бота telegram
|
|
||||||
"personalMessageTemplate": "{{{issue_url}}} {{{sender_name}}}:\n\n{{{message}}}",
|
|
||||||
"periodValidityNotification": 43200000, // 12h
|
|
||||||
"tagManager": {
|
|
||||||
"updateInterval": 15000,
|
|
||||||
"updateItemsLimit": 3,
|
|
||||||
"tagsCustomFieldName": "Tags"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
// TODO: Указать правила для формирования сообщений с уведомлениями об изменениях статусов
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "New",
|
|
||||||
"to": "In Progress",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} взял в работу задачу #{{issue_id}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "In Progress",
|
|
||||||
"to": "Code Review",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} завершил разработку по задаче #{{issue_id}} и передал на ревью {{cr.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "cr",
|
|
||||||
"changes_message": "{{cr.name}} получил задачу #{{issue_id}} на ревью от {{dev.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{issue_subject}}:\n{{dev.name}} завершил разработку по задаче и передал вам на ревью\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Code Review",
|
|
||||||
"to": "Re-opened",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "cr",
|
|
||||||
"changes_message": "{{cr.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} получил задачу #{{issue_id}} на доработку после завершения ревью {{cr.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{cr.name}} вернул вам с ревью на доработку задачу\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Re-opened",
|
|
||||||
"to": "In Progress",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} продолжил работу над задачей #{{issue_id}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Code Review",
|
|
||||||
"to": "Resolved",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "cr",
|
|
||||||
"changes_message": "{{cr.name}} завершил ревью задачи {{issue_id}} и передал её на тест {{qa.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} получил на тест задачу #{{issue_id}} после разработки {{dev.name}} и ревью {{cr.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} завершил разработку по задаче, а {{cr.name}} выполнил ревью и передали теперь вам для проверки\n\n{{journal_note}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{cr.name}} выполнил ревью вашей задачи и передал далее на проверку {{qa.name}}\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Re-opened",
|
|
||||||
"to": "Code Review",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} выполнил доработки и перевёл задачу #{{issue_id}} на ревью {{cr.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "cr",
|
|
||||||
"changes_message": "{{cr.name}} получил задачу #{{issue_id}} на повторное ревью от {{dev.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} выполнил по задаче доработки и теперь вы можете сделать ревью\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Resolved",
|
|
||||||
"to": "Closed",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} протестировал задачу #{{issue_id}} и закрыл"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} проверил и закрыл вашу задачу"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Testing",
|
|
||||||
"to": "Re-opened",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} получил от {{qa.name}} задачу #{{issue_id}} на доработку",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} вернул вам с тестирования задачу на доработку\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Resolved",
|
|
||||||
"to": "Re-opened",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} получил от {{qa.name}} задачу #{{issue_id}} на доработку",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} вернул вам с тестирования задачу на доработку\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Resolved",
|
|
||||||
"to": "Testing",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} начал проверять задачу #{{issue_id}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Testing",
|
|
||||||
"to": "Closed",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} протестировал задачу #{{issue_id}} и закрыл"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} проверил и закрыл вашу задачу"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"to": "Rejected",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "initiator",
|
|
||||||
"changes_message": "{{initiator.name}} изменил статус задачи {{issue_id}} с {{old_status.name}} на {{new_status.name}} (dev - {{dev.name}}, cr - {{cr.name}}, qa - {{qa.name}})"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": true,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "initiator",
|
|
||||||
"changes_message": "{{initiator.name}} изменил статус задачи #{{issue_id}} с {{old_status.name}} на {{new_status.name}} (dev - {{dev.name}}, cr - {{cr.name}}, qa - {{qa.name}})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{initiator.name}} изменил статус задачи с {{old_status.name}} на {{new_status.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "current_user",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{initiator.name}} изменил статус задачи с {{old_status.name}} на {{new_status.name}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"new_issue": true,
|
|
||||||
"from": "New",
|
|
||||||
"to": "New",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "author",
|
|
||||||
"changes_message": "{{author.name}} создал новую задачу #{{issue_id}} {{#if current_user}}для {{current_user.name}}{{/if}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "current_user",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{author.name}} создал новую задачу и назначил её на вас"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "Re-opened",
|
|
||||||
"to": "Resolved",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "dev",
|
|
||||||
"changes_message": "{{dev.name}} перевёл задачу #{{issue_id}} на повторную проверку {{qa.name}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "qa",
|
|
||||||
"changes_message": "{{qa.name}} получил задачу #{{issue_id}} на повторную проверку от {{dev.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} выполнил по задаче доработки и теперь вы можете проверить повторно\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
// TODO: Указать какие статусы могут быть в вашем Redmine
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "New"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "In Progress"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"name": "Re-opened"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"name": "Code Review"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"name": "Resolved"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"name": "Testing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"name": "Wait Release"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 10,
|
|
||||||
"name": "Pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 11,
|
|
||||||
"name": "Feedback"
|
|
||||||
}
|
|
||||||
|
|
||||||
// {
|
|
||||||
// "id": 5,
|
|
||||||
// "name": "Closed",
|
|
||||||
// "is_closed": true
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// "id": 6,
|
|
||||||
// "name": "Rejected",
|
|
||||||
// "is_closed": true
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// "id": 7,
|
|
||||||
// "name": "Confirming"
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// "id": 12,
|
|
||||||
// "name": "Frozen",
|
|
||||||
// "is_closed": true
|
|
||||||
// },
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// deprecated
|
|
||||||
{
|
|
||||||
"path": "/path/to/eltex-redmine-issue-event-emitter/configs/kanban-boards"
|
|
||||||
}
|
|
||||||
|
|
@ -24,32 +24,10 @@
|
||||||
],
|
],
|
||||||
"updateInterval": 600000 // 10 min
|
"updateInterval": 600000 // 10 min
|
||||||
},
|
},
|
||||||
"csvListener": {
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"schedule": "", // cron schedule syntax
|
|
||||||
"updatedAtFieldName": "",
|
|
||||||
"dateTimeFormat": "",
|
|
||||||
"csvLinks": [
|
|
||||||
"",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"rootIssueListener": {
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"schedule": "", // cron schedule syntax
|
|
||||||
"rootIssues": [] // number[]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"issueChangesQueue": {
|
"issueChangesQueue": {
|
||||||
"updateInterval": 5000, // 5 sec
|
"updateInterval": 5000, // 5 sec
|
||||||
"itemsLimit": 3
|
"itemsLimit": 3
|
||||||
},
|
},
|
||||||
"redmineToken": "",
|
|
||||||
"redmineUrlPrefix": "",
|
"redmineUrlPrefix": "",
|
||||||
"redmineUrlPublic": "",
|
"redmineUrlPublic": "",
|
||||||
"webhooks": [
|
"webhooks": [
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
|
@ -1,252 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="380.13333mm"
|
|
||||||
height="110.25833mm"
|
|
||||||
viewBox="0 0 380.13334 110.25833"
|
|
||||||
version="1.1"
|
|
||||||
id="svg8"
|
|
||||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
|
||||||
sodipodi:docname="Преобразования задач.svg"
|
|
||||||
inkscape:export-filename="/home/pavel/obsidian/Личные/Проекты/Монолитный Redmine Event Emitter/Документация/_resources/Преобразования задач.png"
|
|
||||||
inkscape:export-xdpi="96"
|
|
||||||
inkscape:export-ydpi="96">
|
|
||||||
<defs
|
|
||||||
id="defs2">
|
|
||||||
<marker
|
|
||||||
style="overflow:visible;"
|
|
||||||
id="marker1241"
|
|
||||||
refX="0.0"
|
|
||||||
refY="0.0"
|
|
||||||
orient="auto"
|
|
||||||
inkscape:stockid="Arrow2Lend"
|
|
||||||
inkscape:isstock="true">
|
|
||||||
<path
|
|
||||||
transform="scale(1.1) rotate(180) translate(1,0)"
|
|
||||||
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
|
|
||||||
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
|
|
||||||
id="path1239" />
|
|
||||||
</marker>
|
|
||||||
<marker
|
|
||||||
style="overflow:visible;"
|
|
||||||
id="marker1213"
|
|
||||||
refX="0.0"
|
|
||||||
refY="0.0"
|
|
||||||
orient="auto"
|
|
||||||
inkscape:stockid="Arrow2Lend"
|
|
||||||
inkscape:isstock="true">
|
|
||||||
<path
|
|
||||||
transform="scale(1.1) rotate(180) translate(1,0)"
|
|
||||||
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
|
|
||||||
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
|
|
||||||
id="path1211" />
|
|
||||||
</marker>
|
|
||||||
<marker
|
|
||||||
style="overflow:visible;"
|
|
||||||
id="Arrow2Lend"
|
|
||||||
refX="0.0"
|
|
||||||
refY="0.0"
|
|
||||||
orient="auto"
|
|
||||||
inkscape:stockid="Arrow2Lend"
|
|
||||||
inkscape:isstock="true"
|
|
||||||
inkscape:collect="always">
|
|
||||||
<path
|
|
||||||
transform="scale(1.1) rotate(180) translate(1,0)"
|
|
||||||
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
|
|
||||||
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
|
|
||||||
id="path918" />
|
|
||||||
</marker>
|
|
||||||
<marker
|
|
||||||
style="overflow:visible;"
|
|
||||||
id="Arrow2Mend"
|
|
||||||
refX="0.0"
|
|
||||||
refY="0.0"
|
|
||||||
orient="auto"
|
|
||||||
inkscape:stockid="Arrow2Mend"
|
|
||||||
inkscape:isstock="true">
|
|
||||||
<path
|
|
||||||
transform="scale(0.6) rotate(180) translate(0,0)"
|
|
||||||
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
|
|
||||||
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
|
|
||||||
id="path924" />
|
|
||||||
</marker>
|
|
||||||
<rect
|
|
||||||
x="124.35417"
|
|
||||||
y="15.875"
|
|
||||||
width="105.83333"
|
|
||||||
height="31.75"
|
|
||||||
id="rect879" />
|
|
||||||
</defs>
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="base"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="1"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:zoom="0.98994949"
|
|
||||||
inkscape:cx="755.38262"
|
|
||||||
inkscape:cy="192.02171"
|
|
||||||
inkscape:document-units="mm"
|
|
||||||
inkscape:current-layer="layer1"
|
|
||||||
inkscape:document-rotation="0"
|
|
||||||
showgrid="true"
|
|
||||||
inkscape:window-width="1856"
|
|
||||||
inkscape:window-height="1051"
|
|
||||||
inkscape:window-x="64"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:snap-text-baseline="true"
|
|
||||||
fit-margin-top="10"
|
|
||||||
lock-margins="true"
|
|
||||||
fit-margin-left="10"
|
|
||||||
fit-margin-right="10"
|
|
||||||
fit-margin-bottom="10">
|
|
||||||
<inkscape:grid
|
|
||||||
type="xygrid"
|
|
||||||
id="grid36"
|
|
||||||
originx="2.2125001"
|
|
||||||
originy="52.483332" />
|
|
||||||
</sodipodi:namedview>
|
|
||||||
<metadata
|
|
||||||
id="metadata5">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
<dc:title></dc:title>
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
inkscape:label="Слой 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(2.2125,52.483332)">
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:85.6522;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="22.545856"
|
|
||||||
y="31.792259"
|
|
||||||
id="text30"
|
|
||||||
transform="translate(-6.6699219,-2.6880925)"><tspan
|
|
||||||
x="22.545856"
|
|
||||||
y="31.792259"><tspan
|
|
||||||
style="stroke-width:0.3">Чтение данных из </tspan></tspan><tspan
|
|
||||||
x="22.545856"
|
|
||||||
y="39.729758"><tspan
|
|
||||||
style="stroke-width:0.3">redmine </tspan><tspan
|
|
||||||
style="stroke-width:0.3">через </tspan><tspan
|
|
||||||
style="stroke-width:0.3">http api</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="rect38"
|
|
||||||
width="100.54166"
|
|
||||||
height="31.75"
|
|
||||||
x="7.9375"
|
|
||||||
y="15.875" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="132.29167"
|
|
||||||
y="37.041668"
|
|
||||||
id="text865"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan863"
|
|
||||||
x="132.29167"
|
|
||||||
y="37.041668"
|
|
||||||
style="stroke-width:0.3" /></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6673;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="18.520834"
|
|
||||||
y="-7.9375"
|
|
||||||
id="text869"
|
|
||||||
transform="translate(-2.6464845,-23.812501)"><tspan
|
|
||||||
x="18.520834"
|
|
||||||
y="-7.9375"><tspan
|
|
||||||
style="stroke-width:0.3">Указание коллекции </tspan></tspan><tspan
|
|
||||||
x="18.520834"
|
|
||||||
y="2.970919e-07"><tspan
|
|
||||||
style="stroke-width:0.3">функций-</tspan></tspan><tspan
|
|
||||||
x="18.520834"
|
|
||||||
y="7.9375003"><tspan
|
|
||||||
style="stroke-width:0.3">преобразователей при </tspan></tspan><tspan
|
|
||||||
x="18.520834"
|
|
||||||
y="15.875"><tspan
|
|
||||||
style="stroke-width:0.3">инициализии приложения</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.30000001;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.20000006,1.20000006;stroke-dashoffset:0"
|
|
||||||
id="rect871"
|
|
||||||
width="100.54166"
|
|
||||||
height="42.333332"
|
|
||||||
x="7.9375"
|
|
||||||
y="-42.333332" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:119.062;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="132.29167"
|
|
||||||
y="29.104166"
|
|
||||||
id="text875"><tspan
|
|
||||||
x="132.29167"
|
|
||||||
y="29.104166"><tspan
|
|
||||||
style="stroke-width:0.3">Выполнение всех функций-</tspan></tspan><tspan
|
|
||||||
x="132.29167"
|
|
||||||
y="37.041666"><tspan
|
|
||||||
style="stroke-width:0.3">преобразователей</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
id="text877"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect879);fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000;" />
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="rect883"
|
|
||||||
width="108.47916"
|
|
||||||
height="31.75"
|
|
||||||
x="124.35416"
|
|
||||||
y="15.875" />
|
|
||||||
<path
|
|
||||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.30000001;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:1.20000006,1.20000006;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#Arrow2Lend)"
|
|
||||||
d="m 108.47917,-23.8125 c 39.6875,0 63.5,10.583333 63.49999,39.6875"
|
|
||||||
id="path885"
|
|
||||||
sodipodi:nodetypes="cc" />
|
|
||||||
<path
|
|
||||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#marker1241)"
|
|
||||||
d="m 108.47917,31.75 h 15.875"
|
|
||||||
id="path887" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:113.771;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="261.9375"
|
|
||||||
y="29.10417"
|
|
||||||
id="text891"
|
|
||||||
transform="translate(-5.2916514)"><tspan
|
|
||||||
x="261.9375"
|
|
||||||
y="29.10417"><tspan
|
|
||||||
style="stroke-width:0.3">Дальнейшая работа с данными </tspan></tspan><tspan
|
|
||||||
x="261.9375"
|
|
||||||
y="37.04167"><tspan
|
|
||||||
style="stroke-width:0.3">(сохранение в </tspan><tspan
|
|
||||||
style="stroke-width:0.3">CouchDB</tspan><tspan
|
|
||||||
style="stroke-width:0.3">)</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="rect893"
|
|
||||||
width="119.0625"
|
|
||||||
height="31.75"
|
|
||||||
x="248.70833"
|
|
||||||
y="15.875" />
|
|
||||||
<path
|
|
||||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#marker1213)"
|
|
||||||
d="m 232.83333,31.749999 h 15.875"
|
|
||||||
id="path895" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
|
@ -1,462 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="265.65228mm"
|
|
||||||
height="517.26074mm"
|
|
||||||
viewBox="0 0 265.65229 517.26075"
|
|
||||||
version="1.1"
|
|
||||||
id="svg8"
|
|
||||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
|
||||||
sodipodi:docname="Сохранение в CouchDB.svg"
|
|
||||||
inkscape:export-filename="/home/pavel/obsidian/Личные/Проекты/Монолитный Redmine Event Emitter/Документация/_resources/Сохранение в CouchDB.png"
|
|
||||||
inkscape:export-xdpi="96"
|
|
||||||
inkscape:export-ydpi="96">
|
|
||||||
<defs
|
|
||||||
id="defs2" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="base"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="1"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:zoom="0.49497475"
|
|
||||||
inkscape:cx="530.06227"
|
|
||||||
inkscape:cy="959.12713"
|
|
||||||
inkscape:document-units="mm"
|
|
||||||
inkscape:current-layer="layer1"
|
|
||||||
inkscape:document-rotation="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:window-width="928"
|
|
||||||
inkscape:window-height="1051"
|
|
||||||
inkscape:window-x="992"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="0"
|
|
||||||
inkscape:snap-text-baseline="true"
|
|
||||||
showguides="false"
|
|
||||||
fit-margin-top="10"
|
|
||||||
lock-margins="true"
|
|
||||||
fit-margin-left="10"
|
|
||||||
fit-margin-right="10"
|
|
||||||
fit-margin-bottom="10">
|
|
||||||
<inkscape:grid
|
|
||||||
type="xygrid"
|
|
||||||
id="grid28"
|
|
||||||
originx="49.837479"
|
|
||||||
originy="-14.118424" />
|
|
||||||
<sodipodi:guide
|
|
||||||
position="60.420812,512.85835"
|
|
||||||
orientation="300,0"
|
|
||||||
id="guide873" />
|
|
||||||
<sodipodi:guide
|
|
||||||
position="160.96248,436.12918"
|
|
||||||
orientation="110.00002,0"
|
|
||||||
id="guide885" />
|
|
||||||
<sodipodi:guide
|
|
||||||
position="92.170813,433.48335"
|
|
||||||
orientation="0,105"
|
|
||||||
id="guide889" />
|
|
||||||
<sodipodi:guide
|
|
||||||
position="71.004145,420.25418"
|
|
||||||
orientation="165,0"
|
|
||||||
id="guide927" />
|
|
||||||
<sodipodi:guide
|
|
||||||
position="171.54582,420.25418"
|
|
||||||
orientation="60,0"
|
|
||||||
id="guide931" />
|
|
||||||
</sodipodi:namedview>
|
|
||||||
<metadata
|
|
||||||
id="metadata5">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
<dc:title></dc:title>
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
inkscape:label="Слой 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(49.837477,-14.118424)">
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="-39.6875"
|
|
||||||
y="44.979164"
|
|
||||||
id="text857"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
x="-39.6875"
|
|
||||||
y="44.979164"
|
|
||||||
style="font-size:4.23333px;stroke-width:0.3"
|
|
||||||
id="tspan859">{</tspan><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
x="-39.6875"
|
|
||||||
y="50.270828"
|
|
||||||
style="font-size:4.23333px;stroke-width:0.3"
|
|
||||||
id="tspan861"> "_id": "8ccc",</tspan><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
x="-39.6875"
|
|
||||||
y="55.562489"
|
|
||||||
style="font-size:4.23333px;stroke-width:0.3"
|
|
||||||
id="tspan863"> "_rev": "0-abcd",</tspan><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
x="-39.6875"
|
|
||||||
y="60.854149"
|
|
||||||
style="font-size:4.23333px;stroke-width:0.3"
|
|
||||||
id="tspan865"> "field_a": "value_a"</tspan><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
x="-39.6875"
|
|
||||||
y="66.145813"
|
|
||||||
style="font-size:4.23333px;stroke-width:0.3"
|
|
||||||
id="tspan867">}</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="10.583333"
|
|
||||||
y="97.895836"
|
|
||||||
id="text877"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan875"
|
|
||||||
x="10.583333"
|
|
||||||
y="97.895836"
|
|
||||||
style="stroke-width:0.3">Поток 1</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"
|
|
||||||
id="text881"
|
|
||||||
transform="translate(10.582682,5.2916667)"><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"><tspan
|
|
||||||
style="stroke-width:0.3">Чтение документа из </tspan></tspan><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="127"><tspan
|
|
||||||
style="stroke-width:0.3">коллекции</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="111.125"
|
|
||||||
y="97.895836"
|
|
||||||
id="text893"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan891"
|
|
||||||
x="111.125"
|
|
||||||
y="97.895836"
|
|
||||||
style="stroke-width:0.3">Поток 2</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"
|
|
||||||
id="text901"
|
|
||||||
transform="translate(6.5094167e-4,3.9687551)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">{
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="158.74999"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> id: 8ccc,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="164.04165"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> rev: 0-abcd,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="169.3333"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> field_a: value_a
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="174.62496"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">}</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
|
|
||||||
id="rect903"
|
|
||||||
width="5.2916665"
|
|
||||||
height="95.250008"
|
|
||||||
x="10.583333"
|
|
||||||
y="111.125" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"
|
|
||||||
id="text881-6"
|
|
||||||
transform="translate(111.12435,44.979173)"><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"><tspan
|
|
||||||
style="stroke-width:0.3">Чтение документа из </tspan></tspan><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="127"><tspan
|
|
||||||
style="stroke-width:0.3">коллекции</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
|
|
||||||
id="rect903-5"
|
|
||||||
width="5.2916665"
|
|
||||||
height="140.22917"
|
|
||||||
x="111.125"
|
|
||||||
y="150.8125" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"
|
|
||||||
id="text881-3"
|
|
||||||
transform="translate(10.582683,124.35415)"><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"><tspan
|
|
||||||
style="stroke-width:0.3">Запись новой версии </tspan></tspan><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="127"><tspan
|
|
||||||
style="stroke-width:0.3">документа в </tspan><tspan
|
|
||||||
style="stroke-width:0.3">коллекцию</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"
|
|
||||||
id="text901-5"
|
|
||||||
transform="translate(6.4991001e-4,113.77082)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"><tspan
|
|
||||||
style="font-size:4.23333px">{
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="158.74999"><tspan
|
|
||||||
style="font-size:4.23333px"> id: 8ccc,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="164.04165"><tspan
|
|
||||||
style="font-size:4.23333px"> rev: 0-abcd,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="169.3333"><tspan
|
|
||||||
style="font-size:4.23333px"> </tspan><tspan
|
|
||||||
style="font-size:4.23333px;fill:#0000ff">field_a: success_value</tspan><tspan
|
|
||||||
style="font-size:4.23333px">
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="174.62496"><tspan
|
|
||||||
style="font-size:4.23333px">}</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
|
|
||||||
id="rect903-6"
|
|
||||||
width="5.2916665"
|
|
||||||
height="137.58333"
|
|
||||||
x="10.583333"
|
|
||||||
y="230.1875" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="333.375"
|
|
||||||
id="text1140"
|
|
||||||
transform="translate(6.4991001e-4,-26.458354)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="333.375"><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">Успех. Документу </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="341.31251"><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">внутри </tspan><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">CouchDB </tspan><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">будет </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="349.25001"><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">проставлена новая </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="357.18751"><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">ревизия - 1-</tspan><tspan
|
|
||||||
style="fill:#008000;stroke-width:0.3">bcde</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="-39.6875"
|
|
||||||
y="29.104166"
|
|
||||||
id="text1260"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan1258"
|
|
||||||
x="-39.6875"
|
|
||||||
y="29.104166"
|
|
||||||
style="stroke-width:0.3">Исходный документ:</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#008000;stroke:none;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="148.16667"
|
|
||||||
id="text1268"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan1266"
|
|
||||||
x="21.166666"
|
|
||||||
y="148.16667"
|
|
||||||
style="fill:#008000;stroke:none;stroke-width:0.3">Получили:</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"
|
|
||||||
id="text901-5-0"
|
|
||||||
transform="translate(6.4964419e-4,187.85415)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000">{
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="158.74999"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000"> id: 8ccc,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="164.04165"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000"> rev: 1-bcde,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="169.3333"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000"> </tspan><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000">field_a: success_value</tspan><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000">
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="174.62496"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000">}</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"
|
|
||||||
id="text881-3-9"
|
|
||||||
transform="translate(111.12433,285.74998)"><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="119.0625"><tspan
|
|
||||||
style="stroke-width:0.3">Запись новой версии </tspan></tspan><tspan
|
|
||||||
x="10.583333"
|
|
||||||
y="127"><tspan
|
|
||||||
style="stroke-width:0.3">документа в </tspan><tspan
|
|
||||||
style="stroke-width:0.3">коллекцию</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"
|
|
||||||
id="text901-5-3"
|
|
||||||
transform="translate(100.5423,275.16665)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"><tspan
|
|
||||||
style="font-size:4.23333px">{
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="158.74999"><tspan
|
|
||||||
style="font-size:4.23333px"> id: 8ccc,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="164.04165"><tspan
|
|
||||||
style="font-size:4.23333px"> rev: 0-abcd,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="169.3333"><tspan
|
|
||||||
style="font-size:4.23333px"> </tspan><tspan
|
|
||||||
style="font-size:4.23333px;fill:#0000ff">field_a: fail_value</tspan><tspan
|
|
||||||
style="font-size:4.23333px">
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="174.62496"><tspan
|
|
||||||
style="font-size:4.23333px">}</tspan></tspan></text>
|
|
||||||
<rect
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.300001;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
|
|
||||||
id="rect903-6-6"
|
|
||||||
width="5.2916665"
|
|
||||||
height="124.35415"
|
|
||||||
x="111.125"
|
|
||||||
y="391.58337" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#ff0000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="333.375"
|
|
||||||
id="text1140-0"
|
|
||||||
transform="translate(100.5423,134.93748)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="333.375"><tspan
|
|
||||||
style="fill:#ff0000">Fail. </tspan><tspan
|
|
||||||
style="fill:#ff0000">Ревизия не </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="341.31251"><tspan
|
|
||||||
style="fill:#ff0000">совпадает с предыдущим </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="349.25001"><tspan
|
|
||||||
style="fill:#ff0000">значением:
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="357.18751"><tspan
|
|
||||||
style="fill:#ff0000">"0-</tspan><tspan
|
|
||||||
style="fill:#ff0000">abcd" != "1-bcde".
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="365.12501"><tspan
|
|
||||||
style="fill:#ff0000">Документ не будет </tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="373.06251"><tspan
|
|
||||||
style="fill:#ff0000">обновлён.</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"
|
|
||||||
id="text901-2"
|
|
||||||
transform="translate(100.54233,43.656245)"><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="153.45833"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">{
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="158.74999"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> id: 8ccc,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="164.04165"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> rev: 0-abcd,
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="169.3333"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> field_a: value_a
|
|
||||||
</tspan></tspan><tspan
|
|
||||||
x="21.166666"
|
|
||||||
y="174.62496"><tspan
|
|
||||||
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">}</tspan></tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#008000;stroke:none;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="121.70834"
|
|
||||||
y="187.85417"
|
|
||||||
id="text1268-6"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan1266-1"
|
|
||||||
x="121.70834"
|
|
||||||
y="187.85417"
|
|
||||||
style="fill:#008000;stroke:none;stroke-width:0.3">Получили:</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
|
|
||||||
x="-39.6875"
|
|
||||||
y="97.895836"
|
|
||||||
id="text1634"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan1632"
|
|
||||||
x="-39.6875"
|
|
||||||
y="97.895836"
|
|
||||||
style="stroke-width:0.3">Время</tspan></text>
|
|
||||||
<path
|
|
||||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
|
|
||||||
d="m -34.395832,111.125 h 5.291666 v 402.16666 h 5.291666 l -7.937498,7.9375 -7.9375,-7.9375 h 5.291666 z"
|
|
||||||
id="path1636" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
|
|
@ -1,52 +0,0 @@
|
||||||
# Общая структура
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Основной код приложения написан на TypeScript и фреймворке NestJS (это такой Angular для backend-а)
|
|
||||||
|
|
||||||
Монолитное приложение включает в своём составе:
|
|
||||||
|
|
||||||
- основная функциональность
|
|
||||||
- кеш и промежуточный коллектор для данных на couchdb
|
|
||||||
- http api для внешних интеграций
|
|
||||||
- webhook-и и websocket-ы
|
|
||||||
- telegram-бот - есть возможность для реализации новых интеграций с другими чат-ботами
|
|
||||||
- cron-task-и для выполнения повторяющихся задач
|
|
||||||
- frontend server-side на простой шаблонизации
|
|
||||||
- frontend на react
|
|
||||||
- специальный плагин для lowcode платформы n8n
|
|
||||||
|
|
||||||
# Основной процесс
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Двигателем приложения служит redmine-issue-event-emitter - это основной процесс поступления данных в Pinkmine.
|
|
||||||
|
|
||||||
1. Подписка на источники событий - по email, через cron-таски, через rss. Позволяет обнаруживать факты изменений в задачах в redmine. ([подробнее](Как%20это%20работает/Как%20работают%20стратегии%20синхронизации%20redmine%20и%20pinkmine.md))
|
|
||||||
2. Redmine Issue Event Emitter - это очередь для синхронизации данных из redmine. На выход кладётся номер задачи, на выходе данные полученные с помощью redmine api. Очередь обеспечивает бережное отношение к боевому redmine, так как позволяет выполнять выгрузки не выходя за выставленный лимит обращений. ([подробнее про работу очереди](Как%20это%20работает/Как%20работает%20очередь%20загрузки%20задач.md) и [подробнее про загрузку задач из redmine](Как%20это%20работает/Как%20происходит%20загрузка%20задачи%20из%20Redmine.md))
|
|
||||||
3. Преобразование задачи - это набор вспомогательных обработчиков. Позволяет при получении данных произвести попутно полезные изменения, например, привести текстовые теги к массиву, определить текущего ответственного за задачу, обнаружить в описании или в комментариях ссылки на gitlab на MR-ы, в т.ч. можно реализовать специализированный обработчик. ([подбронее](./Как%20это%20работает/Как%20происходит%20преобразование%20задачи.md))
|
|
||||||
4. Сохранение задачи в кеш в CouchDB ([подробнее](./Как%20это%20работает/Сохранение%20задачи%20в%20кеш%20CouchDB.md))
|
|
||||||
5. Анализ изменений в задаче - это сравнение предыдущего состояния задачи с новым. Позволяет организовать процессы связанные с жизненным циклом самой задачи и, например, разослать уведомления с учётом происходящих изменений ([подробнее](./Как%20это%20работает/Анализ%20изменений%20в%20задаче.md))
|
|
||||||
6. Сохранение событий изменения в журнал - вспомогательная коллекция данных ([подробнее](./Как%20это%20работает/Анализ%20изменений%20в%20задаче.md))
|
|
||||||
|
|
||||||
# Точки расширения
|
|
||||||
|
|
||||||
Платформа Pinkmine предполагает в т.ч. реализацию конкретных задач в рамках основного кода. Допускается реализация специализированных внутренних сервисов.
|
|
||||||
|
|
||||||
В рамках ядра redmine-issue-event-emitter:
|
|
||||||
|
|
||||||
- новые стратегии получения задач из Redmine
|
|
||||||
- специализированные обработчики задач для отдельных проектов
|
|
||||||
- интеграция событий в новые процессы - чат-боты или простая передача данных с помощью webhook-ов и websocket-ов
|
|
||||||
|
|
||||||
В рамках расширенной платформы pinkmine:
|
|
||||||
|
|
||||||
Благодаря CouchDB стало возможным предоставить api для поиска задач с более гибкими условиями для выборки чем во встроенном в redmine api. Можно использовать в выборках комбинированные выражения через "и"/"или". Богатый набор операторов - "есть значение", "нет значения", "больше", "меньше", "равно", "поиск по регулярному выражению". Для поиска можно пользоваться расширенными данными.
|
|
||||||
|
|
||||||
CouchDB позволяет выгружать для работы и анализа больший объём данных чем основной api redmine.
|
|
||||||
|
|
||||||
Webhook-и и websocket-ы могут сделать api ещё более гибким.
|
|
||||||
|
|
||||||
В рамках реализованного telegram chat бота можно дописывать логику работы под узкоспециализированные задачи.
|
|
||||||
|
|
||||||
С помощью фреймворка NestJS можно сделать сервисы для решения узких задач. Например, сделать cron-task-у с аналитической выгрузкой с сохранением промежуточных результатов во вспомогательную коллекцию в CouchDB и предостатить уже к ней доступ через http api и сделать человеко-читаемое представление с помощью шаблона.
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
Как было описано в документе [Сохранение задачи в кеш CouchDB](./Сохранение%20задачи%20в%20кеш%20CouchDB.md) специфика работы с данными из issue такова что есть два состояния - предыдущее и текущее состояние.
|
|
||||||
|
|
||||||
Значит есть возможность сравнения двух состояний и построение дополнительной логики на основе сравнения.
|
|
||||||
|
|
||||||
Это позволило реализовать рассылку уведомлений при обнаружении в задачах изменений.
|
|
||||||
|
|
||||||
Функции сохранения данных в CouchDB с вычислением изменений реализована в `libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts`.
|
|
||||||
|
|
||||||
Затем с помощью пайплайнов rxjs реализован дополнительный анализ изменений:
|
|
||||||
|
|
||||||
1. `src/notifications/personal-notifications.service.ts` - поиск упоминаний пользователей redmine в комментариях для пересылки личных уведомлений в чат-бот
|
|
||||||
2. `src/notifications/status-change-notifications.service.ts` - поиск изменений статусов для рассылки уведомлений согласно изменениям ответственного за дальнейшую работу над задачей
|
|
||||||
3. `src/changes-cache-writer/changes-cache-writer.service.ts` - сохранение изменений в дополнительный журнал активностей в коллекцию в CouchDB
|
|
||||||
|
|
||||||
Сам пайплайн rxjs определяется в инициализирующей функции в `src/app.module.ts`.
|
|
||||||
|
|
||||||
Для правильности работы функций анализа нужно выполнить правильно конфигугирование.
|
|
||||||
|
|
||||||
Статусы используемые в вашем экземпляре redmine определяются в конфигурационном файле `configs/redmine-statuses-config.jsonc`. Они имеет следующий вид:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "New"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"name": "In Progress"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Closed",
|
|
||||||
"is_closed": true
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Идентификаторы и name должны соответствовать статусам настроенным в основном redmine.
|
|
||||||
|
|
||||||
Правила для определения изменений в статусах определяются с помощью конфигурационного файла `configs/redmine-status-changes-config.jsonc`. Он имеет следующий вид:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"from": "New",
|
|
||||||
"to": "In Progress",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"recipient": "<field_name>",
|
|
||||||
// Handlebars - template engine
|
|
||||||
"changes_message": "{{qa.name}} got issue #{{issue_id}} after development {{dev.name}}",
|
|
||||||
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} finished development. You can test issue.\n\n{{journal_note}}"
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Это набор правил для формирования уведомлений и сообщений для журнала изменений. Поля "from" и "to" - указывают на возможные статусы задачи. "messages" - это набор сообщений. Тут можно определить варианты сообщений для различных получателей задаваемых полем "recipient", а тексты сообщений с помощью шаблонизатора [Handlebars](https://handlebarsjs.com).
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
Это обращение к стандартной функции через http api по следующему шаблону:
|
|
||||||
|
|
||||||
`<redmine_url>/issues/<issue_number>.json?include=children,journals,relations`
|
|
||||||
|
|
||||||
Подробное описание параметров api можно найти в официальной фокументации на странице [Rest_Issues](https://www.redmine.org/projects/redmine/wiki/Rest_Issues)
|
|
||||||
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
Для разрешения некоторых проблем напрашивалось попутно после получения данных об issue из redmine делать преобразования
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
1. Преобразовать дату+вермя из текстового формата в числовой для возможности фильтрации данных с помощью сравнивающих операторов - больше и меньше.
|
|
||||||
2. Привести текстовое значение тегов к массиву для фильтрации данных с помощью оператора "$in".
|
|
||||||
3. Можно извлечь из описания и комментариев ссылки на MR-ы в gitlab-е.
|
|
||||||
4. Определить текущего ответственного за задачу по правилам соответствия статусу одному из полей - "Назначено", "Code Reviewer", "Quality Assurance".
|
|
||||||
|
|
||||||
Через конфигурационный файл уже не решить настройку, т.к. за преобразования задач отвечают функции - а если точнее то классы с реализацией интерфейса `IssueEnhancerInterface`. Это делается в коде проекта Pinkmine. Примеры реализаций в файлах:
|
|
||||||
|
|
||||||
- `libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts` - преобразует дату+время из текстового формата в числовой
|
|
||||||
- `libs/event-emitter/src/issue-enhancers` - в папке ещё несколько универсальных примеров (не зависящих от экземпляра redmine)
|
|
||||||
- `src/issue-enhancers` - в папке лежат дополнительные примеры с функциями уже привязанными к рабочему redmine и специфичные для рабочих проектов
|
|
||||||
|
|
||||||
Чтобы эти функции отрабатывали налету после получения данных из redmine и перед сохранением в кеш, то нужно при начальной инициализации приложения (`src/app.module.ts` > `AppModule.onModuleInit`) указать набор реализаций `IssueEnhancerInterface`. Упрощённый код, отражающий суть:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export class AppModule {
|
|
||||||
// ...
|
|
||||||
onModuleInit() {
|
|
||||||
// ...
|
|
||||||
this.enhancerService.addEnhancer([
|
|
||||||
this.timestampEnhancer,
|
|
||||||
this.customFieldsEnhancer,
|
|
||||||
this.currentUserEnhancer,
|
|
||||||
this.issueUrlEnhancer,
|
|
||||||
this.categoryMergeToTagsEnhancer,
|
|
||||||
this.calendarEnhancer,
|
|
||||||
]);
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Схематически процесс можно проиллюстрировать так:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Выполнение преобразований данных до сохранения в кеш в CouchDB позволяет в последствии использовать дополнительные данные для выборки задач
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
Это стандартный паттерн:
|
|
||||||
|
|
||||||
1. берём максимум N номеров задач раз в M секунд,
|
|
||||||
2. передаём их в функцию для синхронизаицации
|
|
||||||
1. выгружаем данные о задаче из redmine api
|
|
||||||
2. пишем полученные данные во внутренний кеш
|
|
||||||
|
|
||||||
Очередь нужна для бережного отношения к боевому redmine, потому что не загрузит CPU на сервере сотнями одновременных параллельных запросов.
|
|
||||||
|
|
||||||
Настройка очереди производится в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `issueChangesQueue`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"issueChangesQueue": {
|
|
||||||
"updateInterval": 5000, // 5 sec
|
|
||||||
"itemsLimit": 3
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# Стратегия по email
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
1. В задаче в redmine делается любое изменение - смена статуса, обновление описания, добавление нового комментария.
|
|
||||||
2. Redmine отправляет сообщение о факте обновления задачи по email
|
|
||||||
3. Pinkmine читает новые письма протоколу imap, находит в заголовках номер задачи с помощью регулярного выражения
|
|
||||||
4. Pinkmine помещает номер задачи в очередь для выполнения обновления
|
|
||||||
|
|
||||||
Параметры работы стратегии задаются в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `mailListener`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
"mailListener": {
|
|
||||||
|
|
||||||
// регулярное выражение для определения номера задачи в заголовке письма:
|
|
||||||
"issueNumberParser": "\\b(?<=#)\\d+\\b",
|
|
||||||
|
|
||||||
// параметры для доступа к почте через протокол imap:
|
|
||||||
"imapSimpleConfig": {
|
|
||||||
"imap": {
|
|
||||||
"user": "",
|
|
||||||
"password": "",
|
|
||||||
"host": "",
|
|
||||||
"port": 143,
|
|
||||||
// tls: true,
|
|
||||||
"autotls": "always",
|
|
||||||
"authTimeout": 5000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// интервал через который происходит проверка почты:
|
|
||||||
"updateInterval": 180000, // 3 min
|
|
||||||
|
|
||||||
// имя папки в которой следует искать входящие письма
|
|
||||||
"boxName": "INBOX"
|
|
||||||
},
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
# Стратегия через cron-таски
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
1. Pinkmine загружает данные из преднастроенных query-запросов в redmine в формате csv. Выбирает данные по двум полям - id задачи и "обновлено" (дата+время обновления задачи)
|
|
||||||
2. Pinkmine проверяет какие задачи в своём кеше (CouchDB) уже успели устареть
|
|
||||||
3. Номера устаревших задач кладёт в очередь для дальнейшего обновления
|
|
||||||
|
|
||||||
Параметры работы стратегии задаются в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секциях `csvListener` и `rootIssueListener`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// таски для синхронизации по преднастроенным запросам:
|
|
||||||
"csvListener": {
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
// расписание для выполнения задачи (синтаксис crontab)
|
|
||||||
"schedule": "* * * * *",
|
|
||||||
|
|
||||||
// поле в csv-файле с датой и временем обновления задачи
|
|
||||||
"updatedAtFieldName": "Обновлено",
|
|
||||||
|
|
||||||
// формат даты и времени
|
|
||||||
"dateTimeFormat": "dd.MM.yyyy HH:mm",
|
|
||||||
|
|
||||||
// ссылки на предстароенные запросы выборки задач в формате csv
|
|
||||||
"csvLinks": [
|
|
||||||
"https://<redmine_host>/projects/proj/issues.csv?utf8=%E2%9C%93&query_id=2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// таски для синхронизации корневых и суммирующих задач:
|
|
||||||
"rootIssueListener": {
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
// расписание для выполнения задачи (синтаксис crontab)
|
|
||||||
"schedule": "15 6,12 * * *",
|
|
||||||
|
|
||||||
// номера задач
|
|
||||||
"rootIssues": [
|
|
||||||
1,
|
|
||||||
3,
|
|
||||||
7,
|
|
||||||
11
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Эта стратегия позволяет догружать данные, которые не пришли по email рассылке: закрытые задачи, shady-mode, изменение структуры дерева при смене привязки к родительской задаче и т.п.
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
CouchDB - это nosql субд. Основная концепция CouchDB - это коллекции данных (json) с возможностью гибкого поиска по ним.
|
|
||||||
|
|
||||||
Важная особенность CouchDB - это отсутствие механизма транзакций. Гарантия согласованности записи обеспечивается с помощью контроля ревизии.
|
|
||||||
|
|
||||||
Документы в CouchDB имеют версионирование, аналогичное тому, как это было бы в обычной системе контроля версий, такой как Subversion. Если вы хотите изменить значение в документе, вы создаете полностью новую версию этого документа и сохраняете ее поверх старой. После выполнения этого вы получите две версии одного и того же документа, одну старую и одну новую.
|
|
||||||
|
|
||||||
Как это обеспечивает улучшение по сравнению с блокировками? Рассмотрим набор запросов, желающих получить доступ к документу. Первый запрос считывает документ. Пока он обрабатывается, второй запрос изменяет документ. Поскольку второй запрос включает совершенно новую версию документа, CouchDB может просто добавить его в базу данных, не дожидаясь завершения запроса на чтение.
|
|
||||||
|
|
||||||
Когда третий запрос захочет прочитать тот же документ, CouchDB укажет ему на новую версию, которая только что была написана. В течение всего этого процесса первый запрос все еще может читать исходную версию.
|
|
||||||
|
|
||||||
Запрос на чтение всегда будет отображать самый последний снимок вашей базы данных на момент начала запроса.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Redmine Issue Event Emitter всегда для записи нового состояния задачи должен прочитать предыдущее состояние. Это обязательно нужно для корректного определения последней ревизии. Одновременно для дальнейшего анализа станет доступно два состояния - предыдущее и текущее. Благодаря этой особенности работы с CouchDB можно проводить дополнительный [анализ произошедших в задаче изменений](./Анализ%20изменений%20в%20задаче.md).
|
|
||||||
|
|
||||||
Настройка доступа к CouchDB делается в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `couchDb`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"couchDb": {
|
|
||||||
"url": "http://admin:password@localhost:5984",
|
|
||||||
"dbs": {
|
|
||||||
"users": "redmine_users",
|
|
||||||
"issues": "redmine_issues",
|
|
||||||
"dashboards": "dashboards"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
# Создание кастомного виджета для дашборда
|
|
||||||
|
|
||||||
В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих React компонентов или разработки собственных компонентов.
|
|
||||||
|
|
||||||
Чтобы добавить собственный виджет нужно
|
|
||||||
|
|
||||||
- добавить data-loader на backend-е
|
|
||||||
- добавить React компонент для виджета на frontend-е
|
|
||||||
|
|
||||||
data-loader и react-компонент свяжутся через совпадающие поле `type`.
|
|
||||||
|
|
||||||
Давайте разберём подробнее на примере создания виджета предстоящих событий.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Data loader на backend-е
|
|
||||||
|
|
||||||
Выберем расположение файла для класса data-loader-а на backend-е.
|
|
||||||
|
|
||||||
Data-loader для нового виджета можно расположить где угодно.
|
|
||||||
|
|
||||||
1. Для единообразия его можно расположить в стандартном месте с универсальными виджетами в папке `libs/event-emitter/src/dashboards/widget-data-loader`
|
|
||||||
2. Либо если виджет ускоспециализирован под нужды вашего проекта, то можно сделать отдельную папку под ваш проект: `src/<PROJECT_NAME>/dashboards/widget-data-loader`
|
|
||||||
|
|
||||||
Рассматриваемый виджет будет универсальным, поэтому файл сохраним в `libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
|
||||||
import { Result, AppError } from '@app/event-emitter/utils/result';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CalendarWidgetDataLoaderService
|
|
||||||
implements WidgetDataLoaderInterface<any, any, any>
|
|
||||||
{
|
|
||||||
// TODO: Добавить конструктор для подключения
|
|
||||||
// дополнительных зависимостей
|
|
||||||
|
|
||||||
isMyConfig(dataLoaderParams: any): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
load(
|
|
||||||
dataLoaderParams: any,
|
|
||||||
dashboardParams: any,
|
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<any, AppError>> {
|
|
||||||
// TODO: Логика для загрузки данных
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
В фреймворках для классических случаев по концепции MVC делают сервисы для реализации в них логики и контроллеры для открытия ендпоинтов для передачи данных пользователям и на frontend приложения.
|
|
||||||
|
|
||||||
К представленному выше `DataLoaderService` можно относиться как к контроллеру в концепции MVC, который обратиться к более низкоуровневому сервису для получения данных, а так же передаст эти результаты уже в виджет на frontend-е.
|
|
||||||
|
|
||||||
Сервис для получения данных уже был разработан, и остаётся его подключить к data-loader-у класс `CalendarService` через конструктор и получить с его помощью нужные данные вызвав метод `CalendarService.getRawData`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
@Injectable()
|
|
||||||
export class CalendarWidgetDataLoaderService
|
|
||||||
implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
|
|
||||||
{
|
|
||||||
constructor(private calendarService: CalendarService) {}
|
|
||||||
|
|
||||||
isMyConfig(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(
|
|
||||||
dataLoaderParams: DataLoaderParams,
|
|
||||||
dashboardParams: any,
|
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<IssueAndEvent[], AppError>> {
|
|
||||||
let data: IssueAndEvent[];
|
|
||||||
try {
|
|
||||||
data = await this.calendarService.getRawData(
|
|
||||||
dataLoaderParams.filter,
|
|
||||||
dataLoaderParams.period * 24 * 60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
return success(data);
|
|
||||||
} catch (ex) {
|
|
||||||
return fail(createAppError(ex.message ? ex.message : 'UNKNOWN_ERROR'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Остаётся зарегистрировать data-loader в коллекцию доступных виджетов на стороне backend-а и задать ему свой уникальный `type`.
|
|
||||||
|
|
||||||
> Архитектура предполагает кроме реализации `WidgetDataLoaderInterface` ещё и реализацию собственно виджета `WidgetInterface`, но последнее можно упросить с помощью вызова фабричного метода `createInteractiveWidget` - он создаст экземпляр `InteractiveWidget` для реализованного ранее data-loader-а. Более подробно можно ознакомитсья с разными типами виджетов, как они устроены в разделе [[Архитектура дашбордов]].
|
|
||||||
|
|
||||||
Зарегистрировать новый тип виджета можно в коллекции `libs/event-emitter/src/dashboards/widgets-collection.service.ts` для этого в конструктор добавить такой код:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
createInteractiveWidget(
|
|
||||||
this.calendarWidgetDataLoaderService,
|
|
||||||
'calendar_next_events',
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Важно:** проделать необходимые изменения в соответствии с требованиями базового фреймворка NestJS. Сюда входят требования к инъекции зависимостей в конструктор из сервисов доступных через модули `libs/event-emitter/src/event-emitter.module.ts` и `src/app.module.ts` и включение новых сервисов в эти модули. Подробнее об этом можно прочитать в документации [NestJS - Providers](https://docs.nestjs.com/providers).
|
|
||||||
|
|
||||||
На backend-е всё готово. Переходим к самому интересному - frontend-у
|
|
||||||
|
|
||||||
## Виджет на frontend-е
|
|
||||||
|
|
||||||
Начнём с непосредственно компонента. Виджеты лежат в папке `frontend/src/dashboard/widgets`. Добавим туда же новый виджет `frontend/src/dashboard/widgets/calendar-next-events.tsx`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const CalendarNextEvents = (): JSX.Element => {
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот пустой компонент можно сразу добавить в фабрику виджетов дашборда на frontend-е:
|
|
||||||
|
|
||||||
`frontend/src/dashboard/widgets/widget-factory.tsx`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { CalendarNextEvents } from './calendar-next-events';
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
export const WidgetFactory = observer((props: Props): JSX.Element => {
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
if (props.store.type === 'calendar_next_events') {
|
|
||||||
return <CalendarNextEvents store={props.store} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Важно!** Чтобы `type` совпадало с объявленным типом виджета на backend-е. Объявление на backend-е было сделано в файле `libs/event-emitter/src/dashboards/widgets-collection.service.ts`.
|
|
||||||
|
|
||||||
Теперь можно снова вернуться к виджету `calendar-next-events.tsx`.
|
|
||||||
|
|
||||||
Для управления состоянием компонентов на стороне frontend-а предполагается использование [mobx-state-tree](https://mobx-state-tree.js.org/), поэтому для компоненты можно определить следующий стор с массивом данных event+issue:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Описание основных и вспомогательных моделей данных для календаря:
|
|
||||||
|
|
||||||
export const CalendarEvent = types.model({
|
|
||||||
from: types.string,
|
|
||||||
fromTimestamp: types.number,
|
|
||||||
to: types.string,
|
|
||||||
toTimestamp: types.number,
|
|
||||||
fullDay: types.boolean,
|
|
||||||
description: types.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ICalendarEvent = Instance<typeof CalendarEvent>;
|
|
||||||
|
|
||||||
export const FormattedDateTime = types.model({
|
|
||||||
fromDate: types.string,
|
|
||||||
fromTime: types.string,
|
|
||||||
toDate: types.string,
|
|
||||||
toTime: types.string,
|
|
||||||
interval: types.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IFormattedDateTime = Instance<typeof FormattedDateTime>;
|
|
||||||
|
|
||||||
export const IssueAndEvent = types.model({
|
|
||||||
issue: types.frozen<any>(),
|
|
||||||
event: CalendarEvent,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IIssueAndEvent = Instance<typeof IssueAndEvent>;
|
|
||||||
|
|
||||||
type InDay = {
|
|
||||||
order: number;
|
|
||||||
relativeDate: string | null;
|
|
||||||
formattedDate: string;
|
|
||||||
events: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DATE_FORMAT = 'dd.MM.yyyy';
|
|
||||||
export const TIME_FORMAT = 'HH:mm';
|
|
||||||
|
|
||||||
function getEventKey(e: IIssueAndEvent): string {
|
|
||||||
const description: string = e.event.description.replaceAll(/\s+/g, '_');
|
|
||||||
const fromKey = String(e.event.fromTimestamp);
|
|
||||||
const toKey = String(e.event.toTimestamp);
|
|
||||||
return `${e.issue.id}-${fromKey}-${toKey}-${description}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFormattedDateTime(
|
|
||||||
issueAndEvent: IIssueAndEvent,
|
|
||||||
): IFormattedDateTime {
|
|
||||||
const from = DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
const to = DateTime.fromMillis(issueAndEvent.event.toTimestamp);
|
|
||||||
const fromDate: string = from.isValid ? from.toFormat(DATE_FORMAT) : '-';
|
|
||||||
const fromTime: string = from.isValid ? from.toFormat(TIME_FORMAT) : '-';
|
|
||||||
const toDate: string = to.isValid ? to.toFormat(DATE_FORMAT) : '-';
|
|
||||||
const toTime: string = to.isValid ? to.toFormat(TIME_FORMAT) : '-';
|
|
||||||
let interval: string;
|
|
||||||
if (issueAndEvent.event.fullDay) {
|
|
||||||
interval = 'весь день';
|
|
||||||
} else if (toTime != '-') {
|
|
||||||
interval = `${fromTime} - ${toTime}`;
|
|
||||||
} else {
|
|
||||||
interval = `${fromTime}`;
|
|
||||||
}
|
|
||||||
return FormattedDateTime.create({
|
|
||||||
fromDate: fromDate,
|
|
||||||
fromTime: fromTime,
|
|
||||||
toDate: toDate,
|
|
||||||
toTime: toTime,
|
|
||||||
interval: interval,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRelativeDate(issueAndEvent: IIssueAndEvent): string | null {
|
|
||||||
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
return from.toRelativeCalendar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStartOfDayTimestamp(issueAndEvent: IIssueAndEvent): number {
|
|
||||||
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
return from.startOf('day').toMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Стор данных для виджета "Календарь следующих событий" */
|
|
||||||
export const EventsStore = types
|
|
||||||
.model({
|
|
||||||
events: types.array(IssueAndEvent),
|
|
||||||
})
|
|
||||||
.views((self) => {
|
|
||||||
return {
|
|
||||||
/** Геттер мапы событий в формате eventKey -> issue+event */
|
|
||||||
eventsMap: (): Record<string, IIssueAndEvent> => {
|
|
||||||
return self.events.reduce((acc, issueAndEvent) => {
|
|
||||||
const key = getEventKey(issueAndEvent);
|
|
||||||
acc[key] = issueAndEvent;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, IIssueAndEvent>);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Геттер сгруппированных по дням данных.
|
|
||||||
*
|
|
||||||
* Ключ группы определяется по timestamp-у указывающему на начало дня
|
|
||||||
*
|
|
||||||
* Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной
|
|
||||||
* информации об этой группе событий
|
|
||||||
*/
|
|
||||||
orderedByDates: (): InDay[] => {
|
|
||||||
const res: Record<number, InDay> = self.events.reduce(
|
|
||||||
(acc, issueAndEvent) => {
|
|
||||||
const order = getStartOfDayTimestamp(issueAndEvent);
|
|
||||||
const formattedDate = DateTime.fromMillis(
|
|
||||||
issueAndEvent.event.fromTimestamp,
|
|
||||||
).toFormat(DATE_FORMAT);
|
|
||||||
if (!acc[order]) {
|
|
||||||
acc[order] = {
|
|
||||||
order: order,
|
|
||||||
relativeDate: getRelativeDate(issueAndEvent),
|
|
||||||
formattedDate: formattedDate,
|
|
||||||
events: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const key = getEventKey(issueAndEvent);
|
|
||||||
acc[order].events.push(key);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<number, InDay>,
|
|
||||||
);
|
|
||||||
return Object.values(res).sort((a, b) => a.order - b.order);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Мапа событий с отформатированными значениями даты и времени
|
|
||||||
*
|
|
||||||
* * Ключ - это ключ события
|
|
||||||
* * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени
|
|
||||||
*/
|
|
||||||
formattedDateTimes: (): Record<string, IFormattedDateTime> => {
|
|
||||||
const res: Record<string, IFormattedDateTime> = self.events.reduce(
|
|
||||||
(acc, event) => {
|
|
||||||
const key = getEventKey(event);
|
|
||||||
acc[key] = getFormattedDateTime(event);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, IFormattedDateTime>,
|
|
||||||
);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.actions((self) => {
|
|
||||||
return {
|
|
||||||
/** Сеттер основных данных */
|
|
||||||
setEvents: (events: any): void => {
|
|
||||||
self.events = events;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IEventsStore = Instance<typeof EventsStore>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот стор будет принимать данные получаемые с backend-а через action "setEvents" и с помощью вспомогательных геттеров предоставлять нужную информацию для вывода в виджете.
|
|
||||||
|
|
||||||
Теперь остаётся в react-компоненте CalendarNextEvents добавить использование описанного выше стора и сделать рендер данных:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
/**
|
|
||||||
* Компонент для отображения данных из стора EventsStore
|
|
||||||
*
|
|
||||||
* @see {EventsStore}
|
|
||||||
*/
|
|
||||||
export const CalendarList = observer(
|
|
||||||
(props: { store: IEventsStore }): JSX.Element => {
|
|
||||||
const list = props.store.orderedByDates().map((events) => {
|
|
||||||
const keyOfGroup = `${events.order}-${events.relativeDate}`;
|
|
||||||
const item = (
|
|
||||||
<div key={keyOfGroup}>
|
|
||||||
<p title={events.formattedDate}>{events.relativeDate}:</p>
|
|
||||||
<ul>
|
|
||||||
{events.events.map((keyOfEvent) => {
|
|
||||||
const events = props.store.eventsMap();
|
|
||||||
const formatted = props.store.formattedDateTimes();
|
|
||||||
if (!events[keyOfEvent] && !formatted[keyOfEvent]) return <></>;
|
|
||||||
const issue = events[keyOfEvent].issue;
|
|
||||||
return (
|
|
||||||
<li key={keyOfEvent}>
|
|
||||||
{formatted[keyOfEvent].interval}:{' '}
|
|
||||||
<IssueHref
|
|
||||||
id={issue.id}
|
|
||||||
subject={events[keyOfEvent].event.description}
|
|
||||||
tracker={issue.tracker.name}
|
|
||||||
url={issue.url.url}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
return <>{list}</>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
store: DashboardStoreNs.IWidget;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Основной компонент календаря
|
|
||||||
*
|
|
||||||
* Он нужен для преобразования стора абстрактного виджета в стор специфичный для
|
|
||||||
* календаря
|
|
||||||
*/
|
|
||||||
export const CalendarNextEvents = observer((props: Props): JSX.Element => {
|
|
||||||
const calendarListStore = EventsStore.create();
|
|
||||||
onSnapshot(props.store, (storeState) => {
|
|
||||||
if (storeState.data) {
|
|
||||||
calendarListStore.setEvents(storeState.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CalendarList store={calendarListStore} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Проверка
|
|
||||||
|
|
||||||
Если всё было сделано верно, то в дашбордах станет доступен новый виджет "calendar_next_events".
|
|
||||||
|
|
||||||
Можно проверить на тестовом дашборде с конфигурацией следующего вида:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"widgets": [
|
|
||||||
{
|
|
||||||
"id": "test-calendar",
|
|
||||||
"title": "Тест календаря",
|
|
||||||
"type": "calendar_next_events",
|
|
||||||
"collapsed": true,
|
|
||||||
"dataLoaderParams": {
|
|
||||||
"filter": {
|
|
||||||
"selector": {
|
|
||||||
"project.name": "proj"
|
|
||||||
},
|
|
||||||
"limit": 10
|
|
||||||
},
|
|
||||||
"period": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Test"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
И убедиться что данные соответствуют указанным данным в задачах Redmine:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# Установка
|
|
||||||
|
|
||||||
Инструкция для установки приложения
|
|
||||||
|
|
||||||
## Требования к системе
|
|
||||||
|
|
||||||
Требования к окружению для сборки и запуска приложения:
|
|
||||||
|
|
||||||
- CouchDB в качестве хранилища данных
|
|
||||||
- NodeJS для сборки и запуска backend-а и frontend-а
|
|
||||||
|
|
||||||
Доступы к внешним системам:
|
|
||||||
|
|
||||||
- Токен для подключения telegram-бота
|
|
||||||
- Токен для доступа к API Redmine
|
|
||||||
|
|
||||||
## Подготовка и запуск couchdb
|
|
||||||
|
|
||||||
CouchDB необходим для хранения данных.
|
|
||||||
|
|
||||||
Для запуска можно воспользоваться любым удобным сопособом.
|
|
||||||
|
|
||||||
Пример с помощью docker-compose
|
|
||||||
|
|
||||||
1. `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.1'
|
|
||||||
|
|
||||||
services:
|
|
||||||
couchdb:
|
|
||||||
image: apache/couchdb:latest
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:5984:5984
|
|
||||||
volumes:
|
|
||||||
- ./couchdb-data:/opt/couchdb/data
|
|
||||||
environment:
|
|
||||||
- COUCHDB_USER
|
|
||||||
- COUCHDB_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Так же нужно указать имя и пароль с помощью файла `.env`:
|
|
||||||
|
|
||||||
```
|
|
||||||
COUCHDB_USER=admin
|
|
||||||
COUCHDB_PASSWORD=password
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Создать папку `couchdb-data` для вольюма определённого для контейнера в `docker-compose.yml`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mkdir couchdb-data
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Для запуска воспользоваться командой:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Установка зависимостей для backend-а и frontend-а
|
|
||||||
|
|
||||||
Backend и frontend написаны на языке typescript. Для загрузки и запуска необходимо выполнить:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cd $PROJECT_DIR && npm install
|
|
||||||
cd $PROJECT_DIR/frontend && npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Настройка
|
|
||||||
|
|
||||||
Нужно скопировать все конфигурационные файлы `CONFIG_NAME.jsonc.dist` в `CONFIG_NAME.jsonc`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cd $PROJECT_DIR/configs/
|
|
||||||
|
|
||||||
cp calendar-enhancer.jsonc.dist calendar-enhancer.jsonc
|
|
||||||
cp current-user-rules.jsonc.dist current-user-rules.jsonc
|
|
||||||
cp eccm-config.jsonc.dist eccm-config.jsonc
|
|
||||||
cp issue-event-emitter-config.jsonc.dist issue-event-emitter-config.jsonc
|
|
||||||
cp main-config.jsonc.dist main-config.jsonc
|
|
||||||
cp redmine-status-changes-config.jsonc.dist redmine-status-changes-config.jsonc
|
|
||||||
cp redmine-statuses-config.jsonc.dist redmine-statuses-config.jsonc
|
|
||||||
cp simple-kanban-board-config.jsonc.dist simple-kanban-board-config.jsonc
|
|
||||||
```
|
|
||||||
|
|
||||||
Или можно взять за основу файлы из папки `$PROJECT_DIR/configs/configs.example/` или в качестве примеров для заполнения. В этих файлах можно обратить внимание на строчки с TODO и рекоментациями что нужно указать в конфигурационном файле. Там могут встречаться такие рекомендации как:
|
|
||||||
|
|
||||||
* Указать host, port, username и password для доступа к couchdb
|
|
||||||
* Указать token от бота telegram
|
|
||||||
|
|
||||||
## Запуск приложения
|
|
||||||
|
|
||||||
1. Нужно сделать сборку frontend-а:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cd $PROJECT_DIR/frontend/ && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Сделать сборку или можно просто запустить backend:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cd $PROJECT_DIR/ && npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
Если нужно запустить приложение с явным указанием порта, то можно воспользоваться переменной окружения `PORT`.
|
|
||||||
46
frontend/package-lock.json
generated
|
|
@ -26,7 +26,6 @@
|
||||||
"react-router-dom": "^6.11.1",
|
"react-router-dom": "^6.11.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"uuid": "^11.0.5",
|
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -14050,9 +14049,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -15110,14 +15109,6 @@
|
||||||
"websocket-driver": "^0.7.4"
|
"websocket-driver": "^0.7.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sockjs/node_modules/uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-list-map": {
|
"node_modules/source-list-map": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
||||||
|
|
@ -16151,15 +16142,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.0.5",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/esm/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
|
|
@ -26972,9 +26959,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"react": {
|
"react": {
|
||||||
"version": "18.3.1",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -27740,13 +27727,6 @@
|
||||||
"faye-websocket": "^0.11.3",
|
"faye-websocket": "^0.11.3",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"websocket-driver": "^0.7.4"
|
"websocket-driver": "^0.7.4"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"source-list-map": {
|
"source-list-map": {
|
||||||
|
|
@ -28516,9 +28496,9 @@
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
|
||||||
},
|
},
|
||||||
"uuid": {
|
"uuid": {
|
||||||
"version": "11.0.5",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA=="
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||||
},
|
},
|
||||||
"v8-to-istanbul": {
|
"v8-to-istanbul": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,10 @@
|
||||||
"react-router-dom": "^6.11.1",
|
"react-router-dom": "^6.11.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"uuid": "^11.0.5",
|
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "BROWSER=none react-scripts start --open=false",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,6 @@ export const Editor = observer((props: Props): JSX.Element => {
|
||||||
defaultValue={editorValue}
|
defaultValue={editorValue}
|
||||||
value={editorValue}
|
value={editorValue}
|
||||||
onChange={(value) => setEditorValue(value || '')}
|
onChange={(value) => setEditorValue(value || '')}
|
||||||
options={{ tabSize: 2, detectIndentation: true }}
|
|
||||||
></MonacoEditor>
|
></MonacoEditor>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import { observer } from 'mobx-react-lite';
|
|
||||||
import { Instance, onSnapshot, types } from 'mobx-state-tree';
|
|
||||||
import React from 'react';
|
|
||||||
import * as Luxon from 'luxon';
|
|
||||||
import * as DashboardStoreNs from '../dashboard-store';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { IssueHref } from '../../misc-components/issue-href';
|
|
||||||
|
|
||||||
// Описание основных и вспомогательных моделей данных для календаря:
|
|
||||||
|
|
||||||
export const CalendarEvent = types.model({
|
|
||||||
from: types.string,
|
|
||||||
fromTimestamp: types.number,
|
|
||||||
to: types.string,
|
|
||||||
toTimestamp: types.number,
|
|
||||||
fullDay: types.boolean,
|
|
||||||
description: types.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ICalendarEvent = Instance<typeof CalendarEvent>;
|
|
||||||
|
|
||||||
export const FormattedDateTime = types.model({
|
|
||||||
fromDate: types.string,
|
|
||||||
fromTime: types.string,
|
|
||||||
toDate: types.string,
|
|
||||||
toTime: types.string,
|
|
||||||
interval: types.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IFormattedDateTime = Instance<typeof FormattedDateTime>;
|
|
||||||
|
|
||||||
export const IssueAndEvent = types.model({
|
|
||||||
issue: types.frozen<any>(),
|
|
||||||
event: CalendarEvent,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IIssueAndEvent = Instance<typeof IssueAndEvent>;
|
|
||||||
|
|
||||||
type InDay = {
|
|
||||||
order: number;
|
|
||||||
relativeDate: string | null;
|
|
||||||
formattedDate: string;
|
|
||||||
events: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DATE_FORMAT = 'dd.MM.yyyy';
|
|
||||||
export const TIME_FORMAT = 'HH:mm';
|
|
||||||
|
|
||||||
function getEventKey(e: IIssueAndEvent): string {
|
|
||||||
const description: string = e.event.description.replaceAll(/\s+/g, '_');
|
|
||||||
const fromKey = String(e.event.fromTimestamp);
|
|
||||||
const toKey = String(e.event.toTimestamp);
|
|
||||||
return `${e.issue.id}-${fromKey}-${toKey}-${description}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFormattedDateTime(
|
|
||||||
issueAndEvent: IIssueAndEvent,
|
|
||||||
): IFormattedDateTime {
|
|
||||||
const from = DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
const to = DateTime.fromMillis(issueAndEvent.event.toTimestamp);
|
|
||||||
const fromDate: string = from.isValid ? from.toFormat(DATE_FORMAT) : '-';
|
|
||||||
const fromTime: string = from.isValid ? from.toFormat(TIME_FORMAT) : '-';
|
|
||||||
const toDate: string = to.isValid ? to.toFormat(DATE_FORMAT) : '-';
|
|
||||||
const toTime: string = to.isValid ? to.toFormat(TIME_FORMAT) : '-';
|
|
||||||
let interval: string;
|
|
||||||
if (issueAndEvent.event.fullDay) {
|
|
||||||
interval = 'весь день';
|
|
||||||
} else if (toTime != '-') {
|
|
||||||
interval = `${fromTime} - ${toTime}`;
|
|
||||||
} else {
|
|
||||||
interval = `${fromTime}`;
|
|
||||||
}
|
|
||||||
return FormattedDateTime.create({
|
|
||||||
fromDate: fromDate,
|
|
||||||
fromTime: fromTime,
|
|
||||||
toDate: toDate,
|
|
||||||
toTime: toTime,
|
|
||||||
interval: interval,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRelativeDate(issueAndEvent: IIssueAndEvent): string | null {
|
|
||||||
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
return from.toRelativeCalendar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStartOfDayTimestamp(issueAndEvent: IIssueAndEvent): number {
|
|
||||||
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
|
|
||||||
return from.startOf('day').toMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Стор данных для виджета "Календарь следующих событий" */
|
|
||||||
export const EventsStore = types
|
|
||||||
.model({
|
|
||||||
events: types.array(IssueAndEvent),
|
|
||||||
})
|
|
||||||
.views((self) => {
|
|
||||||
return {
|
|
||||||
/** Геттер мапы событий в формате eventKey -> issue+event */
|
|
||||||
eventsMap: (): Record<string, IIssueAndEvent> => {
|
|
||||||
return self.events.reduce((acc, issueAndEvent) => {
|
|
||||||
const key = getEventKey(issueAndEvent);
|
|
||||||
acc[key] = issueAndEvent;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, IIssueAndEvent>);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Геттер сгруппированных по дням данных.
|
|
||||||
*
|
|
||||||
* Ключ группы определяется по timestamp-у указывающему на начало дня
|
|
||||||
*
|
|
||||||
* Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной
|
|
||||||
* информации об этой группе событий
|
|
||||||
*/
|
|
||||||
orderedByDates: (): InDay[] => {
|
|
||||||
const res: Record<number, InDay> = self.events.reduce(
|
|
||||||
(acc, issueAndEvent) => {
|
|
||||||
const order = getStartOfDayTimestamp(issueAndEvent);
|
|
||||||
const formattedDate = DateTime.fromMillis(
|
|
||||||
issueAndEvent.event.fromTimestamp,
|
|
||||||
).toFormat(DATE_FORMAT);
|
|
||||||
if (!acc[order]) {
|
|
||||||
acc[order] = {
|
|
||||||
order: order,
|
|
||||||
relativeDate: getRelativeDate(issueAndEvent),
|
|
||||||
formattedDate: formattedDate,
|
|
||||||
events: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const key = getEventKey(issueAndEvent);
|
|
||||||
acc[order].events.push(key);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<number, InDay>,
|
|
||||||
);
|
|
||||||
return Object.values(res).sort((a, b) => a.order - b.order);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Мапа событий с отформатированными значениями даты и времени
|
|
||||||
*
|
|
||||||
* * Ключ - это ключ события
|
|
||||||
* * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени
|
|
||||||
*/
|
|
||||||
formattedDateTimes: (): Record<string, IFormattedDateTime> => {
|
|
||||||
const res: Record<string, IFormattedDateTime> = self.events.reduce(
|
|
||||||
(acc, event) => {
|
|
||||||
const key = getEventKey(event);
|
|
||||||
acc[key] = getFormattedDateTime(event);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, IFormattedDateTime>,
|
|
||||||
);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.actions((self) => {
|
|
||||||
return {
|
|
||||||
/** Сеттер основных данных */
|
|
||||||
setEvents: (events: any): void => {
|
|
||||||
self.events = events;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IEventsStore = Instance<typeof EventsStore>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Компонент для отображения данных из стора EventsStore
|
|
||||||
*
|
|
||||||
* @see {EventsStore}
|
|
||||||
*/
|
|
||||||
export const CalendarList = observer(
|
|
||||||
(props: { store: IEventsStore }): JSX.Element => {
|
|
||||||
const list = props.store.orderedByDates().map((events) => {
|
|
||||||
const keyOfGroup = `${events.order}-${events.relativeDate}`;
|
|
||||||
const item = (
|
|
||||||
<div key={keyOfGroup}>
|
|
||||||
<p title={events.formattedDate}>{events.relativeDate}:</p>
|
|
||||||
<ul>
|
|
||||||
{events.events.map((keyOfEvent) => {
|
|
||||||
const events = props.store.eventsMap();
|
|
||||||
const formatted = props.store.formattedDateTimes();
|
|
||||||
if (!events[keyOfEvent] && !formatted[keyOfEvent]) return <></>;
|
|
||||||
const issue = events[keyOfEvent].issue;
|
|
||||||
return (
|
|
||||||
<li key={keyOfEvent}>
|
|
||||||
{formatted[keyOfEvent].interval}:{' '}
|
|
||||||
<IssueHref
|
|
||||||
id={issue.id}
|
|
||||||
subject={events[keyOfEvent].event.description}
|
|
||||||
tracker={issue.tracker.name}
|
|
||||||
url={issue.url.url}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
return <>{list}</>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
store: DashboardStoreNs.IWidget;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Основной компонент календаря
|
|
||||||
*
|
|
||||||
* Он нужен для преобразования стора абстрактного виджета в стор специфичный для
|
|
||||||
* календаря
|
|
||||||
*/
|
|
||||||
export const CalendarNextEvents = observer((props: Props): JSX.Element => {
|
|
||||||
const calendarListStore = EventsStore.create();
|
|
||||||
onSnapshot(props.store, (storeState) => {
|
|
||||||
if (storeState.data) {
|
|
||||||
calendarListStore.setEvents(storeState.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CalendarList store={calendarListStore} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import * as DashboardStoreNs from '../dashboard-store';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
|
||||||
import { DebugInfo } from '../../misc-components/debug-info';
|
|
||||||
import { Instance, onSnapshot, types } from 'mobx-state-tree';
|
|
||||||
import { text2id } from '../../utils/text-to-id';
|
|
||||||
|
|
||||||
export const DailyEccmV2Data = types.model({
|
|
||||||
id: types.string,
|
|
||||||
dashboardId: types.string,
|
|
||||||
widgetId: types.string,
|
|
||||||
datetime: types.number,
|
|
||||||
datetimeFormatted: types.string,
|
|
||||||
reportIssues: types.array(types.frozen()),
|
|
||||||
issuesMetrics: types.frozen(),
|
|
||||||
latest: types.boolean,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DailyEccmV2Store = types
|
|
||||||
.model({
|
|
||||||
data: DailyEccmV2Data,
|
|
||||||
})
|
|
||||||
.actions((self) => ({
|
|
||||||
setData(data: any) {
|
|
||||||
self.data = data;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type IDailyEccmV2Store = Instance<typeof DailyEccmV2Store>;
|
|
||||||
|
|
||||||
const VerySimpleCounterViewComponent = (props: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
value: number | string;
|
|
||||||
details?: any;
|
|
||||||
}): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{props.title}:
|
|
||||||
<span style={{ marginLeft: '10px' }}> {props.value}</span>
|
|
||||||
<span style={{ marginLeft: '20px', color: 'lightgray' }}>
|
|
||||||
{props.details ? JSON.stringify(props.details) : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VerySimpleCounterListViewComponent = (props: {
|
|
||||||
id: string;
|
|
||||||
data: { title: string; value: number | string; details?: any }[];
|
|
||||||
}): JSX.Element => {
|
|
||||||
const items: JSX.Element[] = [];
|
|
||||||
props.data.forEach((item) => {
|
|
||||||
const itemId = `${props.id}-${text2id(item.title)}`;
|
|
||||||
items.push(
|
|
||||||
<VerySimpleCounterViewComponent
|
|
||||||
key={itemId}
|
|
||||||
id={itemId}
|
|
||||||
title={item.title}
|
|
||||||
value={item.value}
|
|
||||||
details={item.details}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<ul key={props.id}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<li key={item.props.id + '-li'}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DailyEccmV2 = observer(
|
|
||||||
(props: { store: IDailyEccmV2Store }): JSX.Element => {
|
|
||||||
const dashboardId = props.store?.data?.dashboardId;
|
|
||||||
const widgetId = props.store?.data?.widgetId;
|
|
||||||
if (!dashboardId || !widgetId) return <></>;
|
|
||||||
|
|
||||||
const keyPrefix = `dashboard-${dashboardId}-widget-${widgetId}`;
|
|
||||||
|
|
||||||
const otherData: {
|
|
||||||
title: string;
|
|
||||||
value: number | string;
|
|
||||||
details?: any;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// ШАГ 1. Количество задач по статусам
|
|
||||||
const issuesByStatusCount2 =
|
|
||||||
props.store?.data?.issuesMetrics?.issuesByStatusCount ?? {};
|
|
||||||
const issuesByStatusCount2Prefix = `Счётчики с количеством задач по статусам`;
|
|
||||||
Object.keys(issuesByStatusCount2).forEach((statusName) => {
|
|
||||||
const statusData = issuesByStatusCount2[statusName];
|
|
||||||
if (statusData) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${issuesByStatusCount2Prefix} / ${statusName} / Количество задач`,
|
|
||||||
value: statusData.count,
|
|
||||||
details: {
|
|
||||||
issueIds: statusData.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const byVersions = statusData.byVersion;
|
|
||||||
const byVersionsPrefix = `${issuesByStatusCount2Prefix} / ${statusName} / По версиям`;
|
|
||||||
if (byVersions) {
|
|
||||||
Object.keys(byVersions).forEach((versionName) => {
|
|
||||||
otherData.push({
|
|
||||||
title: `${byVersionsPrefix} / ${versionName} / Количество задач`,
|
|
||||||
value: byVersions[versionName].count,
|
|
||||||
details: {
|
|
||||||
issueIds: byVersions[versionName].issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ШАГ 2. Количество задач по версиям
|
|
||||||
const issuesByVersionCount2 =
|
|
||||||
props.store?.data?.issuesMetrics?.issuesByVersionsCount ?? {};
|
|
||||||
const issuesByVersionCount2Prefix = 'Счётчики по версиям';
|
|
||||||
Object.keys(issuesByVersionCount2).forEach((versionName) => {
|
|
||||||
const versionData = issuesByVersionCount2[versionName];
|
|
||||||
if (versionData) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${issuesByVersionCount2Prefix} / ${versionName} / Количество задач`,
|
|
||||||
value: versionData.count,
|
|
||||||
details: {
|
|
||||||
issueIds: versionData.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const byStatuses = versionData.byStatus;
|
|
||||||
const byStatusesPrefix = `${issuesByVersionCount2Prefix} / ${versionName} / По статусам`;
|
|
||||||
if (byStatuses) {
|
|
||||||
Object.keys(byStatuses).forEach((statusName) => {
|
|
||||||
otherData.push({
|
|
||||||
title: `${byStatusesPrefix} / ${statusName} / Количество задач`,
|
|
||||||
value: byStatuses[statusName].count,
|
|
||||||
details: {
|
|
||||||
issueIds: byStatuses[statusName].issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ШАГ 3. Количество задач по работникам
|
|
||||||
const issuesByUsername2 =
|
|
||||||
props.store?.data?.issuesMetrics?.issuesByUsername ?? {};
|
|
||||||
const issuesByUsername2Prefix = 'Счётчики по работникам';
|
|
||||||
Object.keys(issuesByUsername2).forEach((username) => {
|
|
||||||
const userData = issuesByUsername2[username];
|
|
||||||
if (userData) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${issuesByUsername2Prefix} / ${username} / Количество задач`,
|
|
||||||
value: userData.count,
|
|
||||||
details: {
|
|
||||||
issueIds: userData.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ШАГ 4. Количество внутренних изменений
|
|
||||||
const changesCount2 = props.store?.data?.issuesMetrics?.changesCount ?? {};
|
|
||||||
const changesCount2Prefix = 'Счётчики изменений';
|
|
||||||
otherData.push({
|
|
||||||
title: `${changesCount2Prefix} / Всего изменений`,
|
|
||||||
value: changesCount2.totalChanges || 0,
|
|
||||||
});
|
|
||||||
otherData.push({
|
|
||||||
title: `${changesCount2Prefix} / Всего комментариев`,
|
|
||||||
value: changesCount2.totalComments || 0,
|
|
||||||
});
|
|
||||||
const sections: Record<string, string> = {
|
|
||||||
byIssue: 'По задачам',
|
|
||||||
byStatus: 'По статусам',
|
|
||||||
byVersion: 'По версиям',
|
|
||||||
byUserName: 'По работникам',
|
|
||||||
};
|
|
||||||
Object.keys(sections).forEach((sectionName) => {
|
|
||||||
const sectionData = changesCount2[sectionName];
|
|
||||||
if (!sectionData) return;
|
|
||||||
const sectionPrefix = `${changesCount2Prefix} / ${sections[sectionName]}`;
|
|
||||||
Object.keys(sectionData).forEach((itemKey) => {
|
|
||||||
const itemData = sectionData[itemKey];
|
|
||||||
otherData.push({
|
|
||||||
title: `${sectionPrefix} / ${itemKey} / Изменения`,
|
|
||||||
value: itemData.changes || 0,
|
|
||||||
});
|
|
||||||
otherData.push({
|
|
||||||
title: `${sectionPrefix} / ${itemKey} / Комментарии`,
|
|
||||||
value: itemData.comments || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ШАГ 5. Количество задач с оценками трудозатрат
|
|
||||||
const issuesWithEstimatesAndSpenthoursCount =
|
|
||||||
props.store?.data?.issuesMetrics?.issuesWithEstimatesAndSpenthoursCount ??
|
|
||||||
{};
|
|
||||||
|
|
||||||
const estimatesPrefix =
|
|
||||||
'Счётчики с оценкой трудозатрат и потраченных часов';
|
|
||||||
|
|
||||||
if (issuesWithEstimatesAndSpenthoursCount.withEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefix} / С оценкой трудозатрат`,
|
|
||||||
value: issuesWithEstimatesAndSpenthoursCount.withEstimates.count,
|
|
||||||
details: {
|
|
||||||
issueIds:
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withEstimates.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (issuesWithEstimatesAndSpenthoursCount.withoutEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefix} / Без оценки трудозатрат`,
|
|
||||||
value: issuesWithEstimatesAndSpenthoursCount.withoutEstimates.count,
|
|
||||||
details: {
|
|
||||||
issueIds:
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefix} / С потраченными часами больше, чем оценка трудозатрат`,
|
|
||||||
value:
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates
|
|
||||||
.count,
|
|
||||||
details: {
|
|
||||||
issueIds:
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates
|
|
||||||
.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (issuesWithEstimatesAndSpenthoursCount.byVersion) {
|
|
||||||
const estimatesPrefixVersions = `${estimatesPrefix} / По версиям`;
|
|
||||||
Object.keys(issuesWithEstimatesAndSpenthoursCount.byVersion).forEach(
|
|
||||||
(versionName) => {
|
|
||||||
const estimatesPrefixVersion = `${estimatesPrefixVersions} / ${versionName}`;
|
|
||||||
const versionData =
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[versionName];
|
|
||||||
if (versionData.withEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixVersion} / С оценкой трудозатрат`,
|
|
||||||
value: versionData.withEstimates.count,
|
|
||||||
details: { issueIds: versionData.withEstimates.issueIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (versionData.withoutEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixVersion} / Без оценки трудозатрат`,
|
|
||||||
value: versionData.withoutEstimates.count,
|
|
||||||
details: { issueIds: versionData.withoutEstimates.issueIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (versionData.withSpentHoursOverEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixVersion} / С потраченными часами больше, чем оценка трудозатрат`,
|
|
||||||
value: versionData.withSpentHoursOverEstimates.count,
|
|
||||||
details: {
|
|
||||||
issueIds: versionData.withSpentHoursOverEstimates.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (issuesWithEstimatesAndSpenthoursCount.byStatus) {
|
|
||||||
const estimatesPrefixStatuses = `${estimatesPrefix} / По статусам`;
|
|
||||||
Object.keys(issuesWithEstimatesAndSpenthoursCount.byStatus).forEach(
|
|
||||||
(statusName) => {
|
|
||||||
const estimatesPrefixStatus = `${estimatesPrefixStatuses} / ${statusName}`;
|
|
||||||
const statusData =
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[statusName];
|
|
||||||
if (statusData.withEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixStatus} / С оценкой трудозатрат`,
|
|
||||||
value: statusData.withEstimates.count,
|
|
||||||
details: { issueIds: statusData.withEstimates.issueIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (statusData.withoutEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixStatus} / Без оценки трудозатрат`,
|
|
||||||
value: statusData.withoutEstimates.count,
|
|
||||||
details: { issueIds: statusData.withoutEstimates.issueIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (statusData.withSpentHoursOverEstimates) {
|
|
||||||
otherData.push({
|
|
||||||
title: `${estimatesPrefixStatus} / С потраченными часами больше, чем оценка трудозатрат`,
|
|
||||||
value: statusData.withSpentHoursOverEstimates.count,
|
|
||||||
details: {
|
|
||||||
issueIds: statusData.withSpentHoursOverEstimates.issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Daily ECCM V2</h1>
|
|
||||||
<p>Дата выгрузки: {props.store?.data?.datetimeFormatted}</p>
|
|
||||||
<p>Метрики:</p>
|
|
||||||
<VerySimpleCounterListViewComponent
|
|
||||||
key={`${keyPrefix}-verySimpleCounterListViewComponent`}
|
|
||||||
id={`${keyPrefix}-verySimpleCounterListViewComponent`}
|
|
||||||
data={otherData || []}
|
|
||||||
/>
|
|
||||||
<DebugInfo value={JSON.stringify(props.store?.data, null, 2)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
store: DashboardStoreNs.IWidget;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DailyEccmV2Widget = observer((props: Props): JSX.Element => {
|
|
||||||
const dailyEccmV2Store = DailyEccmV2Store.create();
|
|
||||||
onSnapshot(props.store, (storeState) => {
|
|
||||||
if (storeState.data) {
|
|
||||||
dailyEccmV2Store.setData(storeState.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DailyEccmV2 store={dailyEccmV2Store} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { observer } from 'mobx-react-lite';
|
||||||
import * as KanbanWidgetNs from './kanban';
|
import * as KanbanWidgetNs from './kanban';
|
||||||
import { DebugInfo } from '../../misc-components/debug-info';
|
import { DebugInfo } from '../../misc-components/debug-info';
|
||||||
import * as IssuesListNs from './issues-list';
|
import * as IssuesListNs from './issues-list';
|
||||||
import * as CalendarNextEventsNs from './calendar-next-events';
|
|
||||||
import * as DailyEccmV2 from './daily-eccm-v2';
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
store: Instance<typeof DashboardStoreNs.Widget>;
|
store: Instance<typeof DashboardStoreNs.Widget>;
|
||||||
|
|
@ -23,14 +21,6 @@ export const WidgetFactory = observer((props: Props): JSX.Element => {
|
||||||
return <IssuesListNs.IssuesList store={props.store} />;
|
return <IssuesListNs.IssuesList store={props.store} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'calendar_next_events') {
|
|
||||||
return <CalendarNextEventsNs.CalendarNextEvents store={props.store} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'daily_eccm_v2') {
|
|
||||||
return <DailyEccmV2.DailyEccmV2 store={props.store} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>Unknown widget</div>
|
<div>Unknown widget</div>
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
export function transformUtf8ToTransliteAscii(inputText: string): string {
|
|
||||||
// Define a mapping of Russian characters to their translite ASCII equivalents
|
|
||||||
const transliterationMap: { [key: string]: string } = {
|
|
||||||
а: 'a',
|
|
||||||
б: 'b',
|
|
||||||
в: 'v',
|
|
||||||
г: 'g',
|
|
||||||
д: 'd',
|
|
||||||
е: 'e',
|
|
||||||
ё: 'e',
|
|
||||||
ж: 'zh',
|
|
||||||
з: 'z',
|
|
||||||
и: 'i',
|
|
||||||
й: 'y',
|
|
||||||
к: 'k',
|
|
||||||
л: 'l',
|
|
||||||
м: 'm',
|
|
||||||
н: 'n',
|
|
||||||
о: 'o',
|
|
||||||
п: 'p',
|
|
||||||
р: 'r',
|
|
||||||
с: 's',
|
|
||||||
т: 't',
|
|
||||||
у: 'u',
|
|
||||||
ф: 'f',
|
|
||||||
х: 'kh',
|
|
||||||
ц: 'ts',
|
|
||||||
ч: 'ch',
|
|
||||||
ш: 'sh',
|
|
||||||
щ: 'sch',
|
|
||||||
ъ: '',
|
|
||||||
ы: 'y',
|
|
||||||
ь: '',
|
|
||||||
э: 'e',
|
|
||||||
ю: 'yu',
|
|
||||||
я: 'ya',
|
|
||||||
// Add more mappings as needed...
|
|
||||||
' ': '-',
|
|
||||||
'/': 'then',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Transform the input text to translite ASCII without spaces
|
|
||||||
let transformedText = '';
|
|
||||||
for (const char of inputText.toLowerCase()) {
|
|
||||||
if (transliterationMap[char]) {
|
|
||||||
transformedText += transliterationMap[char];
|
|
||||||
} else {
|
|
||||||
transformedText += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const text2id = transformUtf8ToTransliteAscii;
|
|
||||||
|
|
@ -1,54 +1,58 @@
|
||||||
import { Controller, Get, Inject, Logger, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Inject, Logger, Param, Query } from "@nestjs/common";
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from "./calendar.service";
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
import { UNLIMITED } from '../consts/consts';
|
import { UNLIMITED } from "../consts/consts";
|
||||||
|
|
||||||
@Controller('calendar')
|
@Controller('calendar')
|
||||||
export class CalendarController {
|
export class CalendarController {
|
||||||
private logger = new Logger(CalendarController.name);
|
private logger = new Logger(CalendarController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('CALENDAR_SERVICE')
|
@Inject('CALENDAR_SERVICE')
|
||||||
private calendarService: CalendarService,
|
private calendarService: CalendarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async get(@Param('filter') filter: any): Promise<string> {
|
async get(@Param('filter') filter: any): Promise<string> {
|
||||||
return await this.calendarService.getICalData(filter);
|
return await this.calendarService.getICalData(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/simple')
|
@Get('/simple')
|
||||||
async simple(
|
async simple(
|
||||||
@Query('project') project?: string,
|
@Query('project') project?: string,
|
||||||
@Query('category') category?: string,
|
@Query('category') category?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const andSection: any[] = [
|
const andSection: any[] = [
|
||||||
{
|
{
|
||||||
closed_on: {
|
"closed_on": {
|
||||||
$exists: false,
|
"$exists": false
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
if (project) {
|
if (project) {
|
||||||
andSection.push({
|
andSection.push({
|
||||||
'project.name': {
|
"project.name": {
|
||||||
$in: [project],
|
"$in": [
|
||||||
},
|
project
|
||||||
});
|
]
|
||||||
}
|
}
|
||||||
if (category) {
|
});
|
||||||
andSection.push({
|
}
|
||||||
'category.name': {
|
if (category) {
|
||||||
$in: [category],
|
andSection.push({
|
||||||
},
|
"category.name": {
|
||||||
});
|
"$in": [
|
||||||
}
|
category
|
||||||
const query: nano.MangoQuery = {
|
]
|
||||||
selector: {
|
}
|
||||||
$and: andSection,
|
});
|
||||||
},
|
}
|
||||||
limit: UNLIMITED,
|
const query: nano.MangoQuery = {
|
||||||
};
|
selector: {
|
||||||
return await this.calendarService.getICalData(query);
|
"$and": andSection
|
||||||
}
|
},
|
||||||
}
|
limit: UNLIMITED
|
||||||
|
};
|
||||||
|
return await this.calendarService.getICalData(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { CalendarEvent } from '../models/calendar-event';
|
import { CalendarEvent } from "../models/calendar-event";
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
import { RedmineTypes } from "../models/redmine-types";
|
||||||
import * as Luxon from 'luxon';
|
import * as Luxon from 'luxon';
|
||||||
import { IssuesService } from '../issues/issues.service';
|
import { IssuesService } from "../issues/issues.service";
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -36,89 +36,57 @@ END:VCALENDAR
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type IssueAndEvent = {
|
export type IssueAndEvent = {
|
||||||
issue: RedmineTypes.ExtendedIssue;
|
issue: RedmineTypes.ExtendedIssue;
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarService {
|
export class CalendarService {
|
||||||
private defaultInterval = 30 * 24 * 60 * 60 * 1000; // 30 days
|
private interval = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
constructor(
|
constructor(public calendarEventsKey: string, private issuesService: IssuesService) {}
|
||||||
public calendarEventsKey: string,
|
|
||||||
private issuesService: IssuesService,
|
async getICalData(filter: any): Promise<string> {
|
||||||
) {}
|
const issues = await this.issuesService.find(filter);
|
||||||
|
const actualEvents = this.getActualEvents(issues);
|
||||||
/**
|
const formattedEvents = actualEvents.map((event) => {
|
||||||
* @param filter фильтр для первичной выборки данных из couchdb через nano.filter
|
return this.generateICalendarEvent(event.issue, event.event);
|
||||||
* @param interval период в милисекундах
|
}).filter((event) => {
|
||||||
* @returns
|
return !!event;
|
||||||
*/
|
});
|
||||||
async getICalData(filter: any, interval?: number): Promise<string> {
|
const res = this.generateICalendar(formattedEvents);
|
||||||
const issues = await this.issuesService.find(filter);
|
return res;
|
||||||
const actualEvents = this.getActualEvents(issues, interval);
|
}
|
||||||
const formattedEvents = actualEvents
|
|
||||||
.map((event) => {
|
private getActualEvents(issues: RedmineTypes.ExtendedIssue[]): IssueAndEvent[] {
|
||||||
return this.generateICalendarEvent(event.issue, event.event);
|
const res: IssueAndEvent[] = [];
|
||||||
})
|
for (let i = 0; i < issues.length; i++) {
|
||||||
.filter((event) => {
|
const issue = issues[i];
|
||||||
return !!event;
|
if (!issue[this.calendarEventsKey] || issue[this.calendarEventsKey].length <= 0) {
|
||||||
});
|
continue;
|
||||||
const res = this.generateICalendar(formattedEvents);
|
}
|
||||||
return res;
|
const events = issue[this.calendarEventsKey];
|
||||||
}
|
for (let j = 0; j < events.length; j++) {
|
||||||
|
const event = events[j];
|
||||||
/**
|
if (this.actualEvent(event)) res.push({event: event, issue: issue});
|
||||||
* @param filter фильтр для первичной выборки данных из couchdb через nano.filter
|
}
|
||||||
* @param interval период в милисекундах
|
}
|
||||||
* @returns
|
return res;
|
||||||
*/
|
}
|
||||||
async getRawData(filter: any, interval?: number): Promise<IssueAndEvent[]> {
|
|
||||||
const issues = await this.issuesService.find(filter);
|
private actualEvent(event: CalendarEvent): boolean {
|
||||||
return this.getActualEvents(issues, interval);
|
const now = Luxon.DateTime.now().toMillis();
|
||||||
}
|
const from = now - this.interval;
|
||||||
|
const to = now + this.interval;
|
||||||
private getActualEvents(
|
return Boolean(
|
||||||
issues: RedmineTypes.ExtendedIssue[],
|
(from <= event.fromTimestamp && event.fromTimestamp <= to) ||
|
||||||
interval?: number,
|
(from <= event.toTimestamp && event.toTimestamp <= to)
|
||||||
): IssueAndEvent[] {
|
);
|
||||||
if (typeof interval !== 'number') interval = this.defaultInterval;
|
}
|
||||||
const res: IssueAndEvent[] = [];
|
|
||||||
for (let i = 0; i < issues.length; i++) {
|
private generateICalendarEvent(issue: RedmineTypes.Issue, data: CalendarEvent): string | null {
|
||||||
const issue = issues[i];
|
if (!data) return null;
|
||||||
if (
|
return `BEGIN:VEVENT
|
||||||
!issue[this.calendarEventsKey] ||
|
|
||||||
issue[this.calendarEventsKey].length <= 0
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const events = issue[this.calendarEventsKey];
|
|
||||||
for (let j = 0; j < events.length; j++) {
|
|
||||||
const event = events[j];
|
|
||||||
if (this.actualEvent(event, interval)) {
|
|
||||||
res.push({ event: event, issue: issue });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
private actualEvent(event: CalendarEvent, interval: number): boolean {
|
|
||||||
const now = Luxon.DateTime.now().toMillis();
|
|
||||||
const from = now - interval;
|
|
||||||
const to = now + interval;
|
|
||||||
return Boolean(
|
|
||||||
(from <= event.fromTimestamp && event.fromTimestamp <= to) ||
|
|
||||||
(from <= event.toTimestamp && event.toTimestamp <= to),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateICalendarEvent(
|
|
||||||
issue: RedmineTypes.Issue,
|
|
||||||
data: CalendarEvent,
|
|
||||||
): string | null {
|
|
||||||
if (!data) return null;
|
|
||||||
return `BEGIN:VEVENT
|
|
||||||
UID:${randomUUID()}@example.com
|
UID:${randomUUID()}@example.com
|
||||||
DTSTAMP:${this.formatTimestamp(data.fromTimestamp, data.fullDay)}
|
DTSTAMP:${this.formatTimestamp(data.fromTimestamp, data.fullDay)}
|
||||||
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
||||||
|
|
@ -126,20 +94,20 @@ DTSTART:${this.formatTimestamp(data.fromTimestamp, data.fullDay)}
|
||||||
DTEND:${this.formatTimestamp(data.toTimestamp, data.fullDay)}
|
DTEND:${this.formatTimestamp(data.toTimestamp, data.fullDay)}
|
||||||
SUMMARY:#${issue.id} - ${data.description} - ${issue.subject}
|
SUMMARY:#${issue.id} - ${data.description} - ${issue.subject}
|
||||||
END:VEVENT`;
|
END:VEVENT`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatTimestamp(timestamp: number, fullDay?: boolean): string {
|
private formatTimestamp(timestamp: number, fullDay?: boolean): string {
|
||||||
const format: string = fullDay ? 'yyyyMMdd' : "yyyyMMdd'T'HHmmss'Z'";
|
let format: string = fullDay ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss'Z'";
|
||||||
let datetime = Luxon.DateTime.fromMillis(timestamp);
|
let datetime = Luxon.DateTime.fromMillis(timestamp);
|
||||||
if (!fullDay) datetime = datetime.setZone('utc');
|
if (!fullDay) datetime = datetime.setZone('utc');
|
||||||
return datetime.toFormat(format);
|
return datetime.toFormat(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateICalendar(events: string[]): string {
|
private generateICalendar(events: string[]): string {
|
||||||
return `BEGIN:VCALENDAR
|
return `BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//Example Corp.//CalDAV Client//EN
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
${events.join('\n')}
|
${events.join("\n")}
|
||||||
END:VCALENDAR`;
|
END:VCALENDAR`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,25 +9,20 @@ export class CouchDb {
|
||||||
private static logger = new Logger(CouchDb.name);
|
private static logger = new Logger(CouchDb.name);
|
||||||
private static couchdb: nano.ServerScope | null = null;
|
private static couchdb: nano.ServerScope | null = null;
|
||||||
private static initialized = false;
|
private static initialized = false;
|
||||||
private static url: string | null = null;
|
|
||||||
|
|
||||||
static getCouchDb(): nano.ServerScope | null {
|
static getCouchDb(): nano.ServerScope | null {
|
||||||
if (CouchDb.initialized) {
|
if (CouchDb.initialized) {
|
||||||
return CouchDb.couchdb;
|
return CouchDb.couchdb;
|
||||||
}
|
}
|
||||||
CouchDb.initialized = true;
|
CouchDb.initialized = true;
|
||||||
this.url = config.couchDb?.url;
|
const url = config.couchDb?.url;
|
||||||
if (!this.url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
const n = nano(this.url);
|
const n = nano(url);
|
||||||
CouchDb.logger.log(`CouchDb connected by url ${this.url} ...`);
|
CouchDb.logger.log(`CouchDb connected by url ${url} ...`);
|
||||||
CouchDb.couchdb = n;
|
CouchDb.couchdb = n;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCouchDbUrl(): string | null {
|
|
||||||
return this.url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import { BehaviorSubject } from 'rxjs';
|
|
||||||
import { EventsListener } from '../events/events-listener';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { IssuesService } from '../issues/issues.service';
|
|
||||||
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
|
||||||
|
|
||||||
export type CsvIssue = Record<string, any>;
|
|
||||||
|
|
||||||
export type Task = {
|
|
||||||
schedule: string;
|
|
||||||
updatedAtFieldName: string;
|
|
||||||
dateTimeFormat: string;
|
|
||||||
csvLinks: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CsvListenerParams = {
|
|
||||||
tasks: Task[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CsvIssuesStore = Record<number, CsvIssue>;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CsvListenerFactory {
|
|
||||||
private csvListener: CsvListener;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
private issuesService: IssuesService,
|
|
||||||
private redmineDataLoader: RedmineDataLoader,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getCsvListener(): CsvListener {
|
|
||||||
if (!this.csvListener) {
|
|
||||||
const params = this.configService.get('csvListener');
|
|
||||||
this.csvListener = new CsvListener(
|
|
||||||
params,
|
|
||||||
this.schedulerRegistry,
|
|
||||||
'default_csv_listener',
|
|
||||||
this.issuesService,
|
|
||||||
this.redmineDataLoader,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.csvListener;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CsvListener implements EventsListener {
|
|
||||||
private logger = new Logger(CsvListener.name);
|
|
||||||
|
|
||||||
issues: BehaviorSubject<number[]> = new BehaviorSubject([]);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private params: CsvListenerParams,
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
private jobPrefix: string,
|
|
||||||
private issuesService: IssuesService,
|
|
||||||
private redmineDataLoader: RedmineDataLoader,
|
|
||||||
) {
|
|
||||||
this.logger.log(
|
|
||||||
`Csv listener created with params - ${JSON.stringify(params)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
const tasks = this?.params?.tasks || [];
|
|
||||||
this.logger.debug(`Scheduling ${tasks.length} tasks`);
|
|
||||||
for (let i = 0; i < tasks.length; i++) {
|
|
||||||
const task = this.params.tasks[i];
|
|
||||||
const cronJobName = this.createCronJobName();
|
|
||||||
const cronJob = new CronJob(
|
|
||||||
task.schedule,
|
|
||||||
this.createLoader(task, cronJobName),
|
|
||||||
);
|
|
||||||
this.schedulerRegistry.addCronJob(cronJobName, cronJob);
|
|
||||||
cronJob.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
const jobs = this.schedulerRegistry.getCronJobs();
|
|
||||||
jobs.forEach((job, jobName) => {
|
|
||||||
if (this.isListenerCronJon(jobName)) {
|
|
||||||
job.stop();
|
|
||||||
this.schedulerRegistry.deleteCronJob(jobName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCronJobName(): string {
|
|
||||||
return `${this.jobPrefix}_${randomUUID()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isListenerCronJon(name: string): boolean {
|
|
||||||
return name.startsWith(`${this.jobPrefix}_`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createLoader(task: Task, cronJobName: string): () => Promise<void> {
|
|
||||||
return async () => {
|
|
||||||
this.logger.log(
|
|
||||||
`Execute task ${cronJobName} ` +
|
|
||||||
`by schedule ${task.schedule} ` +
|
|
||||||
`with ${task.csvLinks.length} queries`,
|
|
||||||
);
|
|
||||||
this.logger.debug(`Queries - ${JSON.stringify(task.csvLinks)}`);
|
|
||||||
const csvIssuesStore = await this.loadCsv(task);
|
|
||||||
this.logger.debug(
|
|
||||||
`Loaded from csv issues count - ${Object.keys(csvIssuesStore).length}`,
|
|
||||||
);
|
|
||||||
const existsIssuesStore = await this.getCachedIssues(csvIssuesStore);
|
|
||||||
const ids = this.filterIssueIdsForUpdate(
|
|
||||||
csvIssuesStore,
|
|
||||||
existsIssuesStore,
|
|
||||||
);
|
|
||||||
this.logger.debug(
|
|
||||||
`Issues for update ${ids.length} - ${JSON.stringify(ids)}`,
|
|
||||||
);
|
|
||||||
if (ids && ids.length > 0) {
|
|
||||||
this.issues.next(ids);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadCsv(task: Task): Promise<CsvIssuesStore> {
|
|
||||||
const res: CsvIssuesStore = {};
|
|
||||||
for (let i = 0; i < task.csvLinks.length; i++) {
|
|
||||||
const csvLink = task.csvLinks[i];
|
|
||||||
const csvData = await this.redmineDataLoader.loadCsv(csvLink);
|
|
||||||
if (!csvData || csvData.length <= 0) continue;
|
|
||||||
for (let j = 0; j < csvData.length; j++) {
|
|
||||||
const issue = csvData[j];
|
|
||||||
const issueId = Object.values(issue)[0];
|
|
||||||
issue.id = Number(issueId);
|
|
||||||
issue.updatedAt = DateTime.fromFormat(
|
|
||||||
issue[task.updatedAtFieldName],
|
|
||||||
task.dateTimeFormat,
|
|
||||||
);
|
|
||||||
res[issueId] = issue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getCachedIssues(
|
|
||||||
store: CsvIssuesStore,
|
|
||||||
): Promise<Record<number, RedmineTypes.ExtendedIssue>> {
|
|
||||||
const ids = Object.keys(store).map((k) => Number(k));
|
|
||||||
const issues = await this.issuesService.getIssues(ids);
|
|
||||||
return issues.reduce((acc, issue) => {
|
|
||||||
acc[issue.id] = issue;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterIssueIdsForUpdate(
|
|
||||||
csvIssues: CsvIssuesStore,
|
|
||||||
existsIssues: Record<number, RedmineTypes.ExtendedIssue>,
|
|
||||||
): number[] {
|
|
||||||
const res: number[] = [];
|
|
||||||
for (const [issueId, issue] of Object.entries(csvIssues)) {
|
|
||||||
const id = Number(issueId);
|
|
||||||
if (!existsIssues[id]) {
|
|
||||||
res.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existingIssue = existsIssues[id];
|
|
||||||
const csvIssueTimestamp = issue.updatedAt?.isValid
|
|
||||||
? issue.updatedAt.toMillis()
|
|
||||||
: null;
|
|
||||||
const existingIssueTimestamp =
|
|
||||||
this.getTimestampFromCachedIssue(existingIssue);
|
|
||||||
if (!existingIssueTimestamp) {
|
|
||||||
res.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (existingIssueTimestamp < csvIssueTimestamp) {
|
|
||||||
res.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTimestampFromCachedIssue(
|
|
||||||
issue: RedmineTypes.ExtendedIssue,
|
|
||||||
): number | null {
|
|
||||||
if (
|
|
||||||
typeof issue.updated_on_timestamp === 'number' &&
|
|
||||||
issue.updated_on_timestamp > 0
|
|
||||||
) {
|
|
||||||
return issue.updated_on_timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dt = DateTime.fromISO(issue.updated_on);
|
|
||||||
if (dt.isValid) {
|
|
||||||
return dt.toMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof issue.created_on_timestamp === 'number' &&
|
|
||||||
issue.created_on_timestamp > 0
|
|
||||||
) {
|
|
||||||
return issue.created_on_timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt = DateTime.fromISO(issue.created_on);
|
|
||||||
if (dt.isValid) {
|
|
||||||
return dt.toMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ export class DashboardsDataService {
|
||||||
const cfg = await this.dashboardsService.load(id);
|
const cfg = await this.dashboardsService.load(id);
|
||||||
const results: WidgetWithData[] = [];
|
const results: WidgetWithData[] = [];
|
||||||
let isSuccess = false;
|
let isSuccess = false;
|
||||||
let counter = 0;
|
|
||||||
if (!cfg?.widgets || cfg?.widgets?.length <= 0) {
|
if (!cfg?.widgets || cfg?.widgets?.length <= 0) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
@ -34,17 +33,14 @@ export class DashboardsDataService {
|
||||||
widget.widgetParams,
|
widget.widgetParams,
|
||||||
widget.dataLoaderParams,
|
widget.dataLoaderParams,
|
||||||
cfg,
|
cfg,
|
||||||
id,
|
|
||||||
widget.id,
|
|
||||||
);
|
);
|
||||||
if (loadRes.result) {
|
if (loadRes.result) {
|
||||||
counter++;
|
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
loadRes.result.widgetId = widget.id;
|
loadRes.result.widgetId = widget.id;
|
||||||
results.push({ data: loadRes.result, widgetId: widget.id });
|
results.push({ data: loadRes.result, widgetId: widget.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isSuccess && counter > 0) throw createAppError('CANNOT_LOAD_DATA');
|
if (!isSuccess) throw createAppError('CANNOT_LOAD_DATA');
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,8 +55,6 @@ export class DashboardsDataService {
|
||||||
widget.widgetParams,
|
widget.widgetParams,
|
||||||
widget.dataLoaderParams,
|
widget.dataLoaderParams,
|
||||||
cfg,
|
cfg,
|
||||||
id,
|
|
||||||
widgetId,
|
|
||||||
);
|
);
|
||||||
if (loadRes.result) return loadRes.result;
|
if (loadRes.result) return loadRes.result;
|
||||||
throw createAppError('CANNOT_LOAD_DATA');
|
throw createAppError('CANNOT_LOAD_DATA');
|
||||||
|
|
@ -71,8 +65,6 @@ export class DashboardsDataService {
|
||||||
widgetParams: DashboardModel.WidgetParams,
|
widgetParams: DashboardModel.WidgetParams,
|
||||||
dataLoaderParams: DashboardModel.DataLoaderParams,
|
dataLoaderParams: DashboardModel.DataLoaderParams,
|
||||||
dashboardParams: DashboardModel.Data,
|
dashboardParams: DashboardModel.Data,
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<any, AppError>> {
|
): Promise<Result<any, AppError>> {
|
||||||
const widgetResult = this.widgetsCollectionService.getWidgetByType(type);
|
const widgetResult = this.widgetsCollectionService.getWidgetByType(type);
|
||||||
if (widgetResult.error) return fail(createAppError(widgetResult.error));
|
if (widgetResult.error) return fail(createAppError(widgetResult.error));
|
||||||
|
|
@ -81,8 +73,6 @@ export class DashboardsDataService {
|
||||||
widgetParams,
|
widgetParams,
|
||||||
dataLoaderParams,
|
dataLoaderParams,
|
||||||
dashboardParams,
|
dashboardParams,
|
||||||
dashboardId,
|
|
||||||
widgetId,
|
|
||||||
);
|
);
|
||||||
return renderResult;
|
return renderResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import * as DashboardModel from '../models/dashboard';
|
||||||
import nano from 'nano';
|
import nano from 'nano';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { createAppError } from '../utils/result';
|
import { createAppError } from '../utils/result';
|
||||||
import { TimestampNowFill } from '../utils/timestamp-now-fill';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardsService {
|
export class DashboardsService {
|
||||||
|
|
@ -57,18 +56,18 @@ export class DashboardsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(id: string, data: DashboardModel.Data): Promise<void> {
|
async save(id: string, data: DashboardModel.Data): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.debug(
|
||||||
`Save dashboard id - ${id}, title - ${JSON.stringify(data.title)}`,
|
`Save dashboard id - ${id}, data - ${JSON.stringify(data)}`,
|
||||||
);
|
);
|
||||||
const ds = await this.db.getDatasource();
|
const ds = await this.db.getDatasource();
|
||||||
const prevValue = await this.loadRawData(id);
|
const prevValue = await this.loadRawData(id);
|
||||||
|
|
||||||
const newValue = TimestampNowFill({
|
const newValue = {
|
||||||
_id: prevValue._id,
|
_id: prevValue._id,
|
||||||
_rev: prevValue._rev,
|
_rev: prevValue._rev,
|
||||||
id: prevValue.id,
|
id: prevValue.id,
|
||||||
data: data,
|
data: data,
|
||||||
});
|
};
|
||||||
await ds.insert(newValue);
|
await ds.insert(newValue);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -87,37 +86,4 @@ export class DashboardsService {
|
||||||
if (!data.docs) throw createAppError('DASHBOARDS_NOT_FOUND');
|
if (!data.docs) throw createAppError('DASHBOARDS_NOT_FOUND');
|
||||||
return data.docs.map((d) => ({ id: d.id, title: d.data.title }));
|
return data.docs.map((d) => ({ id: d.id, title: d.data.title }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDashboardsByWidgetType(
|
|
||||||
widgetType: string,
|
|
||||||
updatedAfter?: number,
|
|
||||||
): Promise<DashboardModel.Dashboard[]> {
|
|
||||||
const ds = await this.db.getDatasource();
|
|
||||||
const selector = {
|
|
||||||
'data.widgets': {
|
|
||||||
$elemMatch: {
|
|
||||||
type: widgetType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (updatedAfter > 0) {
|
|
||||||
selector['timestamp__'] = {
|
|
||||||
$gte: updatedAfter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.logger.debug(
|
|
||||||
`Find dashboards by widget type - ${widgetType} ` +
|
|
||||||
`and selector - ${JSON.stringify(selector)}`,
|
|
||||||
);
|
|
||||||
const data = await ds.find({
|
|
||||||
selector: selector,
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
`Found dashboards by widget type - ${widgetType} ` +
|
|
||||||
`, selector - ${JSON.stringify(selector)}` +
|
|
||||||
`, result - ${JSON.stringify(data)}`,
|
|
||||||
);
|
|
||||||
if (!data.docs) throw createAppError('DASHBOARDS_NOT_FOUND');
|
|
||||||
return data.docs;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,8 @@ export class InteractiveWidget
|
||||||
widgetParams: any,
|
widgetParams: any,
|
||||||
dataLoaderParams: any,
|
dataLoaderParams: any,
|
||||||
dashboardParams: any,
|
dashboardParams: any,
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<any, AppError>> {
|
): Promise<Result<any, AppError>> {
|
||||||
const data = await this.dataLoader.load(
|
const data = await this.dataLoader.load(dataLoaderParams, dashboardParams);
|
||||||
dataLoaderParams,
|
|
||||||
dashboardParams,
|
|
||||||
dashboardId,
|
|
||||||
widgetId,
|
|
||||||
);
|
|
||||||
return data.error ? fail(data.error) : success(data.result);
|
return data.error ? fail(data.error) : success(data.result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,5 @@ export interface WidgetDataLoaderInterface<DLP, DBP, R> {
|
||||||
load(
|
load(
|
||||||
dataLoaderParams: DLP,
|
dataLoaderParams: DLP,
|
||||||
dashboardParams: DBP,
|
dashboardParams: DBP,
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<R, AppError>>;
|
): Promise<Result<R, AppError>>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
|
||||||
import {
|
|
||||||
Result,
|
|
||||||
AppError,
|
|
||||||
success,
|
|
||||||
fail,
|
|
||||||
createAppError,
|
|
||||||
} from '@app/event-emitter/utils/result';
|
|
||||||
import {
|
|
||||||
CalendarService,
|
|
||||||
IssueAndEvent,
|
|
||||||
} from '@app/event-emitter/calendar/calendar.service';
|
|
||||||
import nano from 'nano';
|
|
||||||
|
|
||||||
export type DataLoaderParams = {
|
|
||||||
/** Период для выборки предстоящих событий в днях */
|
|
||||||
period: number;
|
|
||||||
/** Фильтр для выборки данных из couchdb */
|
|
||||||
filter: nano.MangoQuery;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CalendarWidgetDataLoaderService
|
|
||||||
implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
|
|
||||||
{
|
|
||||||
constructor(
|
|
||||||
@Inject('CALENDAR_SERVICE') private calendarService: CalendarService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
isMyConfig(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(
|
|
||||||
dataLoaderParams: DataLoaderParams,
|
|
||||||
): Promise<Result<IssueAndEvent[], AppError>> {
|
|
||||||
let data: IssueAndEvent[];
|
|
||||||
try {
|
|
||||||
data = await this.calendarService.getRawData(
|
|
||||||
dataLoaderParams.filter,
|
|
||||||
dataLoaderParams.period * 24 * 60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
return success(data);
|
|
||||||
} catch (ex) {
|
|
||||||
return fail(createAppError(ex.message ? ex.message : 'UNKNOWN_ERROR'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
AppError,
|
AppError,
|
||||||
Result,
|
Result,
|
||||||
createAppError,
|
createAppError,
|
||||||
fail,
|
|
||||||
success,
|
success,
|
||||||
} from '@app/event-emitter/utils/result';
|
} from '@app/event-emitter/utils/result';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,5 @@ export interface WidgetInterface<WP, DLP, DBP, DLR, R> {
|
||||||
widgetParams: WP,
|
widgetParams: WP,
|
||||||
dataLoaderParams: DLP,
|
dataLoaderParams: DLP,
|
||||||
dashboardParams: DBP,
|
dashboardParams: DBP,
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<R, AppError>>;
|
): Promise<Result<R, AppError>>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './widget-data-
|
||||||
import { RootIssueSubTreesWidgetDataLoaderService } from './widget-data-loader/root-issue-subtrees.widget-data-loader.service';
|
import { RootIssueSubTreesWidgetDataLoaderService } from './widget-data-loader/root-issue-subtrees.widget-data-loader.service';
|
||||||
import { createInteractiveWidget } from './interactive-widget-factory';
|
import { createInteractiveWidget } from './interactive-widget-factory';
|
||||||
import { Result, success } from '@app/event-emitter/utils/result';
|
import { Result, success } from '@app/event-emitter/utils/result';
|
||||||
import { CalendarWidgetDataLoaderService } from './widget-data-loader/calendar.widget-data-loader.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WidgetsCollectionService {
|
export class WidgetsCollectionService {
|
||||||
|
|
@ -15,7 +14,6 @@ export class WidgetsCollectionService {
|
||||||
private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
|
private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
|
||||||
private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||||
private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
|
private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
|
||||||
private calendarWidgetDataLoaderService: CalendarWidgetDataLoaderService,
|
|
||||||
) {
|
) {
|
||||||
const collection = [
|
const collection = [
|
||||||
createInteractiveWidget(
|
createInteractiveWidget(
|
||||||
|
|
@ -42,10 +40,6 @@ export class WidgetsCollectionService {
|
||||||
this.rootIssueSubTreesWidgetDataLoaderService,
|
this.rootIssueSubTreesWidgetDataLoaderService,
|
||||||
'issues_list_by_tree',
|
'issues_list_by_tree',
|
||||||
),
|
),
|
||||||
createInteractiveWidget(
|
|
||||||
this.calendarWidgetDataLoaderService,
|
|
||||||
'calendar_next_events',
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
collection.forEach((w) => this.appendWidget(w));
|
collection.forEach((w) => this.appendWidget(w));
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,6 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './dashboards/w
|
||||||
import { ListIssuesByFieldsWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service';
|
import { ListIssuesByFieldsWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service';
|
||||||
import { WidgetsCollectionService } from './dashboards/widgets-collection.service';
|
import { WidgetsCollectionService } from './dashboards/widgets-collection.service';
|
||||||
import { DashboardsController } from './dashboards/dashboards.controller';
|
import { DashboardsController } from './dashboards/dashboards.controller';
|
||||||
import { CalendarWidgetDataLoaderService } from './dashboards/widget-data-loader/calendar.widget-data-loader.service';
|
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
|
||||||
import { CsvListenerFactory } from './csvlistener/csv-listener';
|
|
||||||
import { RootIssueListenerFactory } from './rootissuelistener/root-issue-listener';
|
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class EventEmitterModule implements OnModuleInit {
|
export class EventEmitterModule implements OnModuleInit {
|
||||||
|
|
@ -48,7 +44,6 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
module: EventEmitterModule,
|
module: EventEmitterModule,
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ load: [() => params?.config || MainConfig()] }),
|
ConfigModule.forRoot({ load: [() => params?.config || MainConfig()] }),
|
||||||
ScheduleModule.forRoot(),
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EventEmitterService,
|
EventEmitterService,
|
||||||
|
|
@ -109,9 +104,6 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
DashboardsService,
|
DashboardsService,
|
||||||
DashboardsDataService,
|
DashboardsDataService,
|
||||||
WidgetsCollectionService,
|
WidgetsCollectionService,
|
||||||
CalendarWidgetDataLoaderService,
|
|
||||||
CsvListenerFactory,
|
|
||||||
RootIssueListenerFactory,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EventEmitterService,
|
EventEmitterService,
|
||||||
|
|
@ -150,7 +142,6 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
DashboardsService,
|
DashboardsService,
|
||||||
DashboardsDataService,
|
DashboardsDataService,
|
||||||
WidgetsCollectionService,
|
WidgetsCollectionService,
|
||||||
CalendarWidgetDataLoaderService,
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
MainController,
|
MainController,
|
||||||
|
|
@ -168,28 +159,15 @@ export class EventEmitterModule implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
private redmineEventsGateway: RedmineEventsGateway,
|
private redmineEventsGateway: RedmineEventsGateway,
|
||||||
private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService,
|
private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService,
|
||||||
private csvListenerFactory: CsvListenerFactory,
|
|
||||||
private rootIssueListenerFactory: RootIssueListenerFactory,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
const csvListener = this.csvListenerFactory.getCsvListener();
|
|
||||||
this.redmineEventsGateway.appendAndInitListener(csvListener);
|
|
||||||
const rootIssueListener =
|
|
||||||
this.rootIssueListenerFactory.getRootIssueListener();
|
|
||||||
this.redmineEventsGateway.appendAndInitListener(rootIssueListener);
|
|
||||||
const queue = this.redmineEventsGateway.getIssuesChangesQueue();
|
const queue = this.redmineEventsGateway.getIssuesChangesQueue();
|
||||||
const subj = queue.queue;
|
const subj = queue.queue;
|
||||||
subj.subscribe(async (issues: RedmineTypes.Issue[]) => {
|
subj.subscribe(async (issues: RedmineTypes.Issue[]) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Changed issues - ` +
|
`Changed issues - ` +
|
||||||
issues.map(
|
issues.map((i) => `#${i.id} (${i.subject})`).join(', '),
|
||||||
(i) => {
|
|
||||||
return (i && i.id && i.subject)
|
|
||||||
? `#${i.id} (${i.subject})`
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
).join(', '),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < issues.length; i++) {
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -134,29 +134,25 @@ export class RedmineEventsGateway {
|
||||||
return this.rssListener;
|
return this.rssListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
private listeners: EventsListener[];
|
private listener: EventsListener | null | undefined;
|
||||||
private getMainListener(): EventsListener[] {
|
private getMainListener(): EventsListener | null {
|
||||||
if (!this.listeners) {
|
if (typeof this.listener !== 'undefined') {
|
||||||
this.listeners = [
|
return this.listener;
|
||||||
this.getMailListener(),
|
|
||||||
this.getRssListener(),
|
|
||||||
// this.getCsvListener(),
|
|
||||||
];
|
|
||||||
this.listeners.forEach((l) => l && l.start && l.start());
|
|
||||||
}
|
}
|
||||||
return this.listeners;
|
|
||||||
}
|
|
||||||
|
|
||||||
appendAndInitListener(eventListener: EventsListener): void {
|
const mailListener = this.getMailListener();
|
||||||
const listeners = this.getMainListener();
|
const rssListener = this.getRssListener();
|
||||||
if (listeners.indexOf(eventListener) < 0) {
|
if (mailListener) {
|
||||||
this.listeners.push(eventListener);
|
this.listener = mailListener;
|
||||||
eventListener.start();
|
} else if (rssListener) {
|
||||||
const issuesChangesQueue = this.getIssuesChangesQueue();
|
this.listener = rssListener;
|
||||||
eventListener.issues.subscribe((issues) => {
|
} else {
|
||||||
issuesChangesQueue.add(issues);
|
this.listener = null;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (this.listener) {
|
||||||
|
this.listener.start();
|
||||||
|
}
|
||||||
|
return this.listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
private initWebSocketsSendData(): void {
|
private initWebSocketsSendData(): void {
|
||||||
|
|
@ -174,12 +170,10 @@ export class RedmineEventsGateway {
|
||||||
}
|
}
|
||||||
|
|
||||||
private initChangesLogging(): void {
|
private initChangesLogging(): void {
|
||||||
if (this.listeners && this.listeners.length > 0) {
|
if (this.listener) {
|
||||||
this.getIssuesChangesQueue().queue.subscribe((data) => {
|
this.getIssuesChangesQueue().queue.subscribe((data) => {
|
||||||
const issues = data.map((issue) => {
|
const issues = data.map((issue) => {
|
||||||
return (issue && issue.id && issue.subject)
|
return `${issue['id']} - ${issue['subject']}`;
|
||||||
? `${issue['id']} - ${issue['subject']}`
|
|
||||||
: '';
|
|
||||||
});
|
});
|
||||||
this.logger.debug('Changed issues: ' + JSON.stringify(issues));
|
this.logger.debug('Changed issues: ' + JSON.stringify(issues));
|
||||||
});
|
});
|
||||||
|
|
@ -187,18 +181,14 @@ export class RedmineEventsGateway {
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRedmineEventsGateway(): boolean {
|
private initRedmineEventsGateway(): boolean {
|
||||||
const listeners = this.getMainListener();
|
const listener = this.getMainListener();
|
||||||
if (!listeners || listeners.length <= 0) {
|
if (!listener) {
|
||||||
this.logger.error('Listener not created');
|
this.logger.error('Listener not created');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const issuesChangesQueue = this.getIssuesChangesQueue();
|
const issuesChangesQueue = this.getIssuesChangesQueue();
|
||||||
listeners.forEach((l) => {
|
listener.issues.subscribe((issues) => {
|
||||||
l &&
|
issuesChangesQueue.add(issues);
|
||||||
l.issues &&
|
|
||||||
l.issues.subscribe((issues) => {
|
|
||||||
issuesChangesQueue.add(issues);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
this.initWebSocketsSendData();
|
this.initWebSocketsSendData();
|
||||||
this.initWebHooksSendData();
|
this.initWebHooksSendData();
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const UNKNOWN_CALENDAR_EVENT = 'Unknown calendar event';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarEnhancer implements IssueEnhancerInterface {
|
export class CalendarEnhancer implements IssueEnhancerInterface {
|
||||||
private logger = new Logger(CalendarEnhancer.name);
|
private logger = new Logger(CalendarEnhancer.name);
|
||||||
|
|
||||||
name = 'calendar';
|
name = 'calendar';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -33,15 +33,8 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
|
||||||
public descriptionCalendarParams: DescriptionParserParams,
|
public descriptionCalendarParams: DescriptionParserParams,
|
||||||
public calendarEventsKey: string,
|
public calendarEventsKey: string,
|
||||||
) {
|
) {
|
||||||
const initParams = {
|
const initParams = {useForProjects, customFields, descriptionCalendarParams, calendarEventsKey};
|
||||||
useForProjects,
|
this.logger.debug(`Calendar enhancer init with ${JSON.stringify(initParams)}`);
|
||||||
customFields,
|
|
||||||
descriptionCalendarParams,
|
|
||||||
calendarEventsKey,
|
|
||||||
};
|
|
||||||
this.logger.debug(
|
|
||||||
`Calendar enhancer init with ${JSON.stringify(initParams)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async enhance(
|
async enhance(
|
||||||
|
|
@ -56,18 +49,10 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
|
||||||
try {
|
try {
|
||||||
res[this.calendarEventsKey] = this.getCalendarEvents(res);
|
res[this.calendarEventsKey] = this.getCalendarEvents(res);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this.logger.error(
|
this.logger.error(`Error at parsing calendar events, message - ${ex}: ${(ex as Error)?.stack}`);
|
||||||
`Error at parsing calendar events, message - ${ex}: ${
|
|
||||||
(ex as Error)?.stack
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
this.logger.debug(
|
this.logger.debug(`Calendar events for #${issue.id}: issue.${this.calendarEventsKey} = ${JSON.stringify(res[this.calendarEventsKey])}`);
|
||||||
`Calendar events for #${issue.id}: issue.${
|
|
||||||
this.calendarEventsKey
|
|
||||||
} = ${JSON.stringify(res[this.calendarEventsKey])}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
@ -162,9 +147,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
|
||||||
|
|
||||||
const lines = text.split('\n').map((line) => line.trim());
|
const lines = text.split('\n').map((line) => line.trim());
|
||||||
|
|
||||||
const calendarStartIndex = lines.indexOf(
|
const calendarStartIndex = lines.indexOf(this.descriptionCalendarParams.title);
|
||||||
this.descriptionCalendarParams.title,
|
|
||||||
);
|
|
||||||
if (calendarStartIndex < 0) return [];
|
if (calendarStartIndex < 0) return [];
|
||||||
|
|
||||||
let index = calendarStartIndex + 1;
|
let index = calendarStartIndex + 1;
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,6 @@ export class IssuesController {
|
||||||
return await this.issuesService.find(params);
|
return await this.issuesService.find(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('find-from-root/:id')
|
|
||||||
async getIssuesFromRoot(
|
|
||||||
@Param('id') id: number,
|
|
||||||
): Promise<RedmineTypes.Issue[]> {
|
|
||||||
const rootIssue = await this.issuesService.getIssue(id);
|
|
||||||
const res = await this.issuesService.getIssuesFromRoot(rootIssue);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('find-from-merged-trees-and-query')
|
|
||||||
async findFromMergedTreesAndQuery(
|
|
||||||
@Body() params: any,
|
|
||||||
): Promise<RedmineTypes.Issue[]> {
|
|
||||||
return await this.issuesService.mergedTreesAndFind(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getIssue(@Param('id') id: number): Promise<RedmineTypes.Issue> {
|
async getIssue(@Param('id') id: number): Promise<RedmineTypes.Issue> {
|
||||||
return await this.issuesService.getIssue(id);
|
return await this.issuesService.getIssue(id);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import nano from 'nano';
|
||||||
import { UNLIMITED } from '../consts/consts';
|
import { UNLIMITED } from '../consts/consts';
|
||||||
import { GetParentsHint } from '../utils/get-parents-hint';
|
import { GetParentsHint } from '../utils/get-parents-hint';
|
||||||
import { TreeIssuesStore } from '../utils/tree-issues-store';
|
import { TreeIssuesStore } from '../utils/tree-issues-store';
|
||||||
import { FlatIssuesStore } from '../utils/flat-issues-store';
|
|
||||||
|
|
||||||
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
||||||
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
||||||
|
|
@ -19,12 +18,6 @@ export namespace IssuesServiceNs {
|
||||||
export type IssuesLoader = (
|
export type IssuesLoader = (
|
||||||
ids: number[],
|
ids: number[],
|
||||||
) => Promise<Record<number, RedmineTypes.Issue | null>>;
|
) => Promise<Record<number, RedmineTypes.Issue | null>>;
|
||||||
|
|
||||||
export type TreesAndQuery = {
|
|
||||||
rootIds?: number[];
|
|
||||||
rootIssues?: RedmineTypes.Issue[];
|
|
||||||
query?: nano.MangoQuery;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -183,43 +176,4 @@ export class IssuesService {
|
||||||
await treeIssuesStore.fillData(loader);
|
await treeIssuesStore.fillData(loader);
|
||||||
return treeIssuesStore.getIssuesWithChildren();
|
return treeIssuesStore.getIssuesWithChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssuesFromRoot(
|
|
||||||
rootIssue: RedmineTypes.Issue,
|
|
||||||
): Promise<RedmineTypes.Issue[]> {
|
|
||||||
const treeStore = new TreeIssuesStore();
|
|
||||||
treeStore.setRootIssue(rootIssue);
|
|
||||||
const loader = this.createDynamicIssuesLoader();
|
|
||||||
await treeStore.fillData(loader);
|
|
||||||
return treeStore.getFlatStore().getIssues();
|
|
||||||
}
|
|
||||||
|
|
||||||
async mergedTreesAndFind(
|
|
||||||
query: IssuesServiceNs.TreesAndQuery,
|
|
||||||
): Promise<RedmineTypes.Issue[]> {
|
|
||||||
const flatStore = new FlatIssuesStore();
|
|
||||||
const loader = this.createDynamicIssuesLoader();
|
|
||||||
if (query.query && query.query.selector) {
|
|
||||||
const issues = await this.find(query.query);
|
|
||||||
issues.forEach((issue) => flatStore.push(issue));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootIssues = [];
|
|
||||||
if (query.rootIds) {
|
|
||||||
const issues = await this.getIssues(query.rootIds);
|
|
||||||
rootIssues.push(...issues);
|
|
||||||
}
|
|
||||||
if (query.rootIssues) {
|
|
||||||
rootIssues.push(...query.rootIssues);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < rootIssues.length; i++) {
|
|
||||||
const rootIssue = rootIssues[i];
|
|
||||||
const issues = await this.getIssuesFromRoot(rootIssue);
|
|
||||||
issues.forEach((issue) => flatStore.push(issue));
|
|
||||||
}
|
|
||||||
|
|
||||||
await flatStore.fillData(loader);
|
|
||||||
|
|
||||||
return flatStore.getIssues();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,6 @@ export module RedmineTypes {
|
||||||
done_ratio: number;
|
done_ratio: number;
|
||||||
spent_hours: number;
|
spent_hours: number;
|
||||||
total_spent_hours: number;
|
total_spent_hours: number;
|
||||||
estimated_hours: number;
|
|
||||||
total_estimated_hours: number;
|
|
||||||
custom_fields: CustomField[];
|
custom_fields: CustomField[];
|
||||||
created_on: string;
|
created_on: string;
|
||||||
updated_on?: string;
|
updated_on?: string;
|
||||||
|
|
@ -91,8 +89,6 @@ export module RedmineTypes {
|
||||||
done_ratio: num,
|
done_ratio: num,
|
||||||
spent_hours: num,
|
spent_hours: num,
|
||||||
total_spent_hours: num,
|
total_spent_hours: num,
|
||||||
estimated_hours: num,
|
|
||||||
total_estimated_hours: num,
|
|
||||||
custom_fields: [],
|
custom_fields: [],
|
||||||
created_on: date,
|
created_on: date,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
import { RedmineTypes } from '../models/redmine-types';
|
||||||
import { EnhancerService } from '../issue-enhancers/enhancer.service';
|
import { EnhancerService } from '../issue-enhancers/enhancer.service';
|
||||||
import { parse as csvParse } from 'csv/sync';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedmineDataLoader {
|
export class RedmineDataLoader {
|
||||||
urlPrefix: string;
|
urlPrefix: string;
|
||||||
redmineUrl: string;
|
|
||||||
redmineToken: string;
|
|
||||||
|
|
||||||
private logger = new Logger(RedmineDataLoader.name);
|
private logger = new Logger(RedmineDataLoader.name);
|
||||||
|
|
||||||
|
|
@ -18,8 +15,6 @@ export class RedmineDataLoader {
|
||||||
private enhancerService: EnhancerService,
|
private enhancerService: EnhancerService,
|
||||||
) {
|
) {
|
||||||
this.urlPrefix = this.configService.get<string>('redmineUrlPrefix');
|
this.urlPrefix = this.configService.get<string>('redmineUrlPrefix');
|
||||||
this.redmineUrl = this.configService.get<string>('redmineUrlPublic');
|
|
||||||
this.redmineToken = this.configService.get<string>('redmineToken');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadIssues(issues: number[]): Promise<(RedmineTypes.Issue | null)[]> {
|
async loadIssues(issues: number[]): Promise<(RedmineTypes.Issue | null)[]> {
|
||||||
|
|
@ -27,13 +22,7 @@ export class RedmineDataLoader {
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadIssue(
|
async loadIssue(issueNumber: number): Promise<RedmineTypes.Issue | null> {
|
||||||
issueNumber: number,
|
|
||||||
skipEnhancers = false,
|
|
||||||
): Promise<RedmineTypes.Issue | null> {
|
|
||||||
if (issueNumber <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const url = this.getIssueUrl(issueNumber);
|
const url = this.getIssueUrl(issueNumber);
|
||||||
let resp;
|
let resp;
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,7 +41,6 @@ export class RedmineDataLoader {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
|
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
|
||||||
);
|
);
|
||||||
if (skipEnhancers) return resp.data.issue;
|
|
||||||
let enhancedIssue;
|
let enhancedIssue;
|
||||||
try {
|
try {
|
||||||
enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue);
|
enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue);
|
||||||
|
|
@ -70,7 +58,7 @@ export class RedmineDataLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
|
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
|
||||||
if (typeof userNumber !== 'number' || userNumber <= 0) {
|
if (userNumber <= 0) {
|
||||||
this.logger.warn(`Invalid userNumber = ${userNumber}`);
|
this.logger.warn(`Invalid userNumber = ${userNumber}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -106,42 +94,4 @@ export class RedmineDataLoader {
|
||||||
private getUserUrl(userNumber: number): string {
|
private getUserUrl(userNumber: number): string {
|
||||||
return `${this.urlPrefix}/users/${userNumber}.json`;
|
return `${this.urlPrefix}/users/${userNumber}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCsv(
|
|
||||||
urlQuery: string,
|
|
||||||
csvParserParams?: any,
|
|
||||||
): Promise<Record<string, any>[]> {
|
|
||||||
if (!csvParserParams) {
|
|
||||||
csvParserParams = {
|
|
||||||
delimiter: ';',
|
|
||||||
quote: '"',
|
|
||||||
columns: true,
|
|
||||||
skip_empty_lines: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await fetch(urlQuery, {
|
|
||||||
headers: {
|
|
||||||
'X-Redmine-API-Key': this.redmineToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to fetch data from Redmine by url ${urlQuery}: ${err}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const rawData = await resp.text();
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = csvParse(rawData, csvParserParams);
|
|
||||||
} catch (ex) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error at loading csv from redmine, query - ${urlQuery}, ex - ${ex}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { EventsListener } from '../events/events-listener';
|
|
||||||
import { BehaviorSubject } from 'rxjs';
|
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { RedmineTypes } from '../models/redmine-types';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
export type Task = {
|
|
||||||
schedule: string;
|
|
||||||
rootIssues: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RootIssueListenerParams = {
|
|
||||||
tasks: Task[];
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RootIssueListenerFactory {
|
|
||||||
private rootIssueListener: RootIssueListener;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
private redmineDataLoader: RedmineDataLoader,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getRootIssueListener(): RootIssueListener {
|
|
||||||
if (!this.rootIssueListener) {
|
|
||||||
const params = this.configService.get('rootIssueListener');
|
|
||||||
this.rootIssueListener = new RootIssueListener(
|
|
||||||
params,
|
|
||||||
this.schedulerRegistry,
|
|
||||||
'default_root_issue_listener',
|
|
||||||
this.redmineDataLoader,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.rootIssueListener;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RootIssueListener implements EventsListener {
|
|
||||||
private logger = new Logger(RootIssueListener.name);
|
|
||||||
|
|
||||||
issues: BehaviorSubject<number[]> = new BehaviorSubject([]);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private params: RootIssueListenerParams,
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
private jobPrefix: string,
|
|
||||||
private redmineDataLoader: RedmineDataLoader,
|
|
||||||
) {
|
|
||||||
this.logger.log(
|
|
||||||
`Root issue listener created with params - ${JSON.stringify(params)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
const tasks = this?.params?.tasks || [];
|
|
||||||
this.logger.debug(`Scheduling ${tasks.length} tasks`);
|
|
||||||
for (let i = 0; i < tasks.length; i++) {
|
|
||||||
const task = tasks[i];
|
|
||||||
const cronJobName = this.createCronJobName();
|
|
||||||
const cronJob = new CronJob(
|
|
||||||
task.schedule,
|
|
||||||
this.createLoader(task, cronJobName),
|
|
||||||
);
|
|
||||||
this.schedulerRegistry.addCronJob(cronJobName, cronJob);
|
|
||||||
cronJob.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
const jobs = this.schedulerRegistry.getCronJobs();
|
|
||||||
jobs.forEach((job, jobName) => {
|
|
||||||
if (this.isListenerCronJon(jobName)) {
|
|
||||||
job.stop();
|
|
||||||
this.schedulerRegistry.deleteCronJob(jobName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCronJobName(): string {
|
|
||||||
return `${this.jobPrefix}_${randomUUID()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isListenerCronJon(name: string): boolean {
|
|
||||||
return name.startsWith(`${this.jobPrefix}_`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createLoader(task: Task, cronJobName: string): () => Promise<void> {
|
|
||||||
return async () => {
|
|
||||||
this.logger.log(
|
|
||||||
`Execute task ${cronJobName} ` +
|
|
||||||
`by schedule ${task.schedule} ` +
|
|
||||||
`with ${task.rootIssues.length} root issues`,
|
|
||||||
);
|
|
||||||
const issuesStore = await this.getRootIssuesFromRedmine(task);
|
|
||||||
this.logger.debug(
|
|
||||||
`Loaded root issues ` +
|
|
||||||
`${Object.keys(issuesStore).length} - ` +
|
|
||||||
`${JSON.stringify(Object.keys(issuesStore).map((i) => Number(i)))}`,
|
|
||||||
);
|
|
||||||
const ids = this.getAllRootIssueIds(Object.values(issuesStore), []);
|
|
||||||
this.logger.debug(
|
|
||||||
`Issues for update ${ids.length} - ${JSON.stringify(ids)}`,
|
|
||||||
);
|
|
||||||
if (ids && ids.length > 0) {
|
|
||||||
this.issues.next(ids);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRootIssuesFromRedmine(
|
|
||||||
task: Task,
|
|
||||||
): Promise<Record<number, RedmineTypes.ExtendedIssue>> {
|
|
||||||
const res = {};
|
|
||||||
const SKIP_ENHANCERS = true;
|
|
||||||
for (let i = 0; i < task.rootIssues.length; i++) {
|
|
||||||
const issueId = task.rootIssues[i];
|
|
||||||
if (typeof issueId !== 'number' || issueId <= 0) continue;
|
|
||||||
const issue = await this.redmineDataLoader.loadIssue(
|
|
||||||
issueId,
|
|
||||||
SKIP_ENHANCERS,
|
|
||||||
);
|
|
||||||
if (issue) {
|
|
||||||
res[issueId] = issue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAllRootIssueIds(
|
|
||||||
issues: RedmineTypes.ExtendedIssue[] | RedmineTypes.Children,
|
|
||||||
res: number[],
|
|
||||||
): number[] {
|
|
||||||
for (let i = 0; i < issues.length; i++) {
|
|
||||||
const issue = issues[i];
|
|
||||||
if (issue.children && issue.children.length > 0) {
|
|
||||||
res.push(issue.id);
|
|
||||||
this.getAllRootIssueIds(issue.children, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,32 +46,6 @@ export class UsersService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findUserByLogin(login: string): Promise<RedmineTypes.User | null> {
|
|
||||||
const userFromMemoryCache = this.memoryCache.find((item) => {
|
|
||||||
const email = item.mail;
|
|
||||||
if (!email) return false;
|
|
||||||
return email.startsWith(login);
|
|
||||||
});
|
|
||||||
if (userFromMemoryCache) {
|
|
||||||
return RedmineTypes.CreateUser(userFromMemoryCache);
|
|
||||||
}
|
|
||||||
const usersDb = await this.users.getDatasource();
|
|
||||||
const res = await usersDb.find({
|
|
||||||
selector: {
|
|
||||||
mail: {
|
|
||||||
$regex: login,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
if (!res || !res.docs || !res.docs[0]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const userFromDb = res.docs[0];
|
|
||||||
this.memoryCache.set(userFromDb.id, userFromDb);
|
|
||||||
return RedmineTypes.CreateUser(userFromDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUserByName(
|
async findUserByName(
|
||||||
firstname: string,
|
firstname: string,
|
||||||
lastname: string,
|
lastname: string,
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ export function parse(str: string, params?: Moo.Rules): Moo.Token[] {
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.error(
|
logger.error(`Error at parse str=${str} with params=${params}, error message - ${ex}`);
|
||||||
`Error at parse str=${str} with params=${params}, error message - ${ex}`,
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +140,7 @@ export function parseToCalendarEvent(
|
||||||
to = date1.set({
|
to = date1.set({
|
||||||
hour: time2.hours,
|
hour: time2.hours,
|
||||||
minute: time2.minutes,
|
minute: time2.minutes,
|
||||||
second: time2.seconds,
|
second: time2.seconds
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION));
|
to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION));
|
||||||
|
|
|
||||||
2335
package-lock.json
generated
10
package.json
|
|
@ -31,22 +31,19 @@
|
||||||
"@nestjs/websockets": "^8.4.4",
|
"@nestjs/websockets": "^8.4.4",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cache-manager": "^4.1.0",
|
"cache-manager": "^4.1.0",
|
||||||
"csv": "^6.3.6",
|
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"hbs": "^4.2.0",
|
"hbs": "^4.2.0",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
"jsonc-parser": "^3.2.0",
|
"jsonc-parser": "^3.2.0",
|
||||||
"jsonpath-plus": "^8.1.0",
|
|
||||||
"luxon": "^3.1.0",
|
"luxon": "^3.1.0",
|
||||||
"moo": "^0.5.2",
|
"moo": "^0.5.2",
|
||||||
"nano": "^10.0.0",
|
"nano": "^10.0.0",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.59.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rss-parser": "^3.12.0",
|
"rss-parser": "^3.12.0",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1"
|
||||||
"working-time-calculator": "^0.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.0.0",
|
"@nestjs/cli": "^8.0.0",
|
||||||
|
|
@ -56,11 +53,10 @@
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/jsonpath": "^0.2.4",
|
|
||||||
"@types/luxon": "^3.1.0",
|
"@types/luxon": "^3.1.0",
|
||||||
"@types/moo": "^0.5.6",
|
"@types/moo": "^0.5.6",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/node-telegram-bot-api": "^0.64.6",
|
"@types/node-telegram-bot-api": "^0.57.1",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,6 @@ import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.ser
|
||||||
import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-enhancer';
|
import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-enhancer';
|
||||||
import { Dashboards as DashboardsDs } from '@app/event-emitter/couchdb-datasources/dashboards';
|
import { Dashboards as DashboardsDs } from '@app/event-emitter/couchdb-datasources/dashboards';
|
||||||
import { DashboardInitService } from './dashboards/dashboard-init.service';
|
import { DashboardInitService } from './dashboards/dashboard-init.service';
|
||||||
import { TelegramBotController } from './telegram-bot/telegram-bot.controller';
|
|
||||||
import { DailyEccmV2ReportTaskRunnerService } from './reports/daily-eccm-v2-report-task-runner.service';
|
|
||||||
import { DashboardsService } from '@app/event-emitter/dashboards/dashboards.service';
|
|
||||||
import { DailyEccmReportsV2Datasource } from './couchdb-datasources/daily-eccm-reports-v2.datasource';
|
|
||||||
import { DailyEccmReportsV2DataLoaderService } from './eccm-statistic/dashboards/widget-data-loader/daily-eccm-v2.widget-data-loader.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -81,7 +76,6 @@ import { DailyEccmReportsV2DataLoaderService } from './eccm-statistic/dashboards
|
||||||
SimpleKanbanBoardController,
|
SimpleKanbanBoardController,
|
||||||
SimpleIssuesListController,
|
SimpleIssuesListController,
|
||||||
TagsManagerController,
|
TagsManagerController,
|
||||||
TelegramBotController,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
|
@ -119,10 +113,6 @@ import { DailyEccmReportsV2DataLoaderService } from './eccm-statistic/dashboards
|
||||||
},
|
},
|
||||||
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
|
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
|
||||||
DashboardInitService,
|
DashboardInitService,
|
||||||
DashboardsService,
|
|
||||||
DailyEccmReportsV2Datasource,
|
|
||||||
DailyEccmV2ReportTaskRunnerService,
|
|
||||||
DailyEccmReportsV2DataLoaderService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnModuleInit {
|
export class AppModule implements OnModuleInit {
|
||||||
|
|
@ -151,22 +141,16 @@ export class AppModule implements OnModuleInit {
|
||||||
private calendarEnhancer: CalendarEnhancer,
|
private calendarEnhancer: CalendarEnhancer,
|
||||||
|
|
||||||
private dashboardInitService: DashboardInitService,
|
private dashboardInitService: DashboardInitService,
|
||||||
|
|
||||||
private dashboardsService: DashboardsService,
|
|
||||||
private dailyEccmV2ReportService: DailyEccmV2ReportTaskRunnerService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
const datasources = [
|
Issues.getDatasource();
|
||||||
Issues.getDatasource(),
|
Users.getDatasource();
|
||||||
Users.getDatasource(),
|
Changes.getDatasource();
|
||||||
Changes.getDatasource(),
|
UserMetaInfo.getDatasource();
|
||||||
UserMetaInfo.getDatasource(),
|
DailyEccmReportsDatasource.getDatasource();
|
||||||
DailyEccmReportsDatasource.getDatasource(),
|
DailyEccmReportsUserCommentsDatasource.getDatasource();
|
||||||
DailyEccmReportsUserCommentsDatasource.getDatasource(),
|
DashboardsDs.getDatasource();
|
||||||
DashboardsDs.getDatasource(),
|
|
||||||
DailyEccmReportsV2Datasource.getDatasource(),
|
|
||||||
];
|
|
||||||
|
|
||||||
this.enhancerService.addEnhancer([
|
this.enhancerService.addEnhancer([
|
||||||
this.timestampEnhancer,
|
this.timestampEnhancer,
|
||||||
|
|
@ -236,13 +220,6 @@ export class AppModule implements OnModuleInit {
|
||||||
|
|
||||||
this.initDailyEccmUserCommentsPipeline();
|
this.initDailyEccmUserCommentsPipeline();
|
||||||
this.initDashbordProviders();
|
this.initDashbordProviders();
|
||||||
|
|
||||||
Promise.all(datasources).then(() => {
|
|
||||||
this.dailyEccmV2ReportService.setDashboardsService(
|
|
||||||
this.dashboardsService,
|
|
||||||
);
|
|
||||||
this.dailyEccmV2ReportService.initAutoScanJobs();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private initDailyEccmUserCommentsPipeline(): void {
|
private initDailyEccmUserCommentsPipeline(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb';
|
|
||||||
import nano from 'nano';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const DB_NAME = 'eccm_daily_reports_v2';
|
|
||||||
const DATETIME_INDEX_ASC = 'datetime-json-index';
|
|
||||||
|
|
||||||
type Report = any; // TODO fix this type
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DailyEccmReportsV2Datasource {
|
|
||||||
private static logger = new Logger(DailyEccmReportsV2Datasource.name);
|
|
||||||
private static db = null;
|
|
||||||
private static initilized = false;
|
|
||||||
|
|
||||||
static async getDatasource(): Promise<nano.DocumentScope<Report>> {
|
|
||||||
if (DailyEccmReportsV2Datasource.initilized) {
|
|
||||||
return DailyEccmReportsV2Datasource.db;
|
|
||||||
}
|
|
||||||
DailyEccmReportsV2Datasource.initilized = true;
|
|
||||||
const n = CouchDb.getCouchDb();
|
|
||||||
const dbName = DB_NAME;
|
|
||||||
const dbs = await n.db.list();
|
|
||||||
if (!dbs.includes(dbName)) {
|
|
||||||
await n.db.create(dbName);
|
|
||||||
}
|
|
||||||
DailyEccmReportsV2Datasource.db = await n.db.use(dbName);
|
|
||||||
await this.checkAndCreateIndex();
|
|
||||||
DailyEccmReportsV2Datasource.logger.log(
|
|
||||||
`Connected to daily reports db - ${dbName}`,
|
|
||||||
);
|
|
||||||
return DailyEccmReportsV2Datasource.db;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async checkAndCreateIndex(): Promise<void> {
|
|
||||||
const couchDbUrl = CouchDb.getCouchDbUrl();
|
|
||||||
const indexes = await DailyEccmReportsV2Datasource.getIndexes(couchDbUrl);
|
|
||||||
const index = indexes.find(
|
|
||||||
(index: any) => index.name === DATETIME_INDEX_ASC,
|
|
||||||
);
|
|
||||||
if (!index) {
|
|
||||||
await DailyEccmReportsV2Datasource.createIndex(couchDbUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getIndexes(couchDbUrl: string): Promise<any[]> {
|
|
||||||
const response = await axios.get(
|
|
||||||
`${couchDbUrl}/${DB_NAME}/_index?skip=0&limit=999999`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.logger.debug(`Indexes: ${JSON.stringify(response.data.indexes)}`);
|
|
||||||
return response.data.indexes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async createIndex(couchDbUrl: string): Promise<boolean> {
|
|
||||||
this.logger.debug(`Creating index ${DATETIME_INDEX_ASC}`);
|
|
||||||
const body = {
|
|
||||||
index: { fields: [{ datetime: 'asc' }] },
|
|
||||||
name: DATETIME_INDEX_ASC,
|
|
||||||
type: 'json',
|
|
||||||
};
|
|
||||||
const response = await axios.post(`${couchDbUrl}/${DB_NAME}/_index`, body, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
`Index ${DATETIME_INDEX_ASC} created ` +
|
|
||||||
`with response: ${JSON.stringify(response.data)}`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,14 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { WidgetsCollectionService } from '@app/event-emitter/dashboards/widgets-collection.service';
|
import { WidgetsCollectionService } from '@app/event-emitter/dashboards/widgets-collection.service';
|
||||||
import { IssuesByTagsWidgetDataLoaderService } from './widget-data-loader/issues-by-tags.widget-data-loader.service';
|
import { IssuesByTagsWidgetDataLoaderService } from './widget-data-loader/issues-by-tags.widget-data-loader.service';
|
||||||
import { createInteractiveWidget } from '@app/event-emitter/dashboards/interactive-widget-factory';
|
import { createInteractiveWidget } from '@app/event-emitter/dashboards/interactive-widget-factory';
|
||||||
import { DailyEccmReportsV2DataLoaderService } from 'src/eccm-statistic/dashboards/widget-data-loader/daily-eccm-v2.widget-data-loader.service';
|
|
||||||
import { WIDGET_TYPE as DAILY_ECCM_V2_WIDGET_TYPE } from 'src/reports/daily-eccm-v2-report-task-runner.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardInitService {
|
export class DashboardInitService {
|
||||||
constructor(
|
constructor(
|
||||||
private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
|
private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
|
||||||
private dailyEccmReportsV2DataLoaderService: DailyEccmReportsV2DataLoaderService,
|
|
||||||
private widgetsCollectionService: WidgetsCollectionService,
|
private widgetsCollectionService: WidgetsCollectionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -23,10 +20,6 @@ export class DashboardInitService {
|
||||||
this.issuesByTagsWidgetDataLoaderService,
|
this.issuesByTagsWidgetDataLoaderService,
|
||||||
'issues_list_by_tags',
|
'issues_list_by_tags',
|
||||||
),
|
),
|
||||||
createInteractiveWidget(
|
|
||||||
this.dailyEccmReportsV2DataLoaderService,
|
|
||||||
DAILY_ECCM_V2_WIDGET_TYPE,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
collection.forEach((w) => this.widgetsCollectionService.appendWidget(w));
|
collection.forEach((w) => this.widgetsCollectionService.appendWidget(w));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { WidgetDataLoaderInterface } from '@app/event-emitter/dashboards/widget-data-loader-interface';
|
|
||||||
import {
|
|
||||||
AppError,
|
|
||||||
createAppError,
|
|
||||||
fail,
|
|
||||||
Result,
|
|
||||||
success,
|
|
||||||
} from '@app/event-emitter/utils/result';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { DailyEccmReportsV2Datasource } from 'src/couchdb-datasources/daily-eccm-reports-v2.datasource';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DailyEccmReportsV2DataLoaderService
|
|
||||||
implements WidgetDataLoaderInterface<any, any, any>
|
|
||||||
{
|
|
||||||
isMyConfig(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(
|
|
||||||
dataLoaderParams: any,
|
|
||||||
dashboardParams: any,
|
|
||||||
dashboardId: string,
|
|
||||||
widgetId: string,
|
|
||||||
): Promise<Result<any, AppError>> {
|
|
||||||
const ds = await DailyEccmReportsV2Datasource.getDatasource();
|
|
||||||
const response = await ds.find({
|
|
||||||
selector: {
|
|
||||||
dashboardId: dashboardId,
|
|
||||||
widgetId: widgetId,
|
|
||||||
latest: true,
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
const data = response.docs[0];
|
|
||||||
if (!data) {
|
|
||||||
return fail(createAppError('No data found'));
|
|
||||||
}
|
|
||||||
return success(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model'
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonalNotificationsService {
|
export class PersonalNotificationsService {
|
||||||
private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g;
|
private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g;
|
||||||
private userName2Re = /@([\wА-Яа-яЁё\.]+)/g;
|
|
||||||
private logger = new Logger(PersonalNotificationsService.name);
|
private logger = new Logger(PersonalNotificationsService.name);
|
||||||
|
|
||||||
$messages = new Subject<IssueAndPersonalParsedMessageModel>();
|
$messages = new Subject<IssueAndPersonalParsedMessageModel>();
|
||||||
|
|
@ -72,16 +71,6 @@ export class PersonalNotificationsService {
|
||||||
}
|
}
|
||||||
result = results.next();
|
result = results.next();
|
||||||
}
|
}
|
||||||
const results2 = notes.matchAll(this.userName2Re);
|
|
||||||
let result2 = results2.next();
|
|
||||||
while (!result2.done) {
|
|
||||||
if (result.value && result.value[1]) {
|
|
||||||
const login = result.value[1];
|
|
||||||
const user = await this.usersService.findUserByLogin(login);
|
|
||||||
if (user) recipients.push(user.id);
|
|
||||||
}
|
|
||||||
result2 = results2.next();
|
|
||||||
}
|
|
||||||
if (recipients.length > 0) {
|
if (recipients.length > 0) {
|
||||||
return {
|
return {
|
||||||
message: notes,
|
message: notes,
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
|
||||||
|
|
||||||
export type Params = {
|
|
||||||
query: any; // TODO: add type
|
|
||||||
schedule: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Report = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
datetime: number;
|
|
||||||
datetimeFormatted: string;
|
|
||||||
jobInfo: JobInfo;
|
|
||||||
params: Params; // query?, schedule?, etc TODO
|
|
||||||
data: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JobInfo = {
|
|
||||||
jobId: string;
|
|
||||||
dashboardId: string;
|
|
||||||
widgetId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class DailyEccmV2ReportTaskHandlerService {
|
|
||||||
private logger = new Logger(DailyEccmV2ReportTaskHandlerService.name);
|
|
||||||
|
|
||||||
private issuesService: IssuesService | null = null;
|
|
||||||
|
|
||||||
constructor(public params: Params, public jobInfo: JobInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIssuesService(issuesService: IssuesService): void {
|
|
||||||
this.issuesService = issuesService;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIssuesService(): IssuesService | null {
|
|
||||||
if (!this.issuesService) {
|
|
||||||
this.logger.warn(
|
|
||||||
'DailyEccmV2ReportTaskHandlerService is not initialized, issuesService is null',
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.issuesService;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createReport(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const report = await this.prepareReportData();
|
|
||||||
await this.saveNewReport(report);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async prepareReportData(): Promise<Report> {
|
|
||||||
const issuesService = this.getIssuesService();
|
|
||||||
if (!issuesService) {
|
|
||||||
throw new Error(
|
|
||||||
'Cannot create report without issuesService, DailyEccmV2ReportTaskHandlerService is not initialized',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const issues = await this.issuesService.mergedTreesAndFind(
|
|
||||||
this.params.query,
|
|
||||||
);
|
|
||||||
return {} as Report;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveNewReport(report: Report): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePreviousReport(report: Report): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,579 +0,0 @@
|
||||||
import { DashboardsService } from '@app/event-emitter/dashboards/dashboards.service';
|
|
||||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
|
||||||
import { Dashboard, Widget } from '@app/event-emitter/models/dashboard';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import { DailyEccmReportsV2Datasource } from 'src/couchdb-datasources/daily-eccm-reports-v2.datasource';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { Params } from './daily-eccm-v2-report-task-handler';
|
|
||||||
|
|
||||||
export type Job = {
|
|
||||||
id: string;
|
|
||||||
params: Params;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JobHandlerParams = {
|
|
||||||
job: Job;
|
|
||||||
dashboard: Dashboard;
|
|
||||||
widget: Widget;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WIDGET_TYPE = 'daily_eccm_v2';
|
|
||||||
|
|
||||||
export const JOB_PREFIX = 'daily_eccm_v2';
|
|
||||||
|
|
||||||
export const UPDATE_RATE = 60 * 1000;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DailyEccmV2ReportTaskRunnerService {
|
|
||||||
private logger = new Logger(DailyEccmV2ReportTaskRunnerService.name);
|
|
||||||
|
|
||||||
private dashboardsService: DashboardsService;
|
|
||||||
|
|
||||||
private previousAutoScanTime = 0;
|
|
||||||
|
|
||||||
private cronJobs: Record<string, CronJob> = {};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
|
||||||
private issuesService: IssuesService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto scan jobs every UPDATE_RATE seconds.
|
|
||||||
* First call to autoScanJobs is done immediately.
|
|
||||||
* Each subsequent call is done after the timeout.
|
|
||||||
* The timeout is reset after each call to autoScanJobs.
|
|
||||||
*/
|
|
||||||
initAutoScanJobs() {
|
|
||||||
const tick = () => {
|
|
||||||
setTimeout(tick, UPDATE_RATE);
|
|
||||||
this.autoScanJobs();
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
async autoScanJobs() {
|
|
||||||
this.logger.debug('Auto scan jobs started');
|
|
||||||
const dbs = this.getDashboardsService();
|
|
||||||
if (!dbs) {
|
|
||||||
this.logger.warn('Dashboards service not initialized');
|
|
||||||
this.logger.debug('Auto scan jobs finished');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nowTime = new Date().getTime();
|
|
||||||
const dashboards = await dbs.findDashboardsByWidgetType(
|
|
||||||
WIDGET_TYPE,
|
|
||||||
this.previousAutoScanTime,
|
|
||||||
);
|
|
||||||
this.previousAutoScanTime = nowTime;
|
|
||||||
for (let i = 0; i < dashboards.length; i++) {
|
|
||||||
const dashboard: Dashboard = dashboards[i];
|
|
||||||
for (let j = 0; j < dashboard.data.widgets.length; j++) {
|
|
||||||
const widget = dashboard.data.widgets[j];
|
|
||||||
if (widget.type === WIDGET_TYPE) {
|
|
||||||
const jobId = `${JOB_PREFIX}_${dashboard.id}_${widget.id}`;
|
|
||||||
this.updateCronJob(jobId, widget, dashboard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.logger.debug('Auto scan jobs finished');
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateCronJob(
|
|
||||||
jobId: string,
|
|
||||||
widget: Widget,
|
|
||||||
dashboard: Dashboard,
|
|
||||||
): void {
|
|
||||||
if (this.cronJobs[jobId]) {
|
|
||||||
this.cronJobs[jobId].stop();
|
|
||||||
this.schedulerRegistry.deleteCronJob(jobId);
|
|
||||||
}
|
|
||||||
const job = new CronJob(
|
|
||||||
widget.dataLoaderParams?.schedule || '0 0 * * *',
|
|
||||||
this.createJobHandler(jobId, dashboard, widget),
|
|
||||||
);
|
|
||||||
this.cronJobs[jobId] = job;
|
|
||||||
this.schedulerRegistry.addCronJob(jobId, job);
|
|
||||||
job.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDashboardsService(): DashboardsService | null {
|
|
||||||
if (!this.dashboardsService) {
|
|
||||||
this.logger.warn('Dashboards service not initialized');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.dashboardsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDashboardsService(dashboardsService: DashboardsService) {
|
|
||||||
this.dashboardsService = dashboardsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createJobHandler(
|
|
||||||
jobId: string,
|
|
||||||
dashboard: Dashboard,
|
|
||||||
widget: Widget,
|
|
||||||
): () => Promise<void> {
|
|
||||||
this.logger.debug(
|
|
||||||
`Create job handler for cron job ${jobId}, ` +
|
|
||||||
`dashboard ${dashboard.id}, ` +
|
|
||||||
`widget ${widget.id}`,
|
|
||||||
);
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
this.logger.debug(
|
|
||||||
`Cron job ${jobId} started - dashboard ${dashboard.id}, widget ${widget.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = widget.dataLoaderParams?.query;
|
|
||||||
if (!query) {
|
|
||||||
this.logger.log(
|
|
||||||
`Cron job ${jobId} finished - dashboard ${dashboard.id}, widget ${widget.id}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let issues: RedmineTypes.Issue[] = [];
|
|
||||||
try {
|
|
||||||
issues = await this.issuesService.mergedTreesAndFind(query);
|
|
||||||
await this.saveReport(dashboard, widget, issues);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Cron job ${jobId} finished - ` +
|
|
||||||
`dashboard ${dashboard.id}, ` +
|
|
||||||
`widget ${widget.id}, ` +
|
|
||||||
`issues count ${issues.length}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async analyzeIssueMetrics(
|
|
||||||
dashboard: Dashboard,
|
|
||||||
widget: Widget,
|
|
||||||
issues: RedmineTypes.Issue[],
|
|
||||||
previousIssues?: RedmineTypes.Issue[],
|
|
||||||
): Promise<Record<string, any>> {
|
|
||||||
this.logger.log(
|
|
||||||
`Analyzing issues metrics for dashboard ${dashboard.id}, widget ${widget.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 1. Подсчет количества задач по статусам
|
|
||||||
this.logger.debug(`Step 1. Calculating issues by status`);
|
|
||||||
const issuesByStatusCount = issues.reduce((acc, issue) => {
|
|
||||||
const status = issue.status?.name;
|
|
||||||
if (status) {
|
|
||||||
if (!acc[status]) {
|
|
||||||
acc[status] = {
|
|
||||||
count: 0,
|
|
||||||
issueIds: [],
|
|
||||||
byVersion: {} as Record<
|
|
||||||
string,
|
|
||||||
{ count: number; issueIds: number[] }
|
|
||||||
>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
acc[status].count = acc[status].count + 1;
|
|
||||||
acc[status].issueIds.push(issue.id);
|
|
||||||
const version = issue.fixed_version?.name;
|
|
||||||
if (version) {
|
|
||||||
if (!acc[status].byVersion[version]) {
|
|
||||||
acc[status].byVersion[version] = { count: 0, issueIds: [] };
|
|
||||||
}
|
|
||||||
acc[status].byVersion[version].count++;
|
|
||||||
acc[status].byVersion[version].issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 1. Calculating issues by status - done',
|
|
||||||
JSON.stringify(issuesByStatusCount),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 2. Подсчет количества задач по версиям
|
|
||||||
this.logger.debug('Step 2. Calculating issues by versions');
|
|
||||||
const issuesByVersionsCount = issues.reduce((acc, issue) => {
|
|
||||||
const version = issue.fixed_version?.name;
|
|
||||||
if (version) {
|
|
||||||
if (!acc[version]) {
|
|
||||||
acc[version] = {
|
|
||||||
count: 0,
|
|
||||||
issueIds: [],
|
|
||||||
byStatus: {} as Record<
|
|
||||||
string,
|
|
||||||
{ count: number; issueIds: number[] }
|
|
||||||
>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
acc[version].count = acc[version].count + 1;
|
|
||||||
acc[version].issueIds.push(issue.id);
|
|
||||||
const status = issue.status?.name;
|
|
||||||
if (status) {
|
|
||||||
if (!acc[version].byStatus[status]) {
|
|
||||||
acc[version].byStatus[status] = { count: 0, issueIds: [] };
|
|
||||||
}
|
|
||||||
acc[version].byStatus[status].count += 1;
|
|
||||||
acc[version].byStatus[status].issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 2. Calculating issues by versions - done',
|
|
||||||
JSON.stringify(issuesByVersionsCount),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 3. Подсчёт количества задач по работникам
|
|
||||||
this.logger.debug('Step 3. Calculating issues by user names');
|
|
||||||
const issuesByUsername = {};
|
|
||||||
issues.forEach((issue: any) => {
|
|
||||||
const currentUser = issue.current_user?.name;
|
|
||||||
if (currentUser) {
|
|
||||||
if (!issuesByUsername[currentUser]) {
|
|
||||||
issuesByUsername[currentUser] = { count: 0, issueIds: [] };
|
|
||||||
}
|
|
||||||
issuesByUsername[currentUser].count += 1;
|
|
||||||
issuesByUsername[currentUser].issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 3. Calculating issues by user names - done',
|
|
||||||
JSON.stringify(issuesByUsername),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 4. Подсчет количества внутренних изменений
|
|
||||||
this.logger.debug('Step 4. Calculating internal changes');
|
|
||||||
const changesInterval =
|
|
||||||
(widget?.dataLoaderParams['changesInterval'] as number) ??
|
|
||||||
24 * 60 * 60 * 1000;
|
|
||||||
const changesCount = {
|
|
||||||
totalChanges: 0,
|
|
||||||
totalComments: 0,
|
|
||||||
byIssue: {} as Record<number, { changes: number; comments: number }>,
|
|
||||||
byStatus: {} as Record<string, { changes: number; comments: number }>,
|
|
||||||
byVersion: {} as Record<string, { changes: number; comments: number }>,
|
|
||||||
byUserName: {} as Record<string, { changes: number; comments: number }>,
|
|
||||||
};
|
|
||||||
const now = DateTime.now().toMillis();
|
|
||||||
if (
|
|
||||||
typeof changesInterval === 'number' &&
|
|
||||||
changesInterval > 0 &&
|
|
||||||
issues?.length > 0
|
|
||||||
) {
|
|
||||||
issues.forEach((issue) => {
|
|
||||||
const status = issue.status?.name ?? 'no_status';
|
|
||||||
const version = issue.fixed_version?.name ?? 'no_version';
|
|
||||||
const changes = issue.journals?.reduce(
|
|
||||||
(acc, journal) => {
|
|
||||||
const createdOnTimestamp = DateTime.fromISO(
|
|
||||||
journal.created_on,
|
|
||||||
).toMillis();
|
|
||||||
const currentUser = journal?.user?.name ?? 'unknown';
|
|
||||||
if (
|
|
||||||
createdOnTimestamp > now - changesInterval &&
|
|
||||||
createdOnTimestamp <= now
|
|
||||||
) {
|
|
||||||
if (!changesCount.byStatus[status]) {
|
|
||||||
changesCount.byStatus[status] = { changes: 0, comments: 0 };
|
|
||||||
}
|
|
||||||
if (!changesCount.byVersion[version]) {
|
|
||||||
changesCount.byVersion[version] = { changes: 0, comments: 0 };
|
|
||||||
}
|
|
||||||
if (!changesCount.byUserName[currentUser]) {
|
|
||||||
changesCount.byUserName[currentUser] = {
|
|
||||||
changes: 0,
|
|
||||||
comments: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
acc.changes += 1;
|
|
||||||
changesCount.totalChanges += 1;
|
|
||||||
changesCount.byStatus[status].changes += 1;
|
|
||||||
changesCount.byVersion[version].changes += 1;
|
|
||||||
changesCount.byUserName[currentUser].changes += 1;
|
|
||||||
if (
|
|
||||||
typeof journal.notes === 'string' &&
|
|
||||||
journal.notes.length > 0
|
|
||||||
) {
|
|
||||||
acc.comments += 1;
|
|
||||||
changesCount.totalComments += 1;
|
|
||||||
changesCount.byStatus[status].comments += 1;
|
|
||||||
changesCount.byVersion[version].comments += 1;
|
|
||||||
changesCount.byUserName[currentUser].comments += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
changes: 0,
|
|
||||||
comments: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (changes && (changes.changes > 0 || changes.comments > 0)) {
|
|
||||||
changesCount.byIssue[issue.id] = changes;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 4. Calculating internal changes - done',
|
|
||||||
JSON.stringify(changesCount),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 5: Количество задач с оценками трудозатрат
|
|
||||||
this.logger.debug('Step 5. Counting issues with estimates and spent hours');
|
|
||||||
const issuesWithEstimatesAndSpenthoursCount = {
|
|
||||||
withEstimates: { count: 0, issueIds: [] },
|
|
||||||
withoutEstimates: { count: 0, issueIds: [] },
|
|
||||||
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
|
|
||||||
byVersion: {} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
withEstimates: { count: number; issueIds: number[] };
|
|
||||||
withoutEstimates: { count: number; issueIds: number[] };
|
|
||||||
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
|
|
||||||
}
|
|
||||||
>,
|
|
||||||
byStatus: {} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
withEstimates: { count: number; issueIds: number[] };
|
|
||||||
withoutEstimates: { count: number; issueIds: number[] };
|
|
||||||
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
|
|
||||||
}
|
|
||||||
>,
|
|
||||||
};
|
|
||||||
issues.forEach((issue) => {
|
|
||||||
const version = issue.fixed_version?.name;
|
|
||||||
if (
|
|
||||||
version &&
|
|
||||||
!issuesWithEstimatesAndSpenthoursCount.byVersion[version]
|
|
||||||
) {
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version] = {
|
|
||||||
withEstimates: { count: 0, issueIds: [] },
|
|
||||||
withoutEstimates: { count: 0, issueIds: [] },
|
|
||||||
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = issue.status?.name;
|
|
||||||
if (status && !issuesWithEstimatesAndSpenthoursCount.byStatus[status]) {
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status] = {
|
|
||||||
withEstimates: { count: 0, issueIds: [] },
|
|
||||||
withoutEstimates: { count: 0, issueIds: [] },
|
|
||||||
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof issue.estimated_hours === 'number' &&
|
|
||||||
issue.estimated_hours > 0
|
|
||||||
) {
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withEstimates.count += 1;
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withEstimates.issueIds.push(
|
|
||||||
issue.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.issueIds.push(issue.id);
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.issueIds.push(issue.id);
|
|
||||||
} else {
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.count += 1;
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.issueIds.push(
|
|
||||||
issue.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.issueIds.push(issue.id);
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof issue.spent_hours === 'number' &&
|
|
||||||
typeof issue.estimated_hours === 'number' &&
|
|
||||||
issue.spent_hours > issue.estimated_hours
|
|
||||||
) {
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.count += 1;
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.issueIds.push(
|
|
||||||
issue.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.issueIds.push(issue.id);
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.count += 1;
|
|
||||||
// eslint-disable-next-line prettier/prettier
|
|
||||||
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 5. Counting issues with estimates and spent hours - done',
|
|
||||||
JSON.stringify(issuesWithEstimatesAndSpenthoursCount),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ШАГ 6: Счётчики сравнения с предыдущим отчётом
|
|
||||||
this.logger.debug('Step 6: Calculating differences with previous report');
|
|
||||||
const differencesCount = {
|
|
||||||
newIssues: { count: 0, issueIds: [] },
|
|
||||||
lostIssues: { count: 0, issueIds: [] },
|
|
||||||
reopenedIssues: { count: 0, issueIds: [] },
|
|
||||||
closedIssues: { count: 0, issueIds: [] },
|
|
||||||
};
|
|
||||||
issues.forEach((issue) => {
|
|
||||||
const issueIntoPreviousReport = previousIssues.find(
|
|
||||||
(prevIssue) => prevIssue.id === issue.id,
|
|
||||||
);
|
|
||||||
if (!issueIntoPreviousReport) {
|
|
||||||
differencesCount.newIssues.count += 1;
|
|
||||||
differencesCount.newIssues.issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
previousIssues.forEach((prevIssue) => {
|
|
||||||
const issueIntoCurrentReport = issues.find(
|
|
||||||
(currIssue) => currIssue.id === prevIssue.id,
|
|
||||||
);
|
|
||||||
if (!issueIntoCurrentReport) {
|
|
||||||
differencesCount.lostIssues.count += 1;
|
|
||||||
differencesCount.lostIssues.issueIds.push(prevIssue.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
issues.forEach((issue) => {
|
|
||||||
const issueStatus: any = issue.status;
|
|
||||||
if (issueStatus.is_closed) {
|
|
||||||
const prevReportClosedIssue = previousIssues.find((prevIssue) => {
|
|
||||||
return (
|
|
||||||
prevIssue.id === issue.id &&
|
|
||||||
prevIssue.status &&
|
|
||||||
(prevIssue.status as any).is_closed
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!prevReportClosedIssue) {
|
|
||||||
differencesCount.closedIssues.count += 1;
|
|
||||||
differencesCount.closedIssues.issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const prevReportOpenIssue = previousIssues.find((prevIssue) => {
|
|
||||||
return (
|
|
||||||
prevIssue.id === issue.id &&
|
|
||||||
prevIssue.status &&
|
|
||||||
!(prevIssue.status as any)?.is_closed
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!prevReportOpenIssue) {
|
|
||||||
differencesCount.reopenedIssues.count += 1;
|
|
||||||
differencesCount.reopenedIssues.issueIds.push(issue.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
'Step 6: Calculating differences with previous report - done',
|
|
||||||
JSON.stringify(differencesCount),
|
|
||||||
);
|
|
||||||
|
|
||||||
const metrics = {
|
|
||||||
issuesByStatusCount: issuesByStatusCount,
|
|
||||||
issuesByVersionsCount: issuesByVersionsCount,
|
|
||||||
issuesByUsername: issuesByUsername,
|
|
||||||
changesCount: changesCount,
|
|
||||||
issuesWithEstimatesAndSpenthoursCount:
|
|
||||||
issuesWithEstimatesAndSpenthoursCount,
|
|
||||||
differencesCount: differencesCount,
|
|
||||||
};
|
|
||||||
this.logger.log(
|
|
||||||
`Metrics generated for dashboard ${dashboard.id}, widget ${widget.id}`,
|
|
||||||
);
|
|
||||||
return metrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveReport(
|
|
||||||
dashboard: Dashboard,
|
|
||||||
widget: Widget,
|
|
||||||
issues: RedmineTypes.Issue[],
|
|
||||||
): Promise<void> {
|
|
||||||
const datasource = await DailyEccmReportsV2Datasource.getDatasource();
|
|
||||||
const id = randomUUID();
|
|
||||||
const dashboardId = dashboard.id;
|
|
||||||
const widgetId = widget.id;
|
|
||||||
const now = DateTime.now();
|
|
||||||
const datetime = now.toMillis();
|
|
||||||
const datetimeFormatted = now.toISO();
|
|
||||||
const reportIssues = issues.map((issue: any) => {
|
|
||||||
return {
|
|
||||||
id: issue.id,
|
|
||||||
subject: issue.subject,
|
|
||||||
created_on: issue.created_on,
|
|
||||||
updated_on: issue.updated_on,
|
|
||||||
closed_on: issue.closed_on,
|
|
||||||
status: issue.status,
|
|
||||||
fixed_version: issue.fixed_version,
|
|
||||||
priority: issue.priority,
|
|
||||||
author: issue.author,
|
|
||||||
assigned_to: issue.assigned_to,
|
|
||||||
dev: issue.dev,
|
|
||||||
qa: issue.qa,
|
|
||||||
cr: issue.cr,
|
|
||||||
current_user: issue.current_user,
|
|
||||||
tracker: issue.tracker,
|
|
||||||
start_date: issue.start_date,
|
|
||||||
due_date: issue.due_date,
|
|
||||||
done_ratio: issue.done_ratio,
|
|
||||||
estimated_hours: issue.estimated_hours,
|
|
||||||
total_estimated_hours: issue.total_estimated_hours,
|
|
||||||
spent_hours: issue.spent_hours,
|
|
||||||
total_spent_hours: issue.total_spent_hours,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const prevDataResponse = await datasource.find({
|
|
||||||
selector: {
|
|
||||||
dashboardId: dashboardId,
|
|
||||||
widgetId: widget.id,
|
|
||||||
latest: true,
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
const prevData = prevDataResponse.docs[0];
|
|
||||||
const prevIssues = prevData ? prevData.reportIssues : [];
|
|
||||||
if (prevData) {
|
|
||||||
prevData.latest = false;
|
|
||||||
await datasource.insert(prevData);
|
|
||||||
}
|
|
||||||
const issuesMetrics = await this.analyzeIssueMetrics(
|
|
||||||
dashboard,
|
|
||||||
widget,
|
|
||||||
issues,
|
|
||||||
prevIssues,
|
|
||||||
);
|
|
||||||
const item: any = {
|
|
||||||
_id: id,
|
|
||||||
id: id,
|
|
||||||
dashboardId,
|
|
||||||
widgetId,
|
|
||||||
datetime,
|
|
||||||
datetimeFormatted,
|
|
||||||
reportIssues,
|
|
||||||
issuesMetrics,
|
|
||||||
latest: true,
|
|
||||||
};
|
|
||||||
await datasource.insert(item);
|
|
||||||
this.logger.debug(
|
|
||||||
`Report saved to db - id ${id}, ` +
|
|
||||||
`issues count ${reportIssues.length}, ` +
|
|
||||||
`datetime ${datetimeFormatted}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DailyEccmV2ReportService {}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
CurrentIssuesEccmReportService,
|
CurrentIssuesEccmReportService,
|
||||||
} from 'src/reports/current-issues-eccm.report.service';
|
} from 'src/reports/current-issues-eccm.report.service';
|
||||||
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
|
||||||
import { TelegramBotService, cutMessage } from '../telegram-bot.service';
|
import { TelegramBotService } from '../telegram-bot.service';
|
||||||
import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface';
|
import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -45,7 +45,7 @@ export class CurrentIssuesEccmBotHandlerService
|
||||||
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
|
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
|
||||||
});
|
});
|
||||||
this.logger.debug(`Current issues eccm report: ${report}`);
|
this.logger.debug(`Current issues eccm report: ${report}`);
|
||||||
bot.sendMessage(msg.chat.id, cutMessage(report) || 'empty report', {
|
bot.sendMessage(msg.chat.id, report || 'empty report', {
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { Body, Controller, Post } from '@nestjs/common';
|
|
||||||
import { SendMessageParams, TelegramBotService } from './telegram-bot.service';
|
|
||||||
import {
|
|
||||||
BadRequestErrorHandler,
|
|
||||||
getOrAppErrorOrThrow,
|
|
||||||
} from '@app/event-emitter/utils/result';
|
|
||||||
|
|
||||||
@Controller('/api/telegram-bot')
|
|
||||||
export class TelegramBotController {
|
|
||||||
constructor(private telegramBotService: TelegramBotService) {}
|
|
||||||
|
|
||||||
@Post('send-message')
|
|
||||||
async sendMessage(@Body() params: SendMessageParams): Promise<void> {
|
|
||||||
await getOrAppErrorOrThrow(async () => {
|
|
||||||
await this.telegramBotService.sendMessageByParams(params);
|
|
||||||
}, BadRequestErrorHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,73 +8,6 @@ import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
|
||||||
import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
|
import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
|
||||||
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
|
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
|
||||||
import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service';
|
import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service';
|
||||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
|
||||||
import { JSONPath } from 'jsonpath-plus';
|
|
||||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
|
||||||
import { createAppError } from '@app/event-emitter/utils/result';
|
|
||||||
|
|
||||||
const MAX_TELEGRAM_MESSAGE_LENGTH = 4000;
|
|
||||||
|
|
||||||
export function cutMessage(msg: string): string {
|
|
||||||
if (msg.length > MAX_TELEGRAM_MESSAGE_LENGTH) {
|
|
||||||
return msg.slice(0, 4000) + '...';
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OptionsParams = {
|
|
||||||
parse_mode?: TelegramBot.ParseMode | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecipientByName = {
|
|
||||||
firstname: string;
|
|
||||||
lastname: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecipientByIssueField = {
|
|
||||||
issueId: number;
|
|
||||||
field: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SendMessageParams = {
|
|
||||||
recipients: (RecipientByName | RecipientByIssueField)[];
|
|
||||||
msg: string;
|
|
||||||
options?: OptionsParams;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateSendMessageParams(params: SendMessageParams): string[] {
|
|
||||||
const res: string[] = [];
|
|
||||||
if (typeof params.msg !== 'string') {
|
|
||||||
res.push('Wrong msg field value, must be string');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof params.recipients !== 'object' ||
|
|
||||||
typeof params.recipients.length !== 'number'
|
|
||||||
) {
|
|
||||||
res.push('Wrong recipients field value, must be array');
|
|
||||||
}
|
|
||||||
for (let i = 0; i < params.recipients?.length; i++) {
|
|
||||||
const r: any = params.recipients[i];
|
|
||||||
const checkRecipient = Boolean(
|
|
||||||
(typeof r.firstname === 'string' && typeof r.lastname === 'string') ||
|
|
||||||
(typeof r.issueId === 'number' && typeof r.field === 'string'),
|
|
||||||
);
|
|
||||||
if (!checkRecipient) {
|
|
||||||
res.push(
|
|
||||||
'Wrong recipient item value, must be object with firstname+lastname or issueId+field',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
params?.options?.parse_mode &&
|
|
||||||
['Markdown', 'MarkdownV2', 'HTML'].indexOf(params.options.parse_mode) < 0
|
|
||||||
) {
|
|
||||||
res.push(
|
|
||||||
`Wrong options.parse_mode value, must be one of 'Markdown', 'MarkdownV2', 'HTML'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelegramBotService {
|
export class TelegramBotService {
|
||||||
|
|
@ -93,7 +26,6 @@ export class TelegramBotService {
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
|
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
|
||||||
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
|
||||||
private issuesService: IssuesService,
|
|
||||||
) {
|
) {
|
||||||
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
|
||||||
this.redminePublicUrlPrefix =
|
this.redminePublicUrlPrefix =
|
||||||
|
|
@ -162,7 +94,7 @@ export class TelegramBotService {
|
||||||
`message = ${helpMessage}`,
|
`message = ${helpMessage}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
this.bot.sendMessage(msg.chat.id, cutMessage(helpMessage));
|
this.bot.sendMessage(msg.chat.id, helpMessage);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this.logger.error(`Error at send help message - ${ex?.message}`);
|
this.logger.error(`Error at send help message - ${ex?.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -179,18 +111,7 @@ export class TelegramBotService {
|
||||||
);
|
);
|
||||||
if (!userMetaInfo) return false;
|
if (!userMetaInfo) return false;
|
||||||
const chatId = userMetaInfo.telegram_chat_id;
|
const chatId = userMetaInfo.telegram_chat_id;
|
||||||
const formattedMsg = cutMessage(msg);
|
await this.bot.sendMessage(chatId, msg, options);
|
||||||
try {
|
|
||||||
await this.bot.sendMessage(chatId, formattedMsg, options);
|
|
||||||
} catch (ex) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error at send message to telegram: ` +
|
|
||||||
`redmineId - ${redmineId}; ` +
|
|
||||||
`message - ${formattedMsg}; ` +
|
|
||||||
`exception - ${ex?.message || '<null message>'}`
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Sent message for redmineUserId = ${redmineId}, ` +
|
`Sent message for redmineUserId = ${redmineId}, ` +
|
||||||
`telegramChatId = ${chatId}, ` +
|
`telegramChatId = ${chatId}, ` +
|
||||||
|
|
@ -212,62 +133,6 @@ export class TelegramBotService {
|
||||||
return await this.sendMessageByRedmineId(user.id, msg, options);
|
return await this.sendMessageByRedmineId(user.id, msg, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessageByParams(params: SendMessageParams): Promise<void> {
|
|
||||||
const paramsErrors = validateSendMessageParams(params);
|
|
||||||
if (paramsErrors && paramsErrors.length > 0) {
|
|
||||||
throw createAppError(`Params errors - ${JSON.stringify(paramsErrors)}`);
|
|
||||||
}
|
|
||||||
const issuesStore = await this.getIssuesStore(params);
|
|
||||||
for (let i = 0; i < params.recipients.length; i++) {
|
|
||||||
const recipient: any = params.recipients[i];
|
|
||||||
if (recipient.firstname && recipient.lastname) {
|
|
||||||
await this.sendMessageByName(
|
|
||||||
recipient.firstname,
|
|
||||||
recipient.lastname,
|
|
||||||
params.msg,
|
|
||||||
params.options,
|
|
||||||
);
|
|
||||||
} else if (recipient.issueId && recipient.field) {
|
|
||||||
const issue = issuesStore[recipient.issueId];
|
|
||||||
if (!issue) continue;
|
|
||||||
let fieldValue: any;
|
|
||||||
try {
|
|
||||||
fieldValue = JSONPath({ json: issue, path: recipient.field });
|
|
||||||
} catch (ex) {
|
|
||||||
const warnMsg =
|
|
||||||
`Error at get value from issueId = ${recipient.issueId}; ` +
|
|
||||||
`field = ${JSON.stringify(recipient.field)} ` +
|
|
||||||
`- ${ex.message}`;
|
|
||||||
this.logger.warn(warnMsg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (fieldValue && fieldValue.length > 0) {
|
|
||||||
fieldValue = fieldValue[0];
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof fieldValue === 'object' &&
|
|
||||||
fieldValue.firstname &&
|
|
||||||
fieldValue.lastname
|
|
||||||
) {
|
|
||||||
await this.sendMessageByName(
|
|
||||||
fieldValue.firstname,
|
|
||||||
fieldValue.lastname,
|
|
||||||
params.msg,
|
|
||||||
params.options,
|
|
||||||
);
|
|
||||||
} else if (typeof fieldValue === 'number') {
|
|
||||||
await this.sendMessageByRedmineId(
|
|
||||||
fieldValue,
|
|
||||||
params.msg,
|
|
||||||
params.options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async register(
|
private async register(
|
||||||
msg: TelegramBot.Message,
|
msg: TelegramBot.Message,
|
||||||
): Promise<{ result: boolean; message: string }> {
|
): Promise<{ result: boolean; message: string }> {
|
||||||
|
|
@ -279,15 +144,7 @@ export class TelegramBotService {
|
||||||
`with message ${message}, ` +
|
`with message ${message}, ` +
|
||||||
`log data = ${JSON.stringify(logData || null)}`;
|
`log data = ${JSON.stringify(logData || null)}`;
|
||||||
this.logger.log(logMsg);
|
this.logger.log(logMsg);
|
||||||
try {
|
this.bot.sendMessage(msg.chat.id, message);
|
||||||
this.bot.sendMessage(msg.chat.id, message);
|
|
||||||
} catch (ex) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error at send message to telegram: ` +
|
|
||||||
`message - ${message}; ` +
|
|
||||||
`exception - ${ex?.message || '<null message>'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { result: result, message: message };
|
return { result: result, message: message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -336,24 +193,4 @@ export class TelegramBotService {
|
||||||
await this.userMetaInfoService.delete(userMetaInfo);
|
await this.userMetaInfoService.delete(userMetaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getIssuesStore(
|
|
||||||
params: SendMessageParams,
|
|
||||||
): Promise<Record<number, RedmineTypes.Issue>> {
|
|
||||||
const issueIds: number[] = [];
|
|
||||||
for (let i = 0; i < params.recipients.length; i++) {
|
|
||||||
const recipient: any = params.recipients[i];
|
|
||||||
if (typeof recipient.issueId === 'number' && recipient.issueId > 0) {
|
|
||||||
issueIds.push(recipient.issueId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (issueIds.length <= 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const issues = await this.issuesService.getIssues(issueIds);
|
|
||||||
return issues.reduce((acc, issue) => {
|
|
||||||
acc[issue.id] = issue;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
import { parse } from 'jsonc-parser';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { parseArgs } from 'util';
|
|
||||||
import { parse as csvParse } from 'csv/sync';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { createInterface } from 'readline/promises';
|
|
||||||
|
|
||||||
const helpMsg = `
|
|
||||||
Синхронизация задач из Redmine с Redmine Issue Event Emitter
|
|
||||||
|
|
||||||
* --config=sync-issues.jsonc - конфигурационный файл для постановки задачи на выполнение синхронизации
|
|
||||||
* --test - тестовый прогон без запуска задачи на синхронизацию
|
|
||||||
* --yes - автоматически отвечать 'y'
|
|
||||||
|
|
||||||
Пример конфигурационного файла:
|
|
||||||
|
|
||||||
{
|
|
||||||
"redmineUrl": "http://redmine.my-company.local",
|
|
||||||
"redmineToken": "... ... ...",
|
|
||||||
"eventEmitterUrl": "http://localhost:3000"
|
|
||||||
"forceUpdate": true, // true - принудительное обновление,
|
|
||||||
// false - сравнение даты обновления в redmine и в event-emitter-е
|
|
||||||
// и обновление только просроченных задач
|
|
||||||
"updatedAtFieldName": "Обновлено",
|
|
||||||
"dateTimeFormat": "dd.MM.yyyy HH:mm",
|
|
||||||
|
|
||||||
"csvLinks": [
|
|
||||||
"http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2011", // закрытые за сутки
|
|
||||||
"http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2012" // обновленые за сутки
|
|
||||||
],
|
|
||||||
"csvFiles": [
|
|
||||||
"/tmp/file1.csv",
|
|
||||||
"/tmp/file2.csv"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
var args = parseArgs({
|
|
||||||
options: {
|
|
||||||
config: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
yes: {
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!args?.values?.config) {
|
|
||||||
console.log(helpMsg);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfig(fileName) {
|
|
||||||
const rawData = readFileSync(fileName, { encoding: 'utf8' });
|
|
||||||
return parse(rawData);
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = readConfig(args.values.config);
|
|
||||||
|
|
||||||
console.log(`Конфигурационный файл:\n${JSON.stringify(config, null, ' ')}\n`); // DEBUG
|
|
||||||
|
|
||||||
const UNLIMIT = 999999;
|
|
||||||
|
|
||||||
async function getIssuesByQuery(selector) {
|
|
||||||
const resp = await fetch(`${config.eventEmitterUrl}/issues/find`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
selector: selector,
|
|
||||||
limit: UNLIMIT,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return await resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getIssuesByList(issueIds) {
|
|
||||||
return await getIssuesByQuery({
|
|
||||||
id: {
|
|
||||||
$in: issueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async function getCsvFromRedmine(url, token) {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'X-Redmine-API-Key': token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const rawData = await resp.text();
|
|
||||||
return csvParse(rawData, {
|
|
||||||
delimiter: ';',
|
|
||||||
quote: '"',
|
|
||||||
columns: true,
|
|
||||||
skip_empty_lines: true,
|
|
||||||
cast: (value, context) => {
|
|
||||||
if (context.column == '#') {
|
|
||||||
return Number(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCsvFromLocalFile(fileName) {
|
|
||||||
const rawData = readFileSync(fileName, { encoding: 'utf8' });
|
|
||||||
const data = csvParse(rawData, {
|
|
||||||
delimiter: ';',
|
|
||||||
quote: '"',
|
|
||||||
columns: true,
|
|
||||||
skip_empty_lines: true,
|
|
||||||
cast: (value, context) => {
|
|
||||||
if (context.column == '#') {
|
|
||||||
return Number(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllCsv() {
|
|
||||||
const res = {};
|
|
||||||
const csvLinks = config?.csvLinks || [];
|
|
||||||
const csvFiles = config?.csvFiles || [];
|
|
||||||
const token = config?.redmineToken || '';
|
|
||||||
function appendIssuesToRes(issues) {
|
|
||||||
issues.forEach((issue) => {
|
|
||||||
const issueId = Object.values(issue)[0];
|
|
||||||
issue.id = Number(issueId);
|
|
||||||
const updatedAt = DateTime.fromFormat(
|
|
||||||
issue[config.updatedAtFieldName],
|
|
||||||
config.dateTimeFormat,
|
|
||||||
);
|
|
||||||
if (!res[issueId]) {
|
|
||||||
res[issueId] = issue;
|
|
||||||
} else {
|
|
||||||
const existsIssue = res[issueId];
|
|
||||||
const existsUpdatedAt = DateTime.fromFormat(
|
|
||||||
existsIssue[config.updatedAtFieldName],
|
|
||||||
config.dateTimeFormat,
|
|
||||||
);
|
|
||||||
if (updatedAt.diff(existsUpdatedAt).as('seconds') > 0) {
|
|
||||||
res[issueId] = issue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (let i = 0; i < csvLinks.length; i++) {
|
|
||||||
const link = csvLinks[i];
|
|
||||||
const issues = await getCsvFromRedmine(link, token);
|
|
||||||
appendIssuesToRes(issues);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < csvFiles.length; i++) {
|
|
||||||
const file = csvFiles[i];
|
|
||||||
const issues = await getCsvFromLocalFile(file);
|
|
||||||
appendIssuesToRes(issues);
|
|
||||||
}
|
|
||||||
return Object.values(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFromEventEmitter(issueIds) {
|
|
||||||
return await getIssuesByList(issueIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getIssueIdsForUpdate(newIssues) {
|
|
||||||
if (config.forceUpdate) {
|
|
||||||
return newIssues.map((i) => i.id);
|
|
||||||
}
|
|
||||||
const existsIssue = await loadFromEventEmitter(newIssues.map((i) => i.id));
|
|
||||||
const existsIssueMap = existsIssue.reduce((acc, issue) => {
|
|
||||||
acc[issue.id] = issue;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const newIds = newIssues
|
|
||||||
.filter((newIssue) => {
|
|
||||||
const existsIssue = existsIssueMap[newIssue.id];
|
|
||||||
if (!existsIssue) return true;
|
|
||||||
const newUpdatedAt = DateTime.fromFormat(
|
|
||||||
newIssue[config.updatedAtFieldName],
|
|
||||||
config.dateTimeFormat,
|
|
||||||
);
|
|
||||||
const existsUpdatedTimestamp = DateTime.fromMillis(
|
|
||||||
existsIssue.timestamp__,
|
|
||||||
);
|
|
||||||
if (newUpdatedAt.diff(existsUpdatedTimestamp).as('seconds') > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.map((i) => i.id);
|
|
||||||
return newIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const issues = await loadAllCsv();
|
|
||||||
const idsForUpdate = await getIssueIdsForUpdate(issues);
|
|
||||||
|
|
||||||
console.log(`Из csv получено задач - ${issues.length} шт.`);
|
|
||||||
console.log(`Нужно обновить задач - ${idsForUpdate.length} шт.`);
|
|
||||||
console.log(`Задачи для обновления - ` + JSON.stringify(idsForUpdate));
|
|
||||||
|
|
||||||
async function continueQuestion() {
|
|
||||||
if (args.values.yes) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const readline = createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
const answer = await readline.question(`\nПродолжить? (y/n)\n> `);
|
|
||||||
const res = answer == 'y';
|
|
||||||
readline.close();
|
|
||||||
console.log(res ? `\nПродолжаем...` : '\nНу и мне тогда это не надо');
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.values.test) {
|
|
||||||
const answer = await continueQuestion();
|
|
||||||
if (answer) {
|
|
||||||
console.log(`Отправка задачи на синхронизацию...`);
|
|
||||||
await fetch(
|
|
||||||
`${config.eventEmitterUrl}/redmine-event-emitter/append-issues`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(idsForUpdate),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log(`Синхронизация запущена`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import { parse } from 'jsonc-parser';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { parseArgs } from 'util';
|
|
||||||
import { createInterface } from 'readline/promises';
|
|
||||||
|
|
||||||
const helpMsg = `
|
|
||||||
Синхронизация суммирующих задач
|
|
||||||
|
|
||||||
* --config=sync-root-issues.jsonc - конфигурационный файл для постановки задачи на выполнение синхронизации
|
|
||||||
* --yes - автоматически отвечать 'y'
|
|
||||||
|
|
||||||
Пример конфигурационного файла:
|
|
||||||
|
|
||||||
{
|
|
||||||
"redmineUrl": "http://redmine.my-company.local",
|
|
||||||
"redmineToken": "... ... ...",
|
|
||||||
"eventEmitterUrl": "http://localhost:3000",
|
|
||||||
|
|
||||||
"rootIds": [
|
|
||||||
123001,
|
|
||||||
130012
|
|
||||||
]
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
var args = parseArgs({
|
|
||||||
options: {
|
|
||||||
config: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
yes: {
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!args?.values?.config) {
|
|
||||||
console.log(helpMsg);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfig(fileName) {
|
|
||||||
const rawData = readFileSync(fileName, { encoding: 'utf8' });
|
|
||||||
return parse(rawData);
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = readConfig(args.values.config);
|
|
||||||
|
|
||||||
function getAllRootIssueIds(issues, res) {
|
|
||||||
for (let i = 0; i < issues.length; i++) {
|
|
||||||
const issue = issues[i];
|
|
||||||
if (issue.children && issue.children.length > 0) {
|
|
||||||
res.push(issue.id);
|
|
||||||
getAllRootIssueIds(issue.children, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadIssueFromRedmine(issueId) {
|
|
||||||
const token = config?.redmineToken || '';
|
|
||||||
const redmineUrl = config?.redmineUrl || '';
|
|
||||||
const url = `${redmineUrl}/issues/${issueId}.json?include=children`;
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'X-Redmine-API-Key': token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
return data?.issue || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllRootIssueIds() {
|
|
||||||
const rootIssueIds = config?.rootIds || [];
|
|
||||||
const rootIssues = [];
|
|
||||||
for (let i = 0; i < rootIssueIds.length; i++) {
|
|
||||||
const rootIssueId = rootIssueIds[i];
|
|
||||||
const issue = await loadIssueFromRedmine(rootIssueId);
|
|
||||||
if (issue) {
|
|
||||||
rootIssues.push(issue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allRootIssueIds = getAllRootIssueIds(rootIssues, []);
|
|
||||||
return allRootIssueIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function questionYesOrNo(question, yesMsg, noMsg) {
|
|
||||||
const readline = createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
const answer = await readline.question(`\n${question} (y/n)\n> `);
|
|
||||||
const res = answer == 'y';
|
|
||||||
readline.close();
|
|
||||||
console.log(res ? `\n${yesMsg}` : `\n${noMsg}`);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log(
|
|
||||||
`Конфигурационный файл:\n${JSON.stringify(config, null, ' ')}\n`,
|
|
||||||
);
|
|
||||||
if (!args.values.yes) {
|
|
||||||
const res = await questionYesOrNo(
|
|
||||||
'Продолжаем?',
|
|
||||||
'Продолжаем...',
|
|
||||||
'Прервано',
|
|
||||||
);
|
|
||||||
if (!res) process.exit();
|
|
||||||
}
|
|
||||||
const rootIds = await loadAllRootIssueIds();
|
|
||||||
console.log(`Задачи для синхронизации - ${JSON.stringify(rootIds)}`);
|
|
||||||
if (!args.values.yes) {
|
|
||||||
const res = await questionYesOrNo(
|
|
||||||
'Продолжаем?',
|
|
||||||
'Продолжаем...',
|
|
||||||
'Прервано',
|
|
||||||
);
|
|
||||||
if (!res) process.exit();
|
|
||||||
}
|
|
||||||
console.log(`Отправка задач на синхронизацию...`);
|
|
||||||
await await fetch(
|
|
||||||
`${config.eventEmitterUrl}/redmine-event-emitter/append-issues`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(rootIds),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log(`Синхронизация запущена`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||