До списку уроків
10 клас · Урок 9 з 16

Скрипт звіту report.sh: агрегація даних і дашборд

виконано0%
Крок 1

Що робимо сьогодні

У ~/data/leads.jsonl уже лежать справжні заявки з твоєї форми. Сьогодні вони перетворюються на дашборд — і головна робота не в тому, щоб порахувати, а в тому, щоб порахувати й нічого не винести назовні.

Чого навчишся за цей урок

Слова «агрегація», «атомарний запис» та «ідемпотентність» поки нічого не означають. Розберемо їх по черзі, кожне на своєму місці.

З теми 2 у тебе вже є приймач. Він дописує рядок у ~/data/leads.jsonl щоразу, коли хтось заповнює форму. Файл лежить поза ~/www/. Саме тому за посиланням його ніхто не відкриє.

Сьогодні ти вперше береш дані з приватної частини й кладеш результат у публічну. Межу між ними проводиш ти сам. Скрипт ~/bin/report.sh читає файл з іменами й адресами, а віддає report.json із самими числами.

Кожен зайвий ключ у цьому JSON бачить будь-хто у світі. Тому за кожне поле звіту доводиться відповідати окремо.

Що поїде на сервер

ФайлПраваЩо в ньому
~/bin/report.sh700 твій скрипт: читає ~/data/leads.jsonl, пише report.json. По FTP кладеш у ~/upload/, далі по SSH переносиш у ~/bin/ і ставиш права
~/www/dash/index.html644 дашборд: діаграма, числа з report.json, час останнього оновлення, твій коментар до цифр
~/www/dash/report.json644 генерує сам скрипт. По FTP не заливаєш ніколи — інакше числа на сторінці розійдуться з даними
~/data/leads.jsonl600 джерело. Лишається там, де лежало. На сервер сьогодні не заливається й у ~/www/ не потрапляє
Тека зветься за темою, не за номером уроку Дашборд живе в ~/www/dash/, і наступного уроку ти працюватимеш у ній же. Не шукай lesson-09: тема одна, уроків два.
Що на сервер не їде Ніяких знімків екрана, картинок дашборду й експортів у PDF. Діаграму малює твій код у SVG, а дані для неї — це report.json вагою в кілька сотень байтів. Усе, що важче за текст, учитель дивиться очно.

Хід уроку

ЕтапХвЩо робиш
Межа приватного й публічного15 розбираєш шлях даних і вирішуєш, де саме проходить лінія
Читання JSONL у bash25 jq, sort | uniq -c, awk; звіряєш кожне число руками
Каркас report.sh25 set -euo pipefail, абсолютні шляхи, блоки скрипта
Мінімізація й агрегація20 конструктор звіту: що потрапляє в публічний JSON, а що ні
Права на скрипт і дані15 аудит прав: що зламається й що витече при кожній комбінації
Атомарний запис та ідемпотентність15 тимчасовий файл і mv; два запуски — один результат
Дашборд і автоперевірка5 сторінка читає report.json, чеклист до зеленого
Крок 2

Одна межа, яку сьогодні проводиш ти

Дані для звіту приватні. Сам звіт публічний. Між ними — рівно один файл, який ти пишеш власноруч

На уроці 3 ти вже клав leads.jsonl поза ~/www/. Тоді за все відповідав сервер: чого немає у веб-корені, того для браузера не існує.

Сьогодні захист інший — твій власний код. Скрипт має право читати приватний файл. Він же має право писати в публічну теку. Що саме перетече через нього, вирішує вже не сервер, а ти.

Шлях даних від приватного JSONL до публічного дашборду Ліворуч приватна зона: leads.jsonl з правами 600 і report.sh з правами 700. Праворуч публічна зона: report.json і index.html з правами 644, які читає будь-який відвідувач. Між зонами пунктирна межа, через яку проходить лише одна стрілка — з самими числами. Приватна частина акаунта Публічна частина ~/data/leads.jsonl права 600 · 47 рядків {"ts":"…09:14","name":"Оля", "email":"olia@…","class":"10A"} ~/bin/report.sh права 700 рахує 5 чисел і жодного не іменує ~/www/dash/report.json права 644 {"total":47,"unique":43, "by_class":{"10A":22,…}} ~/www/dash/index.html читає report.json і малює діаграму тільки числа Пунктир — не налаштування сервера: цю межу тримає твій скрипт. Будь-який відвідувач бачить усе, що лежить праворуч Для відвідувача лівої половини не існує: теки ~/data і ~/bin поза веб-коренем.
Числа 47, 43 і 22 тут і далі на сторінці — з одного демонстраційного набору заявок, який живе в цій сторінці. У тебе будуть свої.

Запис і агрегат — різні речі

Уяви класний журнал. Один рядок у ньому — це одна людина: прізвище, оцінки, пропуски. А середній бал класу — уже не про людину. Він про всіх разом, і за ним нікого не впізнати.

