Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
477 lines
22 KiB
Go
477 lines
22 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"time"
|
||
)
|
||
|
||
// Попередження про вичерпання місця.
|
||
//
|
||
// ЧОМУ ЦЕ НЕ ПРАВИЛО МОНІТОРИНГУ
|
||
//
|
||
// Найдешевший на вигляд шлях — писати розмір бази й запас у добах
|
||
// звичайними метриками в ts.series й дати наявному метричному правилу
|
||
// працювати як є. Він перевірений і не працює; повний розбір — у
|
||
// коментарі до 0067_storage_alert.sql. Коротко: метричне правило
|
||
// обчислюється запитом із JOIN inv.devices, тобто вимагає ХОСТА, а
|
||
// хоста для тому бази не існує; штучний хост коштував би слота тарифу
|
||
// (bill.assert_device_limit) і сам підпав би під наявні правила «даних
|
||
// немає взагалі»; правила живуть у кабінеті, а том — в інсталяції; і
|
||
// головне — «лишилось N діб» не вимір, а частка, знаменник якої буває
|
||
// нулем і від'ємним, а ts.samples.value не вміє мовчати.
|
||
//
|
||
// Тому перевірка живе тут, у тому ж такті, що знімає розміри, і
|
||
// спирається рівно на ті самі числа, які показує сторінка. Одне число —
|
||
// одне значення: розходження між «сторінка каже 40 діб» і «алерт каже
|
||
// 12» не пояснити нікому, а виникло б воно від будь-якої другої
|
||
// формули.
|
||
//
|
||
// ЩО САМЕ ПЕРЕВІРЯЄТЬСЯ
|
||
//
|
||
// Три умови, і порядок між ними не косметичний.
|
||
//
|
||
// ЗАПАС У ДОБАХ — головна. Вона єдина знає ШВИДКІСТЬ. «Зайнято 85 %»
|
||
// на томі, що росте на 0,1 % за добу, — це ще пів року; ті самі
|
||
// 85 % при 3 % за добу — це п'ятниця. Одне й те саме число означає
|
||
// протилежні речі, і тільки нахил їх розрізняє.
|
||
//
|
||
// РІВЕНЬ ЗАЙНЯТОГО — не запасний варіант, а покриття сліпої зони.
|
||
// Прогноз мовчить рівно тоді, коли швидкості немає: спостережень ще
|
||
// не набралось, або приріст нульовий, або від'ємний — базу почистили
|
||
// чи видалили хост. Останній випадок найгірший: одне видалення
|
||
// робить приріст від'ємним на все вікно спостережень, і прогноз
|
||
// сліпне на місяць, поки база тим часом росте як росла. Рівень
|
||
// бачить це без будь-якої історії.
|
||
//
|
||
// МІСЦЯ МЕНШЕ ЗА max_wal_size — підлога, нижче якої відсотки
|
||
// безглузді. Postgres між контрольними точками має право написати
|
||
// до max_wal_size журналу; якщо стільки не влазить, він зупиняється
|
||
// незалежно від того, 90 це відсотків чи 99. Число береться з
|
||
// налаштувань самого сервера, а не з нашого уявлення про запас.
|
||
|
||
// StorageAlertConfig — як налаштоване попередження.
|
||
type StorageAlertConfig struct {
|
||
Enabled bool `json:"enabled"`
|
||
DaysWarn int `json:"days_warn"`
|
||
DaysCrit int `json:"days_crit"`
|
||
}
|
||
|
||
// StorageAlertState — те, що сторінка показує про попередження: і як
|
||
// воно налаштоване, і що воно сказало б прямо зараз.
|
||
type StorageAlertState struct {
|
||
StorageAlertConfig
|
||
|
||
// Level — вирок на цю мить: "" (тихо), "warning" або "disaster".
|
||
Level string `json:"level"`
|
||
// Reason — що саме спрацювало: days | level | wal.
|
||
Reason string `json:"reason,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
|
||
// Blind — чому вирок зараз ні на що не спирається. Порожньо —
|
||
// спирається. Це поле важливіше за Level: «тихо» й «нічим міряти»
|
||
// на екрані виглядають однаково, а означають протилежне.
|
||
Blind string `json:"blind,omitempty"`
|
||
|
||
// FiringSince — коли алерт піднято; nil — не піднято зараз.
|
||
FiringSince *time.Time `json:"firing_since,omitempty"`
|
||
}
|
||
|
||
// storageDedupKey — ключ, за яким алерт вважається тим самим.
|
||
//
|
||
// Стала, а не похідна від чогось: алерт про том рівно один на кабінет,
|
||
// і будь-яка змінна складова в ключі означала б новий алерт щоразу, коли
|
||
// число трохи змінилось, тобто нове сповіщення щогодини.
|
||
const storageDedupKey = "storage:volume"
|
||
|
||
// storageForecastMinDays — скільки діб спостережень має бути, щоб
|
||
// прогнозу можна було вірити настільки, щоб на нього будити людину.
|
||
//
|
||
// Дві. Знімок береться щогодини, і за одну добу різниця країв ловить
|
||
// повний добовий цикл — нічні згортки, добовий збір конфігів, вивантаження
|
||
// дампа. Друга доба потрібна, щоб цей цикл повторився: інакше «плюс 2 ГБ»
|
||
// від одного нічного проходу читається як стала швидкість.
|
||
//
|
||
// Сторінка 0064 показує приріст уже з пів доби, і це правильно: вона
|
||
// довідка, її читає людина, яка бачить поруч «за 0,6 діб спостережень».
|
||
// Будити на такій підставі не можна.
|
||
const storageForecastMinDays = 2.0
|
||
|
||
// storageVerdict — чистий вирок за числами.
|
||
//
|
||
// Окремою функцією без бази навмисно, і не заради стилю: усі цікаві
|
||
// випадки тут — межі, яких на живій базі не відтворити. Нуль
|
||
// спостережень буває раз на інсталяцію в першу годину; від'ємний
|
||
// приріст — після прибирання, якого не замовиш; ділення на нуль — рівно
|
||
// тоді, коли база не росте, тобто в найтихішому зі станів. Перевірити їх
|
||
// можна лише так.
|
||
func storageVerdict(o *StorageOverview, cfg StorageAlertConfig) StorageAlertState {
|
||
st := StorageAlertState{StorageAlertConfig: cfg}
|
||
|
||
// 1. Чи є взагалі знаменник. Без вільного місця не рахується ані
|
||
// запас, ані відсоток — жодна з трьох умов не має сенсу.
|
||
if o.FreeBytes == nil {
|
||
st.Blind = "no_capacity"
|
||
return st
|
||
}
|
||
free := *o.FreeBytes
|
||
|
||
// 2. Підлога WAL. Перевіряється першою, бо вона єдина не залежить ні
|
||
// від історії, ні від налаштувань людини: це вимога самого Postgres
|
||
// до себе.
|
||
if o.MaxWalBytes > 0 && free < o.MaxWalBytes {
|
||
st.Level, st.Reason = "disaster", "wal"
|
||
st.Message = fmt.Sprintf(
|
||
"На томі лишилось %s — менше, ніж %s, які Postgres має право написати "+
|
||
"в журнал між контрольними точками (max_wal_size). Записи можуть "+
|
||
"зупинитись раніше, ніж том заповниться повністю.",
|
||
humanBytes(free), humanBytes(o.MaxWalBytes))
|
||
return st
|
||
}
|
||
|
||
// 3. Запас у добах — головна умова.
|
||
//
|
||
// Три причини не порахувати, і кожна називається своїм словом.
|
||
// Спільне «—» тут було б найдорожчою економією в цьому файлі:
|
||
// «прогноз мовчить» і «прогноз каже, що все гаразд» на екрані
|
||
// виглядають однаково.
|
||
switch {
|
||
case o.ObservedDays < storageForecastMinDays:
|
||
st.Blind = "no_observations"
|
||
case o.PerDayBytes <= 0:
|
||
st.Blind = "not_growing"
|
||
default:
|
||
days := float64(free) / o.PerDayBytes
|
||
switch {
|
||
case days < float64(cfg.DaysCrit):
|
||
st.Level, st.Reason = "disaster", "days"
|
||
case days < float64(cfg.DaysWarn):
|
||
st.Level, st.Reason = "warning", "days"
|
||
}
|
||
if st.Level != "" {
|
||
st.Message = fmt.Sprintf(
|
||
"Вільного місця на томі %s, база росте на %s за добу — вистачить "+
|
||
"приблизно на %s. Прогноз спирається на %.1f діб спостережень.",
|
||
humanBytes(free), humanBytes(int64(math.Round(o.PerDayBytes))),
|
||
humanDays(days), o.ObservedDays)
|
||
return st
|
||
}
|
||
}
|
||
|
||
// 4. Рівень зайнятого. Не «якщо не спрацював прогноз», а завжди:
|
||
// рівень може бути аварійним і при повільному зростанні, і саме
|
||
// тоді, коли прогноз осліп після прибирання.
|
||
if o.DiskTotalBytes > 0 {
|
||
used := o.DiskTotalBytes - free
|
||
pct := float64(used) / float64(o.DiskTotalBytes) * 100
|
||
if pct >= float64(o.WarnPct) {
|
||
st.Level, st.Reason = "warning", "level"
|
||
st.Message = fmt.Sprintf(
|
||
"Зайнято %.1f %% тому на %s, вільного %s — це більше за поріг %d %%.",
|
||
pct, humanBytes(o.DiskTotalBytes), humanBytes(free), o.WarnPct)
|
||
// Рівень уже щось сказав, тож сліпота прогнозу перестає бути
|
||
// причиною мовчання — але лишається поясненням, чому в тексті
|
||
// немає дати.
|
||
return st
|
||
}
|
||
}
|
||
return st
|
||
}
|
||
|
||
// storageSeverity переводить рівень вироку в серйозність алерту.
|
||
//
|
||
// warning і disaster, без проміжних. Проміжні рівні тут не описували б
|
||
// нічого нового: між «встигнеш замовити диск» і «встигнеш лише
|
||
// скоротити строк» немає третього стану, а зайва градація коштує
|
||
// маршруту доставки, який ніхто не налаштує.
|
||
func storageSeverity(level string) string {
|
||
if level == "disaster" {
|
||
return "disaster"
|
||
}
|
||
return "warning"
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Читання й збереження налаштувань
|
||
// ---------------------------------------------------------------------
|
||
|
||
// StorageAlertSettings читає налаштування попередження.
|
||
func (s *Store) StorageAlertSettings(ctx context.Context) (StorageAlertConfig, error) {
|
||
var c StorageAlertConfig
|
||
err := s.bg.QueryRow(ctx, `
|
||
SELECT alert_enabled, alert_days_warn, alert_days_crit
|
||
FROM core.storage_config
|
||
`).Scan(&c.Enabled, &c.DaysWarn, &c.DaysCrit)
|
||
return c, err
|
||
}
|
||
|
||
// SetStorageAlert зберігає налаштування попередження.
|
||
//
|
||
// Перевірки тут, а не лише в CHECK бази: суперечливі пороги — помилка
|
||
// людини у формі, і вона має отримати текст, а не 500 від порушеного
|
||
// обмеження. CHECK при цьому лишається — він захищає від запису повз
|
||
// цю функцію, а не від людини.
|
||
// dataPath — вказівник: nil означає «не чіпати шлях», порожній рядок —
|
||
// «прибрати». Дві різні дії, і злити їх в одну не можна: шлях
|
||
// прибирають рідко, а зберігають форму часто.
|
||
func (s *Store) SetStorageAlert(ctx context.Context, c StorageAlertConfig, dataPath *string, userID string) error {
|
||
if c.DaysWarn < 2 || c.DaysWarn > 365 {
|
||
return ErrInvalid
|
||
}
|
||
if c.DaysCrit < 1 || c.DaysCrit > 90 {
|
||
return ErrInvalid
|
||
}
|
||
if c.DaysCrit > c.DaysWarn {
|
||
return ErrInvalid
|
||
}
|
||
|
||
setPath := dataPath != nil
|
||
path := ""
|
||
if setPath {
|
||
path = *dataPath
|
||
}
|
||
_, err := s.bg.Exec(ctx, `
|
||
UPDATE core.storage_config
|
||
SET alert_enabled = $1,
|
||
alert_days_warn = $2,
|
||
alert_days_crit = $3,
|
||
data_path = CASE WHEN $4::boolean THEN $5::text ELSE data_path END,
|
||
updated_at = now(),
|
||
updated_by = $6
|
||
WHERE id
|
||
`, c.Enabled, c.DaysWarn, c.DaysCrit, setPath, nullString(path), nullUUID(userID))
|
||
return err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Підняття й закриття
|
||
// ---------------------------------------------------------------------
|
||
|
||
// CheckStorageAlert обчислює вирок і приводить алерти у відповідність
|
||
// до нього.
|
||
//
|
||
// Повертає сам вирок — щоб такт міг написати в журнал те саме, що
|
||
// побачить людина, а не свій переказ.
|
||
func (s *Store) CheckStorageAlert(ctx context.Context) (StorageAlertState, error) {
|
||
o, err := s.StorageUsage(ctx)
|
||
if err != nil {
|
||
return StorageAlertState{}, err
|
||
}
|
||
st := o.Alert
|
||
|
||
if !st.Enabled {
|
||
// Вимкнене попередження має ще й прибрати за собою: інакше
|
||
// алерт, піднятий до вимкнення, лишився б на дошці назавжди —
|
||
// закрити його не буде кому.
|
||
if _, err := s.resolveStorageAlerts(ctx); err != nil {
|
||
return st, err
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
if st.Level == "" {
|
||
if _, err := s.resolveStorageAlerts(ctx); err != nil {
|
||
return st, err
|
||
}
|
||
return st, nil
|
||
}
|
||
return st, s.raiseStorageAlert(ctx, o, st)
|
||
}
|
||
|
||
// raiseStorageAlert піднімає алерт у кожному чинному кабінеті.
|
||
//
|
||
// У КОЖНОМУ — це рішення, а не недогляд. Том один на інсталяцію, а
|
||
// алерти живуть у кабінетах; коли база стане, зупиниться моніторинг
|
||
// усіх, тож дізнатись про це має кожен, у кого він є. У коробковій
|
||
// поставці кабінет один і питання не виникає зовсім; на спільному
|
||
// хостингу ціна — по одному сповіщенню на клієнта, і вона менша за
|
||
// ціну клієнта, який дізнався про зупинку моніторингу від своєї
|
||
// мережі.
|
||
//
|
||
// Вікна обслуговування свідомо не питаються. Вони існують, щоб не
|
||
// будити людину через те, що вона сама вимкнула, — а том від
|
||
// оголошеного вікна наповнюватись не перестає. До того ж алерт тут один
|
||
// і дедуплікований: він потурбує рівно раз, а не щогодини.
|
||
func (s *Store) raiseStorageAlert(ctx context.Context, o *StorageOverview, st StorageAlertState) error {
|
||
tenants, err := s.activeTenantIDs(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(tenants) == 0 {
|
||
return nil
|
||
}
|
||
|
||
sev := storageSeverity(st.Level)
|
||
title := "Сховище: місце на томі бази закінчується"
|
||
if st.Level == "disaster" {
|
||
title = "Сховище: місце на томі бази ось-ось закінчиться"
|
||
}
|
||
|
||
meta := map[string]any{
|
||
"reason": st.Reason,
|
||
"free_bytes": o.FreeBytes,
|
||
"total_bytes": o.DiskTotalBytes,
|
||
"per_day_bytes": o.PerDayBytes,
|
||
"observed_days": o.ObservedDays,
|
||
"source": o.FreeSource,
|
||
}
|
||
if o.DaysLeft != nil {
|
||
meta["days_left"] = *o.DaysLeft
|
||
}
|
||
ctxJSON, err := json.Marshal(meta)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var value *float64
|
||
if o.DaysLeft != nil {
|
||
value = o.DaysLeft
|
||
}
|
||
|
||
for _, tenantID := range tenants {
|
||
// notify_pending виставляється лише коли є про що сповіщати
|
||
// заново: перша поява або зростання серйозності. Інакше
|
||
// щогодинний такт слав би те саме повідомлення двадцять чотири
|
||
// рази на добу — і його вимкнули б на другу.
|
||
//
|
||
// Порівняння серйозностей робить сам Postgres: alr.severity —
|
||
// перелік, і порядок у ньому той самий, що й у голові
|
||
// (info < warning < average < high < disaster).
|
||
if _, err := s.bg.Exec(ctx, `
|
||
INSERT INTO alr.alerts
|
||
(tenant_id, rule_id, device_id, severity, state, title, message,
|
||
dedup_key, value, context, notify_pending, started_at, last_seen_at)
|
||
VALUES ($1, NULL, NULL, $2::alr.severity, 'firing', $3, $4,
|
||
$5, $6, $7::jsonb, true, now(), now())
|
||
ON CONFLICT (tenant_id, dedup_key)
|
||
WHERE state IN ('firing','acknowledged','suppressed')
|
||
DO UPDATE SET
|
||
last_seen_at = now(),
|
||
value = EXCLUDED.value,
|
||
message = EXCLUDED.message,
|
||
title = EXCLUDED.title,
|
||
context = EXCLUDED.context,
|
||
severity = EXCLUDED.severity,
|
||
-- Підтверджений людиною алерт не повертається у firing від
|
||
-- того, що проблема триває: ack означає «я знаю».
|
||
state = CASE WHEN alr.alerts.state = 'acknowledged'
|
||
THEN 'acknowledged' ELSE 'firing' END::alr.alert_state,
|
||
notify_pending = alr.alerts.notify_pending
|
||
OR EXCLUDED.severity > alr.alerts.severity
|
||
`, tenantID, sev, title, st.Message, storageDedupKey, value, string(ctxJSON)); err != nil {
|
||
return fmt.Errorf("алерт про сховище (кабінет %s): %w", tenantID, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// resolveStorageAlerts закриває алерт, коли причини більше немає.
|
||
//
|
||
// Своїм запитом, а не через ResolveMissing: та працює за rule_id, а в
|
||
// цього алерту правила немає за побудовою. Тобто без цієї функції він
|
||
// не закрився б ніколи — і саме такий алерт навчає людей не вірити
|
||
// дошці.
|
||
func (s *Store) resolveStorageAlerts(ctx context.Context) (int64, error) {
|
||
tag, err := s.bg.Exec(ctx, `
|
||
UPDATE alr.alerts
|
||
SET state = 'resolved', resolved_at = now(), notify_pending = false
|
||
WHERE dedup_key = $1
|
||
AND state IN ('firing','acknowledged','suppressed')
|
||
`, storageDedupKey)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return tag.RowsAffected(), nil
|
||
}
|
||
|
||
// activeTenantIDs — кабінети, яким узагалі є сенс щось повідомляти.
|
||
//
|
||
// Той самий предикат, що й у вибірці правил движка: призупинений або
|
||
// скасований кабінет не отримує алертів ні про що інше, і сховище тут
|
||
// не виняток.
|
||
func (s *Store) activeTenantIDs(ctx context.Context) ([]string, error) {
|
||
rows, err := s.bg.Query(ctx, `
|
||
SELECT id::text FROM core.tenants
|
||
WHERE deleted_at IS NULL AND status NOT IN ('suspended','cancelled')
|
||
ORDER BY created_at
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []string
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, id)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// storageAlertSince — коли алерт про том піднято; nil, якщо не піднято.
|
||
//
|
||
// По всій інсталяції, а не по кабінету: сторінка сховища теж показує
|
||
// інсталяцію цілком, і «у вас тихо, а в сусіда горить» було б для неї
|
||
// неможливим станом.
|
||
func (s *Store) storageAlertSince(ctx context.Context) *time.Time {
|
||
var t *time.Time
|
||
if err := s.bg.QueryRow(ctx, `
|
||
SELECT min(started_at) FROM alr.alerts
|
||
WHERE dedup_key = $1 AND state IN ('firing','acknowledged','suppressed')
|
||
`, storageDedupKey).Scan(&t); err != nil {
|
||
return nil
|
||
}
|
||
return t
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Числа словами
|
||
// ---------------------------------------------------------------------
|
||
|
||
// humanBytes — розмір для тексту сповіщення.
|
||
//
|
||
// Своя, а не спільна з клієнтом: цей рядок їде в Telegram і в пошту,
|
||
// тобто туди, куди форматування браузера не доїжджає взагалі.
|
||
func humanBytes(b int64) string {
|
||
neg := ""
|
||
if b < 0 {
|
||
neg, b = "−", -b
|
||
}
|
||
const unit = 1024
|
||
if b < unit {
|
||
return fmt.Sprintf("%s%d Б", neg, b)
|
||
}
|
||
div, exp := int64(unit), 0
|
||
for n := b / unit; n >= unit && exp < 3; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%s%.1f %s", neg, float64(b)/float64(div),
|
||
[]string{"КБ", "МБ", "ГБ", "ТБ"}[exp])
|
||
}
|
||
|
||
// humanDays — запас у добах словами.
|
||
//
|
||
// Менше доби окремим випадком: «0 діб» читається як «даних немає», а
|
||
// це рівно протилежне до того, що відбувається.
|
||
func humanDays(d float64) string {
|
||
if d < 1 {
|
||
return "менш ніж добу"
|
||
}
|
||
n := int(d)
|
||
switch {
|
||
case n%100 >= 11 && n%100 <= 14:
|
||
return fmt.Sprintf("%d діб", n)
|
||
case n%10 == 1:
|
||
return fmt.Sprintf("%d добу", n)
|
||
case n%10 >= 2 && n%10 <= 4:
|
||
return fmt.Sprintf("%d доби", n)
|
||
default:
|
||
return fmt.Sprintf("%d діб", n)
|
||
}
|
||
}
|