З нашим файлом так само. Рядок leads.jsonl стосується однієї людини: там ім'я, адреса, текст повідомлення. Число "10A": 22 не стосується нікого окремо.

Перетворення записів на такі спільні числа зветься агрегацією. Саме тут і проходить межа між приватним і публічним.

ЗаписАгрегат
Про когопро конкретну людинупро групу
Приклад{"name":"Оля","email":"olia@…"} {"10A": 22}
Де живе~/data/, права 600 ~/www/dash/, права 644
Що дає стороннімадресу для листа, привід зателефонувати нічого, поки група достатньо велика
Чи можна відкотити ні: з числа 22 не дістати 22 імені
Питання

Однокласник зробив дашборд швидше за всіх: скрипта в нього немає взагалі. Він скопіював leads.jsonl у ~/www/dash/, і index.html читає файл через fetch та рахує суми просто в браузері. Сторінка працює, числа правильні. Що з цим не так?

Крок 3

Читаємо JSONL із bash: чотири інструменти

Кожен рядок файлу — окремий валідний JSON. Саме тому звіт можна порахувати командами, а не програмою

Формат JSON Lines зроблений так, щоб його можна було читати рядок за рядком і дописувати в кінець без переписування файлу. Одна заявка — один рядок, розділювач — \n. Ніяких ком між об'єктами і жодних квадратних дужок навколо.

~/data/leads.jsonl · три перші рядки
{"ts":"2026-08-30T09:14:07+00:00","name":"Оля","email":"olia@example.com","class":"10A","message":"Хочу в гурток робототехніки"}
{"ts":"2026-08-30T09:16:31+00:00","name":"Тарас","email":"taras@example.com","class":"10B","message":""}
{"ts":"2026-08-30T09:22:04+00:00","name":"Ніна","email":"nina@example.com","class":"10A","message":"Чи потрібен свій ноутбук?"}

1. Скільки всього: wc -l і його пастка

ssh · рахуємо рядки
$ wc -l < ~/data/leads.jsonl 47

wc -l рахує не рядки, а символи переводу рядка. Різниця вилазить на останньому записі. Приймач міг упасти на півдорозі й не дописати \n у кінці.

Тоді запис на диску є, а перевід рядка після нього — ні. Ти побачиш 46 замість 47 і нічого не запідозриш. Команда, яка рахує чесно:

ssh · рахуємо рядки без пастки
$ grep -c '' ~/data/leads.jsonl 47

Порожній шаблон '' збігається з будь-яким рядком, а grep рахує саме рядки, а не переводи. Різниця між двома командами — це і є твій зіпсований останній запис.

Питання

На одному й тому самому файлі wc -l каже 46, а grep -c '' — 47. Ти відкриваєш файл і рахуєш очима: записів справді 47, усі цілі. Обидві команди відпрацювали без помилок. Що не так із файлом?

2. Дістати поле: jq

jq — це grep для JSON. Ключ -r віддає рядок без лапок, щоб його можна було передати далі по конвеєру.

ssh · одна колонка з усіх записів
$ jq -r '.class' ~/data/leads.jsonl | head -3 10A 10B 10A

3. Порахувати повторення: sort | uniq -c

uniq склеює тільки сусідні однакові рядки. Без sort перед ним ти отримаєш той самий клас у кількох групах.

ssh · розподіл по класах
$ jq -r '.class' ~/data/leads.jsonl | sort | uniq -c 22 10A 21 10B 4 10C

Це та сама команда, якою ти звіряєш звіт руками. У скрипт іде інша — та, що одразу дає готовий JSON:

ssh · те саме, але вже об'єктом
$ jq -s 'group_by(.class) | map({(.[0].class): length}) | add' ~/data/leads.jsonl { "10A": 22, "10B": 21, "10C": 4 }

Ця команда робить чотири кроки поспіль. Ключ -s (від slurp, «сьорбнути») читає весь файл одразу й складає рядки в один масив. Без нього jq обробляв би кожен рядок окремо й не зміг би їх порівняти.

Далі group_by розкладає масив на купки за значенням поля. Усі заявки з 10A — в одну купку, з 10B — в іншу. Порахувати їх поки ніхто не просив.

Потім map({(.[0].class): length}) проходить по кожній купці й лишає від неї два значення: назву класу і length — скільки в ній заявок. Самі заявки на цьому кроці зникають.

Нарешті add зсипає ці дрібні об'єкти в один. Саме він і поїде у звіт.

Порожнє поле мовчки стає групою Якщо в записі немає ключа class, jq віддасть null — і в звіті з'явиться цілком серйозна група "null": 3. Це не помилка jq: він чесно показав те, що є в даних. Або відсівай такі записи через select(.class != null), або зводь їх у групу "не вказано" — але свідомо.

4. Порахувати число: awk

Коли потрібна сума або середнє, а не кількість, рахує awk. Він читає потік рядок за рядком, накопичує в змінних і друкує підсумок у блоці END.

ssh · середня довжина повідомлення
$ jq -r '.message | length' ~/data/leads.jsonl | awk '{s+=$1; n++} END {printf "%d записів, середня довжина %.1f\n", n, s/n}' 47 записів, середня довжина 96.4

Зверни увагу: жоден символ самого повідомлення сюди не потрапив. Лишилася тільки його довжина. Ось як виглядає агрегація на практиці: текст стає числом якнайраніше, ще всередині конвеєра.

Три записи для перевірки руками Перш ніж вірити будь-якому числу зі скрипта, зроби те саме на трьох рядках: head -3 ~/data/leads.jsonl | jq -r '.class' | sort | uniq -c. Якщо на трьох рядках результат очевидно правильний, а на 47 — дивний, проблема в даних, а не в команді.
Питання

Ти рахуєш унікальних відправників командою jq -r '.email' ~/data/leads.jsonl | uniq | wc -l і отримуєш 47 — рівно стільки, скільки всього рядків. Однокласниця з тим самим файлом отримала 43. Хто помилився і в чому?

Крок 4

set -euo pipefail: три літери проти тихих поломок

За замовчуванням bash не зупиняється на помилці. Він іде далі й доводить справу до кінця — навіть якщо всі числа вже нулі

Скрипт без цього рядка поводиться так. Команда впала, він написав щось у stderr і перейшов до наступної. Так само з другою, третьою і п'ятою.

На виході ти маєш report.json, де все на місці, крім змісту. Дашборд покаже нулі. А ти шукатимеш помилку в дашборді — і не знайдеш, бо він ні до чого.

ПрапорецьЩо робитьЩо ловить у нашому скрипті
-e зупиняє скрипт на першій команді, що завершилась не нулем jq подавився зіпсованим рядком — скрипт зупиняється, а не пише в звіт половину чисел
-u звертання до невизначеної змінної — помилка, а не порожній рядок описався в OUT_DIROUTDIR: без -u це порожній рядок, і mv цілиться в /report.json
-o pipefail конвеєр повертає код найправішої команди, що впала, а не останньої jq … | sort | uniq -c: jq упав, uniq відпрацював на порожньому вводі й повернув 0 — без pipefail помилки просто немає
Класична пастка -e: grep -c Команда grep -c '' на порожньому файлі друкує 0 і завершується кодом 1 — «нічого не знайдено». Це не помилка, але set -e сприймає її саме так і вбиває скрипт. Тому в рядку підрахунку дописують || true: явно кажемо, що нуль записів — теж результат.
Питання

Скрипт відпрацював, у консоль нічого не написав, повернув код 0. У report.json свіжа дата й усі метрики по нулях, хоч у leads.jsonl 47 рядків. Який рядок ти шукаєш першим?

Скрипт по блоках

Сім блоків скрипта report.sh і що ламається без кожного Вертикальна послідовність блоків: запобіжники, абсолютні шляхи, перевірки оточення, агрегація, складання JSON у тимчасовий файл, права й атомарна підміна, повідомлення та код виходу. Праворуч від кожного — наслідок його відсутності. Блок скрипта Що ламається, якщо його немає 1 · шебанг і set -euo pipefail 2 · абсолютні шляхи в змінних 3 · перевірки: jq є, файл читається 4 · агрегація: шість чисел 5 · jq -n складає JSON у тимчасовий файл 6 · chmod 644, потім mv 7 · повідомлення і код виходу три запобіжники в одному рядку SRC, OUT_DIR, OUT — усі від $HOME і виходи з різними кодами жодного імені й жодної адреси тимчасовий файл лежить поруч із робочим одна атомарна дія замість двох щоб cron знав, чим усе скінчилось помилка посеред скрипта не зупиняє його, і в звіт їдуть напівпораховані числа з cron поточна тека інша — скрипт не знайде data/leads.jsonl і впаде на рівному місці немає jq — усі метрики тихо стають нулями, і дашборд показує «0 заявок» як факт саме сюди найлегше випадково дописати масив імен: одна зайва змінна — і це витік склеювання JSON рядками ламає лапки в іменах і апострофи на кшталт О'Коннор сторінка ловить напівзаписаний файл; а без chmod звіт лишається 600 і дає 403 наступного уроку cron не відрізнить успішний запуск від провального
Порядок не декоративний: перевірки стоять до першого підрахунку, а chmod — до mv, а не після.

Каркас, який ти дописуєш під свої дані

Цей код можна згенерувати з Antigravity — але вчитель попросить пояснити будь-які три рядки. Тому читай зараз, а не на перевірці.

~/bin/report.sh · повний каркас
#!/usr/bin/env bash
# ~/bin/report.sh — рахує числа з приватних даних і кладе їх у публічну теку
set -euo pipefail

# 1. шляхи тільки абсолютні: з cron поточна тека буде іншою
SRC="$HOME/data/leads.jsonl"
OUT_DIR="$HOME/www/dash"
OUT="$OUT_DIR/report.json"

# 2. перевірки до першого підрахунку, кожна зі своїм кодом виходу
command -v jq >/dev/null || { echo "report.sh: немає jq" >&2; exit 3; }
[ -r "$SRC" ]            || { echo "report.sh: не читається $SRC" >&2; exit 4; }
mkdir -p "$OUT_DIR"

# 3. агрегація: у змінних лише числа, жодного імені й жодної адреси
total=$(grep -c '' "$SRC" || true)
unique_people=$(jq -r '.email' "$SRC" | sort -u | wc -l)
with_message=$(jq -s '[.[] | select((.message // "") != "")] | length' "$SRC")
avg_msg_len=$(jq -r '.message | length' "$SRC" \
              | awk '{s+=$1; n++} END {printf "%.1f", (n ? s/n : 0)}')
by_class=$(jq -s 'group_by(.class)
                  | map({(.[0].class // "не вказано"): length}) | add' "$SRC")
by_day=$(jq -s 'map(.ts[0:10]) | group_by(.)
                | map({(.[0]): length}) | add' "$SRC")

# 4. збираємо JSON у тимчасовий файл ПОРУЧ із робочим
TMP=$(mktemp "$OUT_DIR/.report.XXXXXX")
trap 'rm -f "$TMP"' EXIT

jq -n \
  --arg     generated_at  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --argjson total         "$total" \
  --argjson unique_people "$unique_people" \
  --argjson with_message  "$with_message" \
  --argjson avg_msg_len   "$avg_msg_len" \
  --argjson by_class      "$by_class" \
  --argjson by_day        "$by_day" \
  '{generated_at:$generated_at, total:$total, unique_people:$unique_people,
    with_message:$with_message, avg_msg_len:$avg_msg_len,
    by_class:$by_class, by_day:$by_day}' > "$TMP"

# 5. права ДО підміни, інакше сторінка секунду віддає 403
chmod 644 "$TMP"
mv "$TMP" "$OUT"
trap - EXIT

echo "report.sh: $total записів → $OUT"
Чому jq -n, а не echo з рядком jq -n будує JSON із параметрів і сам ставить лапки, коми й екранування. Склеювання рядків руками працює рівно доти, доки в даних немає лапки, апострофа чи перенесення рядка. Щойно вони трапляться, report.json стане невалідним, і дашборд покаже помилку розбору. Ключ --arg передає значення як рядок. А --argjson передає його як готовий JSON: число або цілий об'єкт.
Дрібниця, яка ламає скрипт на macOS У macOS wc -l друкує число з відступом (" 43"). --argjson це ще проковтне: пробіли в JSON дозволені. А от порівняння рядків уже ні. На сервері (Linux, GNU coreutils) відступу немає. Якщо пишеш і тестуєш скрипт на своєму Mac, додай | tr -d ' ' у кінець конвеєра.
Питання

У класі скрипт працює бездоганно: заходиш по SSH, набираєш ./report.sh — звіт оновлюється. Наступного тижня той самий скрипт ставлять на розклад, і щоночі він падає з «leads.jsonl: No such file or directory». Файл на місці, права не мінялися. Що в скрипті написано неправильно?

Крок 5

Мінімізація: у звіт іде тільки те, без чого звіту немає

Правило перевірки одне: чи не соромно вивести цей рядок report.json на проєктор перед усією школою

Це не фігура мови. report.json лежить у веб-корені з правами 644. Його адресу відкриє з телефона будь-хто, хто її знає.

А знати її неважко: посилання на файл стоїть прямо в коді дашборду. Тож публічний звіт і публічний проєктор — це буквально одне й те саме.

Що є в данихУ публічний звітЧому
nameні ідентифікує людину прямо; жодна метрика від імені не залежить
emailні ідентифікує людину й одразу дає канал для спаму
messageні текст, написаний для тебе, а не для всіх; у ньому бувають телефони, адреси, чужі імена
кількість записівтак число про групу, а не про людину
кількість унікальних адрестак саме кількість: жодної адреси в звіті не з'являється
розподіл по класахтак, з порогом поки в групі достатньо людей — число нікого не називає
середня довжина повідомленнятак з числа 96.4 не відновити жодного слова
ts з точністю до днятак дата — так; точний час до секунди разом із класом уже звужує коло до однієї людини

Агрегація — дорога в один бік

Агрегація незворотна: із записів виходять числа, з чисел записи — ні Ліворуч записи з іменами й класами, праворуч об'єкт із кількостями по класах. Стрілка вправо підписана group_by і length. Зворотна стрілка перекреслена: з числа 22 не дістати 22 імені. Знизу застереження: група з однієї людини знову називає людину. У файлі — записи про людей У звіті — числа про групи {"name":"Оля", "class":"10A"} {"name":"Тарас","class":"10B"} {"name":"Ніна", "class":"10A"} {"name":"Марко","class":"10A"} …і ще 43 такі самі рядки кожен рядок — конкретна людина { "10A": 22, "10B": 21, "10C": 4 } жодного рядка про конкретну людину group_by(.class) | length З «10A: 22» не дістати 22 імені. Це не шифрування й не пароль: інформації про людей у числі просто більше немає. Але маленька група — це знову людина «10C: 1» у школі, де всі знають одне одного, називає конкретного учня; тому групи, менші за поріг, не показують окремо, а зводять у «інші».
Це і є вся причина, чому дашборд читає report.json, а не leads.jsonl: у звіті просто немає чого викрадати.
Питання

Учень додав у report.json ключ "names": ["Оля","Тарас","Ніна", …] — «щоб на дашборді було видно, хто вже записався, це ж просто імена, без адрес». Сторінку відкрив лише вчитель. У чому проблема, якщо адрес там справді немає?

Поріг: від скількох людей можна показувати групу

Магічної цифри тут немає. Статистичні служби публікують таблиці про людей щодня. Клітинку, у якій менше 5 або 10 спостережень, вони зазвичай не показують.

Замість неї ставлять зведений рядок або прочерк. Логіка проста: чим менша група, тим легше вгадати, хто саме в ній.

У нашому випадку «додаткові знання» — це вся школа. Якщо в звіті написано "10C": 1, то будь-хто з 10C, хто чув розмову на перерві, знає прізвище. Тому в скрипті ставлять поріг:

report.sh · поріг у 5 осіб
# групи від 5 осіб показуємо як є, решту зводимо в «інші»
by_class=$(jq -s --argjson min 5 '
  group_by(.class)
  | (map(select(length >= $min) | {(.[0].class): length}) | add // {}) as $big
  | (map(select(length <  $min) | length) | add // 0)              as $small
  | $big + (if $small > 0 then {"інші": $small} else {} end)
' "$SRC")

На нашому наборі {"10A":22,"10B":21,"10C":4} стає {"10A":22,"10B":21,"інші":4}. Сума не змінилася: 47 лишається 47. Зник тільки рядок, який називав чотирьох людей з маленького класу.

Оператор // у коді страхує від порожнечі. Якщо великих груп немає взагалі, add поверне null. Тоді // {} підставить порожній об'єкт, і скрипт не впаде.

Питання

Дашборд показує розподіл по класах із порогом 5. У понеділок там було {"10A":22,"10B":21,"інші":4}, у вівторок — {"10A":22,"10B":21,"10C":5}. Хтось, хто дивиться дашборд щодня, тепер знає щось нове про конкретну людину. Що саме?

Крок 6

Конструктор звіту

Обираєш показники — сторінка пише за тебе bash і одразу показує, який report.json із цього вийде і що з нього побачить сторонній

Набір даних тут демонстраційний і зашитий у сторінку: 47 заявок, 43 різні адреси, класи 10A / 10B / 10C. Код, який видає конструктор, — справжній: його можна вставити у свій report.sh і замінити назви полів на свої.

Що кладемо в report.json

total · рахує grep -c ''
unique_people · jq дістає адреси, sort -u прибирає повтори, у звіт іде тільки кількість
by_class · тут працює поріг малих груп
by_day · дата з ts, обрізана до дня
with_message · саме кількість, не самі повідомлення
avg_msg_len · рахує awk по довжинах
«хай на дашборді буде видно, хто вже записався» — увімкни й подивись, що з цього виходить
Поріг малих груп
фрагмент report.sh · оновлюється на льоту

  
Що ляже в ~/www/dash/report.json


  
Питання

Учениця поклала список імен у report.json. Посилання на цей файл немає ніде: сторінка підтягує його скриптом, а адресу вона нікому не давала. Через два тижні файл знаходять пошуком у Google. Як це сталося?

Крок 7

Чому report.sh — це 700

У скрипті немає паролів. У ньому є дещо цінніше — карта: де лежать дані, як вони називаються і що в них усередині

Твій сервер не належить тобі одному. Це гуртожиток: у теці /home/ лежать десятки чужих домашніх тек. Усі ці люди залогінені на ту саму машину, що й ти.

Права 755 означають одне: кожен із них прочитає твій файл. Не через інтернет, а просто командою cat, не встаючи зі свого місця.

ВісімковеЛітерамиХто що може
700rwx------ власник читає, пише, запускає. Решта — нічого
750rwxr-x--- плюс група: читає й запускає
755rwxr-xr-x плюс усі інші користувачі сервера: читають і запускають
600rw------- власник читає й пише, але не запускає: немає біта x
777rwxrwxrwx будь-хто на сервері може переписати твій скрипт
Що бачить інший користувач сервера при правах 700 і 755 Ліворуч скрипт із правами 700: команда cat повертає Permission denied, сторонній знає лише, що файл існує. Праворуч права 755: видно шлях до приватних даних, назви полів і структуру файлу. chmod 700 — так має бути chmod 755 — так роблять «щоб працювало» taras@srv:~$ ls -l ~olia/bin/report.sh -rwx------ 1 olia olia 1284 report.sh taras@srv:~$ cat ~olia/bin/report.sh cat: report.sh: Permission denied Що дізнався сторонній що файл існує і чий він. Більше нічого. taras@srv:~$ ls -l ~olia/bin/report.sh -rwxr-xr-x 1 olia olia 1284 report.sh taras@srv:~$ cat ~olia/bin/report.sh SRC="$HOME/data/leads.jsonl" jq -r '.email' "$SRC" | sort -u jq -s 'group_by(.class)' "$SRC" Що дізнався сторонній точний шлях до приватних даних, назви полів: email, class, message, і що файл — звичайний текст. Права на скрипт не дають прав на дані: leads.jsonl усе одно лишається 600. Вони дають карту — куди йти першим, якщо десь з'явиться будь-яка інша дірка.
Це не гіпотеза про зловмисника. Це те, що бачить будь-який однокласник із сусіднього акаунта, набравши одну команду.

Ставимо й перевіряємо

ssh · права на скрипт
$ mkdir -p ~/bin $ mv ~/upload/report.sh ~/bin/report.sh $ chmod 700 ~/bin/report.sh $ stat -c %a ~/bin/report.sh 700 $ ls -l ~/bin/report.sh -rwx------ 1 ТВІЙ-ЛОГІН ТВІЙ-ЛОГІН 1284 вер 2 10:04 /home/ТВІЙ-ЛОГІН/bin/report.sh
Чому саме через ~/upload/ FTP-клієнт часто ставить свої права на щойно залитий файл — типово 644 або навіть 664. Тому файл спершу приземляється в ~/upload/, а вже по SSH ти переносиш його в ~/bin/ і сам призначаєш права. Заливати одразу в ~/bin/ і сподіватися на FTP-клієнта — найчастіша причина, чому критерій script_perms червоний.
Точно про біт x Скрипт без права на виконання все одно можна запустити: bash ~/bin/report.sh працює. Запускається тут bash, а сам скрипт для нього — просто текстовий файл на вхід. А от ./report.sh і cron без біта x не працюють. Тому 700, а не 600: перша цифра має бути 7, і саме її перевіряє критерій.
Що взагалі не має потрапити у файл У ~/bin/ не має бути жодного значення з ~/.env: ні токена вебхука, ні пароля. Якщо скрипту колись знадобиться секрет — він читає його з ~/.env у момент запуску, а не носить у собі. Це блокуючий критерій no_secret_in_script, і перевіряється він буквально пошуком значення токена у файлах ~/bin/.
Питання

Ти виставив на скрипт права 600 — «щоб уже точно ніхто не прочитав». bash ~/bin/report.sh відпрацьовує, звіт свіжий, усе гаразд. Наступного уроку ти ставиш той самий скрипт у cron, і він мовчить щоночі. Чого не вистачає?

Крок 8

Аудит прав: що зламається і що витече

Нижче — шість файлів твого проєкту в тому стані, у якому їх зазвичай лишає FTP-клієнт. Постав кожному права й дивись на наслідок

Помилки тут бувають двох протилежних сортів. Занадто суворі права ламають те, що мало працювати: веб-сервер не читає звіт, cron не запускає скрипт.

Занадто вільні віддають чужим те, що мало лишитися твоїм. Тому правильний набір — не «якнайменше», а «рівно стільки, скільки потрібно кожному учаснику».

Файли проєкту

ФайлПраваНаслідок
Чому веб-серверу потрібні саме 644 Сторінку й report.json віддає не ти, а процес веб-сервера — це окремий користувач системи, і для нього ти «інші». Тому останній розряд у публічних файлах — 4: читати можна, писати не можна. Це, до речі, і відповідь на питання «чому не 600»: 600 означає «навіть веб-сервер не читає», і сторінка чесно віддає 403.
ssh · привести все до ладу однією серією
$ chmod 700 ~/bin/report.sh $ chmod 700 ~/data $ chmod 600 ~/data/leads.jsonl $ chmod 600 ~/.env $ chmod 644 ~/www/dash/index.html ~/www/dash/report.json $ stat -c '%a %n' ~/bin/report.sh ~/data ~/data/leads.jsonl ~/.env ~/www/dash/* 700 /home/ТВІЙ-ЛОГІН/bin/report.sh 700 /home/ТВІЙ-ЛОГІН/data 600 /home/ТВІЙ-ЛОГІН/data/leads.jsonl 600 /home/ТВІЙ-ЛОГІН/.env 644 /home/ТВІЙ-ЛОГІН/www/dash/index.html 644 /home/ТВІЙ-ЛОГІН/www/dash/report.json
stat на macOS пишеться інакше На сервері (Linux) — stat -c %a файл. На macOS той самий результат дає stat -f %A файл. Скопіював команду з уроку в термінал свого Mac і бачиш «illegal option -- c»? Річ саме в цьому, а не в правах.
Питання

Дашборд відкривається, діаграма на місці, а замість чисел — прочерки. У консолі браузера: GET /dash/report.json 403 (Forbidden). Скрипт при цьому відпрацював без жодної помилки й щойно оновив файл. Що сталося і в якому рядку скрипта це виправляти?

Крок 9

Атомарний запис: тимчасовий файл і mv

Поки твій скрипт пише звіт, дашборд може його читати. Це не гіпотеза — це кожен запуск

Уяви, що стираєш дошку й пишеш на ній нове оголошення. Хвилину дошка напівпорожня. Хто зайде саме тоді, прочитає половину фрази й піде.

Команда jq … > report.json робить рівно те саме. Оболонка спершу обнуляє файл, а потім наповнює його по частинах. Між цими двома моментами на диску лежить обрізаний JSON.

Хто відкриє дашборд у цю мить, отримає помилку розбору. Виглядає це як випадковий глюк, а насправді трапляється при кожному запуску.

Прямий запис у файл проти запису через тимчасовий файл і mv Угорі прямий запис: файл обнуляється, наповнюється частинами, і читач у середині цього вікна бачить обірваний JSON. Унизу атомарний запис: усе пишеться в тимчасовий файл, а mv перемикає ім'я однією дією, тому читач завжди бачить цілий файл — старий або новий. Прямий запис: jq … > report.json 0.000 с 0.008 с у цю саму мить 0.031 с > report.json записано 200 байт браузер робить fetch запис завершено файл обнулено JSON обірваний SyntaxError у консолі знову валідний Атомарний запис: jq … > тимчасовий; потім mv 0.000 с 0.031 с 0.031 с будь-коли > .report.ab12cd тимчасовий готовий mv: одна дія ядра браузер читає звіт іще старий звіт іще старий ім'я перемкнулось цілий файл — завжди Вікно у 30 мілісекунд трапляється рідко — тому таку помилку шукають годинами: сторінка «зламалась і сама полагодилась», відтворити не виходить.
Час на схемі ілюстративний: реальна тривалість залежить від розміру файлу й диска. Важлива не величина вікна, а те, що воно існує.

Чому mv — це одна дія

Тому оголошення пишуть на аркуші за столом, а на дошку вішають уже готовим. Обмін займає секунду, і напівпорожньої дошки ніхто не бачить. Це і зветься атомарним записом: дія або сталася повністю, або не сталася зовсім.

Перейменування файлу в межах однієї файлової системи — це системний виклик rename(). Ядро гарантує ось що. У будь-яку мить ім'я report.json веде або на старий файл цілком, або на новий цілком. Стану «наполовину» не існує в принципі.

Тимчасовий файл — поруч, а не в /tmp Гарантія працює тільки в межах однієї файлової системи. Тека /tmp часто лежить на іншому розділі. Поклади тимчасовий файл туди — і mv перетвориться на «скопіювати й видалити». Це знову поступовий запис із тим самим вікном. Тому в скрипті стоїть mktemp "$OUT_DIR/.report.XXXXXX": тимчасовий файл народжується в тій самій теці, куди й ляже.
Крапка на початку імені й trap Ім'я починається з крапки, щоб недописаний файл не потрапив у списки й не привертав уваги. А trap 'rm -f "$TMP"' EXIT прибирає його, якщо скрипт упав на півдорозі. Без цього рядка в теці дашборду накопичуються шматки звітів — по одному після кожної невдачі.

Ідемпотентність: два запуски — один результат

Запусти скрипт двічі, нічого не міняючи в даних. Звіт має вийти той самий, до останнього числа. Якщо так — скрипт ідемпотентний.

Найчастіші причини, чому цього не стається:

Перевірка займає три команди. Поле generated_at міняється завжди — його з порівняння прибирають:

ssh · перевірка ідемпотентності
$ ~/bin/report.sh && jq -S 'del(.generated_at)' ~/www/dash/report.json > ~/a.json report.sh: 47 записів → /home/ТВІЙ-ЛОГІН/www/dash/report.json $ ~/bin/report.sh && jq -S 'del(.generated_at)' ~/www/dash/report.json > ~/b.json report.sh: 47 записів → /home/ТВІЙ-ЛОГІН/www/dash/report.json $ diff ~/a.json ~/b.json && echo "ідемпотентно" && rm ~/a.json ~/b.json ідемпотентно

jq -S сортує ключі, щоб diff не спрацював на різному порядку полів. Порожній вивід diff і слово «ідемпотентно» — це і є критерій idempotent.

Питання

Дашборд відкривається нормально. Але приблизно раз на двадцять перезавантажень показує «помилка розбору JSON». Натискаєш оновлення ще раз — усе вже гаразд. Однокласник каже: «глюк браузера, не зважай». Що насправді відбувається?

Питання

Учень запустив скрипт тричі поспіль, нічого не змінюючи в даних. total у звіті: 47, потім 94, потім 141. Що він написав у скрипті?

Крок 10

Дашборд: сторінка, яка нічого не рахує

Уся арифметика вже сталася на сервері. Сторінці лишається прочитати шість чисел і намалювати з них картинку

Це і є розподіл праці, заради якого існує report.sh. Сторінка не має доступу до leads.jsonl — і не потребує його. Вона тягне report.json вагою в кілька сотень байтів, тому відкривається миттєво навіть із телефона на поганому зв'язку.

~/www/dash/index.html · читання звіту
fetch('report.json', {cache: 'no-store'})
  .then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
  .then(d => {
    document.getElementById('total').textContent = d.total;
    document.getElementById('uniq').textContent  = d.unique_people;
    // час беремо зі звіту, а не з годинника відвідувача
    document.getElementById('upd').textContent =
      new Date(d.generated_at).toLocaleString('uk-UA');
    drawBars(d.by_class);          // твій SVG, як на уроці 6
  })
  .catch(e => {
    document.getElementById('err').textContent = 'звіт недоступний: ' + e;
  });
cache: 'no-store' — не дрібниця Браузер із задоволенням покаже вчорашній report.json із кеша: ім'я файлу не змінилося, отже «це той самий файл». Ти оновлюєш звіт щогодини, а відвідувач бачить учорашні числа й свіжу дату поруч. Без цього прапорця дашборд бреше найнеприємнішим чином — переконливо.
Час останнього оновлення обов'язковий Число без дати нічого не варте: «47 заявок» — це станом на коли? Тому generated_at ставить сам скрипт у момент запису, у UTC (date -u), а сторінка перетворює його на місцевий час відвідувача. Свій годинник браузер для цього не питає — тільки для переведення в локальний пояс.
AI-коментар пишеться після перерахунку, не до Найпоширеніша помилка цього уроку виглядає так. Текстовий висновок під діаграмою згенеровано вчора, а числа вже сьогоднішні. Коментар каже «більшість заявок із 10B», коли в звіті попереду 10A. Кожне число в коментарі має бути в report.json; якщо його там немає, коментар вигаданий.
Питання

Ти оновив дані, запустив скрипт, відкрив дашборд — числа старі. Відкриваєш report.json прямою адресою в тій самій вкладці — числа теж старі. Заходиш по SSH, робиш cat того самого файлу — числа нові. Файл один. Де загубилися числа?

Крок 11

Практика: пишемо report.sh

Кроки 1—9 робиш у себе на комп'ютері й у терміналі, кроки 10—15 — на сервері. Галочки зберігаються

Що на сервер не їде На FTP ідуть три текстові файли: report.sh, index.html і, якщо є, README.md. А report.json створює сам скрипт. Заливати цей файл руками не можна ніколи. Рівно з цього починається розбіжність між числами на сторінці й даними у файлі. Знімки екрана, експорти в PDF і копії leads.jsonl лишаються на твоєму комп'ютері.
Крок 12

Автоперевірка

Чекер запускає твій скрипт двічі, порівнює звіти, дивиться на права й шукає в публічному JSON те, чого там не має бути. Спроби не обмежені

Демонстраційний режим: результат згенеровано для показу. На сервері ця кнопка викликає POST /api/check, який запускає /opt/lessons/09/check.sh від імені учня.

Що вчитель дивиться на твоєму екрані

Крок 13

Шкала виконання

Це те, що бачить учитель, коли виставляє оцінку.

СкладникВагаРезультат
Виконано
0%
Рекомендований бал за 12-бальною

П'ять речень, які варто запам'ятати

Де найчастіше помиляються

Домашнє завдання

Додай до дашборду четверту метрику — таку, якої в каркасі ще немає. Поясни її в README.md двома абзацами. Перший: що вона показує і чим корисна саме тобі.

Другий абзац — чому вона нікого не називає. Він обов'язковий. Якщо пояснити не виходить, це сигнал: метрика небезпечна.

Потім перевір поріг. Постав його на 3, на 5 і на 10. Подивись, як щоразу міняється твій by_class, і запиши в README.md, яке число ти обрав і чому.

Джерела

Звідки взяті твердження й команди

Кожну команду з цієї сторінки можна перевірити в документації, а більшість — просто набрати в терміналі

Повний список із поясненнями, що звідки взято, — у файлі urok-09-джерела.md поруч із цією сторінкою.