• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tstack / lnav / 17657281416-2508

11 Sep 2025 09:05PM UTC coverage: 64.984% (-0.2%) from 65.166%
17657281416-2508

push

github

tstack
[log2src] show source vars in message details

Improve selection of external editors and preserve
cursor location when opening from prompt.

Add CLion and RustRover as external editors.

Add breakpoint support

262 of 629 new or added lines in 26 files covered. (41.65%)

4 existing lines in 3 files now uncovered.

45653 of 70253 relevant lines covered (64.98%)

404292.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.7
/src/timeline_source.cc
1
/**
2
 * Copyright (c) 2023, Timothy Stack
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 * * Redistributions of source code must retain the above copyright notice, this
10
 * list of conditions and the following disclaimer.
11
 * * Redistributions in binary form must reproduce the above copyright notice,
12
 * this list of conditions and the following disclaimer in the documentation
13
 * and/or other materials provided with the distribution.
14
 * * Neither the name of Timothy Stack nor the names of its contributors
15
 * may be used to endorse or promote products derived from this software
16
 * without specific prior written permission.
17
 *
18
 * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY
19
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
 * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
22
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 */
29

30
#include <chrono>
31

32
#include "timeline_source.hh"
33

34
#include <time.h>
35

36
#include "base/humanize.hh"
37
#include "base/humanize.time.hh"
38
#include "base/itertools.enumerate.hh"
39
#include "base/itertools.hh"
40
#include "base/keycodes.hh"
41
#include "base/math_util.hh"
42
#include "command_executor.hh"
43
#include "crashd.client.hh"
44
#include "intervaltree/IntervalTree.h"
45
#include "lnav_util.hh"
46
#include "logline_window.hh"
47
#include "md4cpp.hh"
48
#include "sql_util.hh"
49
#include "sysclip.hh"
50

51
using namespace std::chrono_literals;
52
using namespace lnav::roles::literals;
53
using namespace md4cpp::literals;
54

55
static const std::vector<std::chrono::seconds> TIME_SPANS = {
56
    5min,
57
    15min,
58
    1h,
59
    2h,
60
    4h,
61
    8h,
62
    24h,
63
    7 * 24h,
64
    30 * 24h,
65
    365 * 24h,
66
};
67

68
static constexpr size_t MAX_OPID_WIDTH = 60;
69

70
size_t
71
abbrev_ftime(char* datebuf,
12✔
72
             size_t db_size,
73
             const struct tm& lb_tm,
74
             const struct tm& dt)
75
{
76
    char lb_fmt[32] = " ";
12✔
77
    bool same = true;
12✔
78

79
    if (lb_tm.tm_year == dt.tm_year) {
12✔
80
        strcat(lb_fmt, "    ");
12✔
81
    } else {
82
        same = false;
×
83
        strcat(lb_fmt, "%Y");
×
84
    }
85
    if (same && lb_tm.tm_mon == dt.tm_mon) {
12✔
86
        strcat(lb_fmt, "   ");
12✔
87
    } else {
88
        if (!same) {
×
89
            strcat(lb_fmt, "-");
×
90
        }
91
        same = false;
×
92
        strcat(lb_fmt, "%m");
×
93
    }
94
    if (same && lb_tm.tm_mday == dt.tm_mday) {
12✔
95
        strcat(lb_fmt, "   ");
12✔
96
    } else {
97
        if (!same) {
×
98
            strcat(lb_fmt, "-");
×
99
        }
100
        same = false;
×
101
        strcat(lb_fmt, "%d");
×
102
    }
103
    if (same && lb_tm.tm_hour == dt.tm_hour) {
12✔
104
        strcat(lb_fmt, "   ");
12✔
105
    } else {
106
        if (!same) {
×
107
            strcat(lb_fmt, "T");
×
108
        }
109
        same = false;
×
110
        strcat(lb_fmt, "%H");
×
111
    }
112
    if (same && lb_tm.tm_min == dt.tm_min) {
12✔
113
        strcat(lb_fmt, "   ");
12✔
114
    } else {
115
        if (!same) {
×
116
            strcat(lb_fmt, ":");
×
117
        }
118
        same = false;
×
119
        strcat(lb_fmt, "%M");
×
120
    }
121
    return strftime(datebuf, db_size, lb_fmt, &dt);
12✔
122
}
123

124
std::vector<attr_line_t>
125
timeline_preview_overlay::list_overlay_menu(const listview_curses& lv,
×
126
                                            vis_line_t line)
127
{
128
    static constexpr auto MENU_WIDTH = 25;
129

130
    const auto* tc = dynamic_cast<const textview_curses*>(&lv);
×
131
    std::vector<attr_line_t> retval;
×
132

133
    if (tc->tc_text_selection_active || !tc->tc_selected_text) {
×
134
        return retval;
×
135
    }
136

137
    const auto& sti = tc->tc_selected_text.value();
×
138

139
    if (sti.sti_line != line) {
×
140
        return retval;
×
141
    }
142
    auto title = " Actions "_status_title;
×
143
    auto left = std::max(0, sti.sti_x - 2);
×
144
    auto dim = lv.get_dimensions();
×
145
    auto menu_line = vis_line_t{1};
×
146

147
    if (left + MENU_WIDTH >= dim.second) {
×
148
        left = dim.second - MENU_WIDTH;
×
149
    }
150

151
    this->los_menu_items.clear();
×
152

153
    retval.emplace_back(attr_line_t().pad_to(left).append(title));
×
154
    {
155
        auto start = left;
×
156
        attr_line_t al;
×
157

158
        al.append(":clipboard:"_emoji)
×
159
            .append(" Copy  ")
×
160
            .with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS));
×
161
        this->los_menu_items.emplace_back(
×
162
            menu_line,
163
            line_range{start, start + (int) al.length()},
×
164
            [](const std::string& value) {
×
165
                auto clip_res = sysclip::open(sysclip::type_t::GENERAL);
×
166
                if (clip_res.isErr()) {
×
167
                    log_error("unable to open clipboard: %s",
×
168
                              clip_res.unwrapErr().c_str());
169
                    return;
×
170
                }
171

172
                auto clip_pipe = clip_res.unwrap();
×
173
                fwrite(value.c_str(), 1, value.length(), clip_pipe.in());
×
174
            });
×
175
        retval.emplace_back(attr_line_t().pad_to(left).append(al));
×
176
    }
177

178
    return retval;
×
179
}
×
180

181
timeline_header_overlay::timeline_header_overlay(
581✔
182
    const std::shared_ptr<timeline_source>& src)
581✔
183
    : gho_src(src)
581✔
184
{
185
}
581✔
186

187
bool
188
timeline_header_overlay::list_static_overlay(const listview_curses& lv,
24✔
189
                                             int y,
190
                                             int bottom,
191
                                             attr_line_t& value_out)
192
{
193
    if (y >= 3) {
24✔
194
        return false;
6✔
195
    }
196

197
    if (this->gho_src->gs_time_order.empty()) {
18✔
198
        if (y == 0) {
×
199
            value_out.append("No operations found"_error);
×
200
            return true;
×
201
        }
202

203
        return false;
×
204
    }
205

206
    auto lb = this->gho_src->gs_lower_bound;
18✔
207
    struct tm lb_tm;
208
    auto ub = this->gho_src->gs_upper_bound;
18✔
209
    struct tm ub_tm;
210
    auto bounds
211
        = this->gho_src->get_time_bounds_for(lv.get_selection().value_or(0_vl));
18✔
212

213
    if (bounds.first < lb) {
18✔
214
        lb = bounds.first;
18✔
215
    }
216
    if (ub < bounds.second) {
18✔
217
        ub = bounds.second;
18✔
218
    }
219

220
    secs2tm(lb.tv_sec, &lb_tm);
18✔
221
    secs2tm(ub.tv_sec, &ub_tm);
18✔
222

223
    struct tm sel_lb_tm;
224
    secs2tm(bounds.first.tv_sec, &sel_lb_tm);
18✔
225
    struct tm sel_ub_tm;
226
    secs2tm(bounds.second.tv_sec, &sel_ub_tm);
18✔
227

228
    auto width = lv.get_dimensions().second - 1;
18✔
229

230
    char datebuf[64];
231

232
    if (y == 0) {
18✔
233
        double span = ub.tv_sec - lb.tv_sec;
6✔
234
        double per_ch = span / (double) width;
6✔
235
        strftime(datebuf, sizeof(datebuf), " %Y-%m-%dT%H:%M", &lb_tm);
6✔
236
        value_out.append(datebuf);
6✔
237

238
        auto duration_str = humanize::time::duration::from_tv(ub - lb)
6✔
239
                                .with_resolution(1min)
6✔
240
                                .to_string();
6✔
241
        auto duration_pos = width / 2 - duration_str.size() / 2;
6✔
242
        value_out.pad_to(duration_pos).append(duration_str);
6✔
243

244
        auto upper_size
245
            = strftime(datebuf, sizeof(datebuf), "%Y-%m-%dT%H:%M ", &ub_tm);
6✔
246
        auto upper_pos = width - upper_size;
6✔
247

248
        value_out.pad_to(upper_pos).append(datebuf);
6✔
249

250
        auto lr = line_range{};
6✔
251
        if (lb.tv_sec < bounds.first.tv_sec) {
6✔
252
            auto start_diff = bounds.first.tv_sec - lb.tv_sec;
×
253
            lr.lr_start = start_diff / per_ch;
×
254
        } else {
255
            lr.lr_start = 0;
6✔
256
        }
257
        if (lb.tv_sec < bounds.second.tv_sec) {
6✔
258
            auto start_diff = bounds.second.tv_sec - lb.tv_sec;
6✔
259
            lr.lr_end = start_diff / per_ch;
6✔
260
        } else {
261
            lr.lr_end = 1;
×
262
        }
263
        if (lr.lr_start == lr.lr_end) {
6✔
264
            lr.lr_end += 1;
×
265
        }
266

267
        value_out.get_attrs().emplace_back(
12✔
268
            lr, VC_ROLE.value(role_t::VCR_CURSOR_LINE));
12✔
269
        value_out.with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS_INFO));
6✔
270
    } else if (y == 1) {
18✔
271
        abbrev_ftime(datebuf, sizeof(datebuf), lb_tm, sel_lb_tm);
6✔
272
        value_out.appendf(FMT_STRING(" {}"), datebuf);
24✔
273

274
        auto duration_str
275
            = humanize::time::duration::from_tv(bounds.second - bounds.first)
6✔
276
                  .with_resolution(1min)
6✔
277
                  .to_string();
6✔
278
        auto duration_pos = width / 2 - duration_str.size() / 2;
6✔
279
        value_out.pad_to(duration_pos).append(duration_str);
6✔
280

281
        auto upper_size
282
            = abbrev_ftime(datebuf, sizeof(datebuf), ub_tm, sel_ub_tm);
6✔
283
        auto upper_pos = width - upper_size - 1;
6✔
284
        value_out.pad_to(upper_pos).append(datebuf);
6✔
285
        value_out.with_attr_for_all(VC_ROLE.value(role_t::VCR_CURSOR_LINE));
6✔
286
    } else {
6✔
287
        value_out.append("   Duration   "_h1)
6✔
288
            .append("|", VC_GRAPHIC.value(NCACS_VLINE))
6✔
289
            .append(" ")
6✔
290
            .append("\u2718"_error)
6✔
291
            .append("\u25b2"_warning)
6✔
292
            .append(" ")
6✔
293
            .append("|", VC_GRAPHIC.value(NCACS_VLINE))
12✔
294
            .append(" Operation"_h1);
6✔
295
        auto hdr_attrs = text_attrs::with_underline();
6✔
296
        value_out.get_attrs().emplace_back(line_range{0, -1},
12✔
297
                                           VC_STYLE.value(hdr_attrs));
12✔
298
        value_out.with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS_INFO));
6✔
299
    }
6✔
300

301
    return true;
18✔
302
}
303
void
304
timeline_header_overlay::list_value_for_overlay(
304✔
305
    const listview_curses& lv,
306
    vis_line_t line,
307
    std::vector<attr_line_t>& value_out)
308
{
309
    if (!this->gho_show_details) {
304✔
310
        return;
304✔
311
    }
312

313
    if (lv.get_selection() != line) {
×
314
        return;
×
315
    }
316

317
    if (line >= this->gho_src->gs_time_order.size()) {
×
318
        return;
×
319
    }
320

321
    const auto& row = this->gho_src->gs_time_order[line].get();
×
322

323
    if (row.or_value.otr_sub_ops.size() <= 1) {
×
324
        return;
×
325
    }
326

327
    auto width = lv.get_dimensions().second;
×
328

329
    if (width < 37) {
×
330
        return;
×
331
    }
332

333
    width -= 37;
×
334
    double span = row.or_value.otr_range.duration().count();
×
335
    double per_ch = span / (double) width;
×
336

337
    for (const auto& sub : row.or_value.otr_sub_ops) {
×
338
        value_out.resize(value_out.size() + 1);
×
339

340
        auto& al = value_out.back();
×
341
        auto& attrs = al.get_attrs();
×
342
        auto total_msgs = sub.ostr_level_stats.lls_total_count;
×
343
        auto duration = sub.ostr_range.tr_end - sub.ostr_range.tr_begin;
×
344
        auto duration_str = fmt::format(
345
            FMT_STRING(" {: >13}"),
×
346
            humanize::time::duration::from_tv(duration).to_string());
×
347
        al.pad_to(14)
×
348
            .append(duration_str, VC_ROLE.value(role_t::VCR_OFFSET_TIME))
×
349
            .append(" ")
×
350
            .append(lnav::roles::error(humanize::sparkline(
×
351
                sub.ostr_level_stats.lls_error_count, total_msgs)))
×
352
            .append(lnav::roles::warning(humanize::sparkline(
×
353
                sub.ostr_level_stats.lls_warning_count, total_msgs)))
×
354
            .append(" ")
×
355
            .append(lnav::roles::identifier(sub.ostr_subid.to_string()))
×
356
            .append(row.or_max_subid_width
×
357
                        - sub.ostr_subid.utf8_length().unwrapOr(
×
358
                            row.or_max_subid_width),
×
359
                    ' ')
360
            .append(sub.ostr_description);
×
361
        al.with_attr_for_all(VC_ROLE.value(role_t::VCR_COMMENT));
×
362

363
        auto start_diff = (double) to_mstime(sub.ostr_range.tr_begin
×
364
                                             - row.or_value.otr_range.tr_begin);
×
365
        auto end_diff = (double) to_mstime(sub.ostr_range.tr_end
×
366
                                           - row.or_value.otr_range.tr_begin);
×
367

368
        auto lr = line_range{
369
            (int) (32 + (start_diff / per_ch)),
×
370
            (int) (32 + (end_diff / per_ch)),
×
371
            line_range::unit::codepoint,
372
        };
373

374
        if (lr.lr_start == lr.lr_end) {
×
375
            lr.lr_end += 1;
×
376
        }
377

378
        auto block_attrs = text_attrs::with_reverse();
×
379
        attrs.emplace_back(lr, VC_STYLE.value(block_attrs));
×
380
    }
381
    if (!value_out.empty()) {
×
382
        value_out.back().get_attrs().emplace_back(
×
383
            line_range{0, -1}, VC_STYLE.value(text_attrs::with_underline()));
×
384
    }
385
}
386
std::optional<attr_line_t>
387
timeline_header_overlay::list_header_for_overlay(const listview_curses& lv,
×
388
                                                 vis_line_t line)
389
{
390
    if (lv.get_overlay_selection()) {
×
391
        return attr_line_t("\u258C Sub-operations: Press ")
×
392
            .append("Esc"_hotkey)
×
393
            .append(" to exit this panel");
×
394
    }
395
    return attr_line_t("\u258C Sub-operations: Press ")
×
396
        .append("CTRL-]"_hotkey)
×
397
        .append(" to focus on this panel");
×
398
}
399

400
timeline_source::timeline_source(textview_curses& log_view,
581✔
401
                                 logfile_sub_source& lss,
402
                                 textview_curses& preview_view,
403
                                 plain_text_source& preview_source,
404
                                 statusview_curses& preview_status_view,
405
                                 timeline_status_source& preview_status_source)
581✔
406
    : gs_log_view(log_view), gs_lss(lss), gs_preview_view(preview_view),
581✔
407
      gs_preview_source(preview_source),
581✔
408
      gs_preview_status_view(preview_status_view),
581✔
409
      gs_preview_status_source(preview_status_source)
581✔
410
{
411
    this->tss_supports_filtering = true;
581✔
412
    this->gs_preview_view.set_overlay_source(&this->gs_preview_overlay);
581✔
413
}
581✔
414

415
bool
416
timeline_source::list_input_handle_key(listview_curses& lv, const ncinput& ch)
×
417
{
418
    switch (ch.eff_text[0]) {
×
419
        case 'q':
×
420
        case KEY_ESCAPE: {
421
            if (this->gs_preview_focused) {
×
422
                this->gs_preview_focused = false;
×
423
                this->gs_preview_view.set_height(5_vl);
×
424
            }
425
            this->tss_view->tc_cursor_role = role_t::VCR_CURSOR_LINE;
×
426
            this->gs_preview_view.tc_cursor_role
×
427
                = role_t::VCR_DISABLED_CURSOR_LINE;
×
428
            this->gs_preview_status_view.set_enabled(this->gs_preview_focused);
×
429
            break;
×
430
        }
431
        case '\n':
×
432
        case '\r':
433
        case NCKEY_ENTER: {
434
            this->gs_preview_focused = !this->gs_preview_focused;
×
435
            this->gs_preview_status_view.set_enabled(this->gs_preview_focused);
×
436
            if (this->gs_preview_focused) {
×
437
                auto height = this->tss_view->get_dimensions().first;
×
438

439
                if (height > 5) {
×
440
                    this->gs_preview_view.set_height(height - 3_vl);
×
441
                }
442
                this->tss_view->tc_cursor_role
×
443
                    = role_t::VCR_DISABLED_CURSOR_LINE;
×
444
                this->gs_preview_view.tc_cursor_role = role_t::VCR_CURSOR_LINE;
×
445
            } else {
446
                this->tss_view->tc_cursor_role = role_t::VCR_CURSOR_LINE;
×
447
                this->gs_preview_view.tc_cursor_role
×
448
                    = role_t::VCR_DISABLED_CURSOR_LINE;
×
449
                this->gs_preview_view.set_height(5_vl);
×
450
            }
451
            return true;
×
452
        }
453
    }
454
    if (this->gs_preview_focused) {
×
455
        log_debug("to preview");
×
456
        return this->gs_preview_view.handle_key(ch);
×
457
    }
458

459
    return false;
×
460
}
461

462
bool
463
timeline_source::text_handle_mouse(
×
464
    textview_curses& tc,
465
    const listview_curses::display_line_content_t&,
466
    mouse_event& me)
467
{
468
    auto nci = ncinput{};
×
469
    if (me.is_double_click_in(mouse_button_t::BUTTON_LEFT, line_range{0, -1})) {
×
470
        nci.id = '\r';
×
471
        nci.eff_text[0] = '\r';
×
472
        this->list_input_handle_key(tc, nci);
×
473
    }
474

475
    return false;
×
476
}
477

478
std::pair<timeval, timeval>
479
timeline_source::get_time_bounds_for(int line)
319✔
480
{
481
    const auto low_index = this->tss_view->get_top();
319✔
482
    auto high_index
483
        = std::min(this->tss_view->get_bottom(),
319✔
484
                   vis_line_t((int) this->gs_time_order.size() - 1));
319✔
485
    const auto& low_row = this->gs_time_order[low_index].get();
319✔
486
    const auto& high_row = this->gs_time_order[high_index].get();
319✔
487
    auto low_tv_sec = low_row.or_value.otr_range.tr_begin.tv_sec;
319✔
488
    auto high_tv_sec = high_row.or_value.otr_range.tr_begin.tv_sec;
319✔
489

490
    for (auto index = low_index; index <= high_index; index += 1_vl) {
638✔
491
        const auto& row = this->gs_time_order[index].get();
319✔
492

493
        if (row.or_value.otr_range.tr_end.tv_sec > high_tv_sec) {
319✔
494
            high_tv_sec = row.or_value.otr_range.tr_end.tv_sec;
311✔
495
        }
496
    }
497
    auto duration = std::chrono::seconds{high_tv_sec - low_tv_sec};
319✔
498
    auto span_iter
499
        = std::upper_bound(TIME_SPANS.begin(), TIME_SPANS.end(), duration);
319✔
500
    if (span_iter == TIME_SPANS.end()) {
319✔
501
        --span_iter;
×
502
    }
503
    auto round_to = (*span_iter) == 5min
319✔
504
        ? 60
319✔
505
        : ((*span_iter) == 15min ? 60 * 15 : 60 * 60);
×
506
    auto span_secs = span_iter->count() - round_to;
319✔
507
    struct timeval lower_tv = {
319✔
508
        rounddown(low_row.or_value.otr_range.tr_begin.tv_sec, round_to),
319✔
509
        0,
510
    };
319✔
511
    lower_tv.tv_sec -= span_secs / 2;
319✔
512
    struct timeval upper_tv = {
319✔
513
        static_cast<time_t>(roundup(high_tv_sec, round_to)),
319✔
514
        0,
515
    };
319✔
516
    upper_tv.tv_sec += span_secs / 2;
319✔
517

518
    return {lower_tv, upper_tv};
638✔
519
}
520

521
size_t
522
timeline_source::text_line_count()
6,810✔
523
{
524
    return this->gs_time_order.size();
6,810✔
525
}
526

527
line_info
528
timeline_source::text_value_for_line(textview_curses& tc,
301✔
529
                                     int line,
530
                                     std::string& value_out,
531
                                     text_sub_source::line_flags_t flags)
532
{
533
    if (line < (ssize_t) this->gs_time_order.size()) {
301✔
534
        const auto& row = this->gs_time_order[line].get();
301✔
535
        auto duration
536
            = row.or_value.otr_range.tr_end - row.or_value.otr_range.tr_begin;
301✔
537
        auto duration_str = fmt::format(
538
            FMT_STRING(" {: >13}"),
903✔
539
            humanize::time::duration::from_tv(duration).to_string());
602✔
540

541
        this->gs_rendered_line.clear();
301✔
542

543
        auto total_msgs = row.or_value.otr_level_stats.lls_total_count;
301✔
544
        auto truncated_name = row.or_name.to_string();
301✔
545
        truncate_to(truncated_name, MAX_OPID_WIDTH);
301✔
546
        this->gs_rendered_line
547
            .append(duration_str, VC_ROLE.value(role_t::VCR_OFFSET_TIME))
602✔
548
            .append("  ")
301✔
549
            .append(lnav::roles::error(humanize::sparkline(
903✔
550
                row.or_value.otr_level_stats.lls_error_count, total_msgs)))
301✔
551
            .append(lnav::roles::warning(humanize::sparkline(
903✔
552
                row.or_value.otr_level_stats.lls_warning_count, total_msgs)))
301✔
553
            .append("  ")
301✔
554
            .append(lnav::roles::identifier(truncated_name))
602✔
555
            .append(this->gs_opid_width
903✔
556
                        - utf8_string_length(truncated_name)
602✔
557
                              .unwrapOr(this->gs_opid_width),
301✔
558
                    ' ')
559
            .append(row.or_description);
301✔
560
        this->gs_rendered_line.with_attr_for_all(
301✔
561
            VC_ROLE.value(role_t::VCR_COMMENT));
602✔
562

563
        value_out = this->gs_rendered_line.get_string();
301✔
564
    }
301✔
565

566
    return {};
301✔
567
}
568

569
void
570
timeline_source::text_attrs_for_line(textview_curses& tc,
301✔
571
                                     int line,
572
                                     string_attrs_t& value_out)
573
{
574
    if (line < (ssize_t) this->gs_time_order.size()) {
301✔
575
        const auto& row = this->gs_time_order[line].get();
301✔
576

577
        value_out = this->gs_rendered_line.get_attrs();
301✔
578

579
        auto lr = line_range{-1, -1, line_range::unit::codepoint};
301✔
580
        auto sel_bounds = this->get_time_bounds_for(tc.get_selection().value_or(0_vl));
301✔
581

582
        if (row.or_value.otr_range.tr_begin <= sel_bounds.second
301✔
583
            && sel_bounds.first <= row.or_value.otr_range.tr_end)
301✔
584
        {
585
            static const int INDENT = 22;
586

587
            auto width = tc.get_dimensions().second;
301✔
588

589
            if (width > INDENT) {
301✔
590
                width -= INDENT;
301✔
591
                double span
301✔
592
                    = sel_bounds.second.tv_sec - sel_bounds.first.tv_sec;
301✔
593
                double per_ch = span / (double) width;
301✔
594

595
                if (row.or_value.otr_range.tr_begin <= sel_bounds.first) {
301✔
596
                    lr.lr_start = INDENT;
×
597
                } else {
598
                    auto start_diff = row.or_value.otr_range.tr_begin.tv_sec
301✔
599
                        - sel_bounds.first.tv_sec;
301✔
600

601
                    lr.lr_start = INDENT + start_diff / per_ch;
301✔
602
                }
603

604
                if (sel_bounds.second < row.or_value.otr_range.tr_end) {
301✔
605
                    lr.lr_end = -1;
×
606
                } else {
607
                    auto end_diff = row.or_value.otr_range.tr_end.tv_sec
301✔
608
                        - sel_bounds.first.tv_sec;
301✔
609

610
                    lr.lr_end = INDENT + end_diff / per_ch;
301✔
611
                    if (lr.lr_start == lr.lr_end) {
301✔
612
                        lr.lr_end += 1;
262✔
613
                    }
614
                }
615

616
                auto block_attrs = text_attrs::with_reverse();
301✔
617
                value_out.emplace_back(lr, VC_STYLE.value(block_attrs));
301✔
618
            }
301✔
619
        }
620
        auto alt_row_index = line % 4;
301✔
621
        if (alt_row_index == 2 || alt_row_index == 3) {
301✔
622
            value_out.emplace_back(line_range{0, -1},
147✔
623
                                   VC_ROLE.value(role_t::VCR_ALT_ROW));
294✔
624
        }
625
    }
626
}
301✔
627

628
size_t
629
timeline_source::text_size_for_line(textview_curses& tc,
×
630
                                    int line,
631
                                    text_sub_source::line_flags_t raw)
632
{
633
    return this->gs_total_width;
×
634
}
635

636
bool
637
timeline_source::rebuild_indexes()
23✔
638
{
639
    auto& bm = this->tss_view->get_bookmarks();
23✔
640
    auto& bm_errs = bm[&textview_curses::BM_ERRORS];
23✔
641
    auto& bm_warns = bm[&textview_curses::BM_WARNINGS];
23✔
642

643
    bm_errs.clear();
23✔
644
    bm_warns.clear();
23✔
645

646
    this->gs_lower_bound = {};
23✔
647
    this->gs_upper_bound = {};
23✔
648
    this->gs_opid_width = 0;
23✔
649
    this->gs_total_width = 0;
23✔
650
    this->gs_filtered_count = 0;
23✔
651
    this->gs_active_opids.clear();
23✔
652
    this->gs_descriptions.clear();
23✔
653
    this->gs_subid_map.clear();
23✔
654
    this->gs_allocator.reset();
23✔
655
    this->gs_preview_source.clear();
23✔
656
    this->gs_preview_rows.clear();
23✔
657
    this->gs_preview_status_source.get_description().clear();
23✔
658

659
    auto min_log_time_opt = this->get_min_row_time();
23✔
660
    auto max_log_time_opt = this->get_max_row_time();
23✔
661
    auto max_desc_width = size_t{0};
23✔
662

663
    for (const auto& [index, ld] : lnav::itertools::enumerate(this->gs_lss)) {
47✔
664
        if (ld->get_file_ptr() == nullptr) {
24✔
665
            continue;
1✔
666
        }
667
        if (!ld->is_visible()) {
24✔
668
            continue;
1✔
669
        }
670

671
        ld->get_file_ptr()->enable_cache();
23✔
672
        auto format = ld->get_file_ptr()->get_format();
23✔
673
        safe::ReadAccess<logfile::safe_opid_state> r_opid_map(
674
            ld->get_file_ptr()->get_opids());
23✔
675
        for (const auto& pair : r_opid_map->los_opid_ranges) {
1,027✔
676
            auto& otr = pair.second;
1,004✔
677
            auto active_iter = this->gs_active_opids.find(pair.first);
1,004✔
678
            if (active_iter == this->gs_active_opids.end()) {
1,004✔
679
                auto opid = pair.first.to_owned(this->gs_allocator);
1,004✔
680
                auto active_emp_res = this->gs_active_opids.emplace(
2,008✔
681
                    opid,
682
                    opid_row{
1,004✔
683
                        opid,
684
                        otr,
685
                        string_fragment::invalid(),
686
                    });
687
                active_iter = active_emp_res.first;
1,004✔
688
            } else {
689
                active_iter->second.or_value |= otr;
×
690
            }
691

692
            auto& row = active_iter->second;
1,004✔
693
            for (auto& sub : active_iter->second.or_value.otr_sub_ops) {
1,004✔
694
                auto subid_iter = this->gs_subid_map.find(sub.ostr_subid);
×
695

696
                if (subid_iter == this->gs_subid_map.end()) {
×
697
                    subid_iter = this->gs_subid_map
×
698
                                     .emplace(sub.ostr_subid.to_owned(
×
699
                                                  this->gs_allocator),
×
700
                                              true)
×
701
                                     .first;
702
                }
703
                sub.ostr_subid = subid_iter->first;
×
704
                if (sub.ostr_subid.length()
×
705
                    > active_iter->second.or_max_subid_width)
×
706
                {
707
                    active_iter->second.or_max_subid_width
×
708
                        = sub.ostr_subid.length();
×
709
                }
710
            }
711

712
            if (otr.otr_description.lod_id) {
1,004✔
713
                auto desc_id = otr.otr_description.lod_id.value();
996✔
714
                auto desc_def_iter
715
                    = format->lf_opid_description_def->find(desc_id);
996✔
716

717
                if (desc_def_iter == format->lf_opid_description_def->end()) {
996✔
718
                    log_error("cannot find description: %s",
×
719
                              active_iter->first.data());
720
                } else {
721
                    auto desc_key
722
                        = opid_description_def_key{format->get_name(), desc_id};
996✔
723
                    auto desc_defs_iter
724
                        = row.or_description_defs.odd_defs.find(desc_key);
996✔
725
                    if (desc_defs_iter
996✔
726
                        == row.or_description_defs.odd_defs.end())
996✔
727
                    {
728
                        row.or_description_defs.odd_defs.insert(
1,992✔
729
                            desc_key, desc_def_iter->second);
996✔
730
                    }
731

732
                    auto& all_descs = active_iter->second.or_descriptions;
996✔
733
                    auto& curr_desc_m = all_descs[desc_key];
996✔
734
                    const auto& new_desc_v = otr.otr_description.lod_elements;
996✔
735

736
                    for (const auto& desc_pair : new_desc_v) {
1,992✔
737
                        curr_desc_m[desc_pair.first] = desc_pair.second;
996✔
738
                    }
739
                }
740
            } else {
741
                ensure(otr.otr_description.lod_elements.empty());
8✔
742
            }
743
            active_iter->second.or_value.otr_description.lod_elements.clear();
1,004✔
744
        }
745

746
        if (this->gs_index_progress) {
23✔
747
            switch (this->gs_index_progress(
×
748
                progress_t{index, this->gs_lss.file_count()}))
×
749
            {
750
                case lnav::progress_result_t::ok:
×
751
                    break;
×
752
                case lnav::progress_result_t::interrupt:
×
753
                    return false;
×
754
            }
755
        }
756
    }
23✔
757
    if (this->gs_index_progress) {
23✔
758
        this->gs_index_progress(std::nullopt);
×
759
    }
760

761
    size_t filtered_in_count = 0;
23✔
762
    for (const auto& filt : this->tss_filters) {
25✔
763
        if (!filt->is_enabled()) {
2✔
764
            continue;
×
765
        }
766
        if (filt->get_type() == text_filter::INCLUDE) {
2✔
767
            filtered_in_count += 1;
1✔
768
        }
769
    }
770
    this->gs_filter_hits = {};
23✔
771
    this->gs_time_order.clear();
23✔
772
    this->gs_time_order.reserve(this->gs_active_opids.size());
23✔
773
    for (auto& pair : this->gs_active_opids) {
1,027✔
774
        auto& otr = pair.second.or_value;
1,004✔
775
        std::string full_desc;
1,004✔
776
        const auto& desc_defs = pair.second.or_description_defs.odd_defs;
1,004✔
777
        for (auto& desc : pair.second.or_descriptions) {
2,000✔
778
            auto desc_def_iter = desc_defs.find(desc.first);
996✔
779
            if (desc_def_iter == desc_defs.end()) {
996✔
780
                continue;
×
781
            }
782
            const auto& desc_def = desc_def_iter->second;
996✔
783
            full_desc = desc_def.to_string(desc.second);
996✔
784
        }
785
        pair.second.or_descriptions.clear();
1,004✔
786
        auto full_desc_sf = string_fragment::from_str(full_desc);
1,004✔
787
        auto desc_sf_iter = this->gs_descriptions.find(full_desc_sf);
1,004✔
788
        if (desc_sf_iter == this->gs_descriptions.end()) {
1,004✔
789
            full_desc_sf = string_fragment::from_str(full_desc).to_owned(
2,008✔
790
                this->gs_allocator);
1,004✔
791
        }
792
        pair.second.or_description = full_desc_sf;
1,004✔
793

794
        shared_buffer sb_opid;
1,004✔
795
        shared_buffer_ref sbr_opid;
1,004✔
796
        sbr_opid.share(
1,004✔
797
            sb_opid, pair.second.or_name.data(), pair.second.or_name.length());
1,004✔
798
        shared_buffer sb_desc;
1,004✔
799
        shared_buffer_ref sbr_desc;
1,004✔
800
        sbr_desc.share(sb_desc, full_desc.c_str(), full_desc.length());
1,004✔
801
        if (this->tss_apply_filters) {
1,004✔
802
            auto filtered_in = false;
1,004✔
803
            auto filtered_out = false;
1,004✔
804
            for (const auto& filt : this->tss_filters) {
1,170✔
805
                if (!filt->is_enabled()) {
166✔
806
                    continue;
×
807
                }
808
                for (const auto sbr : {&sbr_opid, &sbr_desc}) {
498✔
809
                    if (filt->matches(std::nullopt, *sbr)) {
332✔
810
                        this->gs_filter_hits[filt->get_index()] += 1;
2✔
811
                        switch (filt->get_type()) {
2✔
812
                            case text_filter::INCLUDE:
1✔
813
                                filtered_in = true;
1✔
814
                                break;
1✔
815
                            case text_filter::EXCLUDE:
1✔
816
                                filtered_out = true;
1✔
817
                                break;
1✔
818
                            default:
×
819
                                break;
×
820
                        }
821
                    }
822
                }
823
            }
824

825
            if (min_log_time_opt
1,004✔
826
                && otr.otr_range.tr_end < min_log_time_opt.value())
1,004✔
827
            {
828
                filtered_out = true;
16✔
829
            }
830
            if (max_log_time_opt
1,004✔
831
                && max_log_time_opt.value() < otr.otr_range.tr_begin)
1,004✔
832
            {
833
                filtered_out = true;
16✔
834
            }
835

836
            if ((filtered_in_count > 0 && !filtered_in) || filtered_out) {
1,004✔
837
                this->gs_filtered_count += 1;
115✔
838
                continue;
115✔
839
            }
840
        }
841

842
        if (pair.second.or_name.length() > this->gs_opid_width) {
889✔
843
            this->gs_opid_width = pair.second.or_name.length();
20✔
844
        }
845
        if (full_desc.size() > max_desc_width) {
889✔
846
            max_desc_width = full_desc.size();
12✔
847
        }
848

849
        if (this->gs_lower_bound.tv_sec == 0
1,778✔
850
            || pair.second.or_value.otr_range.tr_begin < this->gs_lower_bound)
889✔
851
        {
852
            this->gs_lower_bound = pair.second.or_value.otr_range.tr_begin;
86✔
853
        }
854
        if (this->gs_upper_bound.tv_sec == 0
1,778✔
855
            || this->gs_upper_bound < pair.second.or_value.otr_range.tr_end)
889✔
856
        {
857
            this->gs_upper_bound = pair.second.or_value.otr_range.tr_end;
66✔
858
        }
859
        this->gs_time_order.emplace_back(pair.second);
889✔
860
    }
1,464✔
861
    std::stable_sort(this->gs_time_order.begin(),
23✔
862
                     this->gs_time_order.end(),
863
                     std::less<const opid_row>{});
864
    for (size_t lpc = 0; lpc < this->gs_time_order.size(); lpc++) {
912✔
865
        const auto& row = this->gs_time_order[lpc].get();
889✔
866
        if (row.or_value.otr_level_stats.lls_error_count > 0) {
889✔
867
            bm_errs.insert_once(vis_line_t(lpc));
19✔
868
        } else if (row.or_value.otr_level_stats.lls_warning_count > 0) {
870✔
869
            bm_warns.insert_once(vis_line_t(lpc));
×
870
        }
871
    }
872

873
    this->gs_opid_width = std::min(this->gs_opid_width, MAX_OPID_WIDTH);
23✔
874
    this->gs_total_width
875
        = std::max<size_t>(22 + this->gs_opid_width + max_desc_width,
46✔
876
                           1 + 16 + 5 + 8 + 5 + 16 + 1 /* header */);
23✔
877

878
    this->tss_view->set_needs_update();
23✔
879

880
    return true;
23✔
881
}
882

883
std::optional<vis_line_t>
884
timeline_source::row_for_time(struct timeval time_bucket)
11✔
885
{
886
    auto iter = this->gs_time_order.begin();
11✔
887
    while (true) {
888
        if (iter == this->gs_time_order.end()) {
79✔
889
            return std::nullopt;
2✔
890
        }
891

892
        if (iter->get().or_value.otr_range.contains_inclusive(time_bucket)) {
77✔
893
            break;
9✔
894
        }
895
        ++iter;
68✔
896
    }
897

898
    auto closest_iter = iter;
9✔
899
    auto closest_diff = time_bucket - iter->get().or_value.otr_range.tr_begin;
9✔
900
    for (; iter != this->gs_time_order.end(); ++iter) {
18✔
901
        if (time_bucket < iter->get().or_value.otr_range.tr_begin) {
14✔
902
            break;
5✔
903
        }
904
        if (!iter->get().or_value.otr_range.contains_inclusive(time_bucket)) {
9✔
905
            continue;
×
906
        }
907

908
        auto diff = time_bucket - iter->get().or_value.otr_range.tr_begin;
9✔
909
        if (diff < closest_diff) {
9✔
910
            closest_iter = iter;
×
911
            closest_diff = diff;
×
912
        }
913

914
        for (const auto& sub : iter->get().or_value.otr_sub_ops) {
9✔
915
            if (!sub.ostr_range.contains_inclusive(time_bucket)) {
×
916
                continue;
×
917
            }
918

919
            diff = time_bucket - sub.ostr_range.tr_begin;
×
920
            if (diff < closest_diff) {
×
921
                closest_iter = iter;
×
922
                closest_diff = diff;
×
923
            }
924
        }
925
    }
926

927
    return vis_line_t(std::distance(this->gs_time_order.begin(), closest_iter));
18✔
928
}
929

930
std::optional<text_time_translator::row_info>
931
timeline_source::time_for_row(vis_line_t row)
26✔
932
{
933
    if (row >= this->gs_time_order.size()) {
26✔
934
        return std::nullopt;
×
935
    }
936

937
    const auto& otr = this->gs_time_order[row].get().or_value;
26✔
938

939
    if (this->tss_view->get_selection() == row) {
26✔
940
        auto ov_sel = this->tss_view->get_overlay_selection();
26✔
941

942
        if (ov_sel && ov_sel.value() < otr.otr_sub_ops.size()) {
26✔
943
            return row_info{
×
944
                otr.otr_sub_ops[ov_sel.value()].ostr_range.tr_begin,
×
945
                row,
946
            };
947
        }
948
    }
949

950
    auto preview_selection = this->gs_preview_view.get_selection();
26✔
951
    if (!preview_selection) {
26✔
952
        return std::nullopt;
×
953
    }
954
    if (preview_selection < this->gs_preview_rows.size()) {
26✔
955
        return this->gs_preview_rows[preview_selection.value()];
×
956
    }
957

958
    return row_info{
52✔
959
        otr.otr_range.tr_begin,
960
        row,
961
    };
52✔
962
}
963

964
size_t
965
timeline_source::text_line_width(textview_curses& curses)
5,413✔
966
{
967
    return this->gs_total_width;
5,413✔
968
}
969

970
void
971
timeline_source::text_selection_changed(textview_curses& tc)
5✔
972
{
973
    static const size_t MAX_PREVIEW_LINES = 200;
974

975
    auto sel = tc.get_selection();
5✔
976

977
    this->gs_preview_source.clear();
5✔
978
    this->gs_preview_rows.clear();
5✔
979
    if (!sel || sel.value() >= this->gs_time_order.size()) {
5✔
980
        return;
5✔
981
    }
982

983
    const auto& row = this->gs_time_order[sel.value()].get();
×
984
    auto low_tv = row.or_value.otr_range.tr_begin;
×
985
    auto high_tv = row.or_value.otr_range.tr_end;
×
986
    auto id_sf = row.or_name;
×
987
    auto level_stats = row.or_value.otr_level_stats;
×
988
    auto ov_sel = tc.get_overlay_selection();
×
989
    if (ov_sel) {
×
990
        const auto& sub = row.or_value.otr_sub_ops[ov_sel.value()];
×
991
        id_sf = sub.ostr_subid;
×
992
        low_tv = sub.ostr_range.tr_begin;
×
993
        high_tv = sub.ostr_range.tr_end;
×
994
        level_stats = sub.ostr_level_stats;
×
995
    }
996
    high_tv.tv_sec += 1;
×
997
    auto low_vl = this->gs_lss.row_for_time(low_tv);
×
998
    auto high_vl = this->gs_lss.row_for_time(high_tv).value_or(
×
999
        vis_line_t(this->gs_lss.text_line_count()));
×
1000

1001
    if (!low_vl) {
×
1002
        return;
×
1003
    }
1004

1005
    auto preview_content = attr_line_t();
×
1006
    auto msgs_remaining = size_t{MAX_PREVIEW_LINES};
×
1007
    auto win = this->gs_lss.window_at(low_vl.value(), high_vl);
×
1008
    auto id_hash = row.or_name.hash();
×
NEW
1009
    for (const auto& msg_line : *win) {
×
1010
        if (!msg_line.get_logline().match_opid_hash(id_hash)) {
×
1011
            continue;
×
1012
        }
1013

1014
        const auto& lvv = msg_line.get_values();
×
1015
        if (!lvv.lvv_opid_value) {
×
1016
            continue;
×
1017
        }
1018
        auto opid_sf = lvv.lvv_opid_value.value();
×
1019

1020
        if (opid_sf == row.or_name) {
×
1021
            std::vector<attr_line_t> rows_al(msg_line.get_line_count());
×
1022

1023
            auto cl = this->gs_lss.at(msg_line.get_vis_line());
×
1024
            this->gs_log_view.listview_value_for_rows(
×
1025
                this->gs_log_view, msg_line.get_vis_line(), rows_al);
×
1026

1027
            for (const auto& row_al : rows_al) {
×
1028
                this->gs_preview_rows.emplace_back(
×
1029
                    msg_line.get_logline().get_timeval(), cl);
×
1030
                ++cl;
×
1031
                preview_content.append(row_al).append("\n");
×
1032
            }
1033
            msgs_remaining -= 1;
×
1034
            if (msgs_remaining == 0) {
×
1035
                break;
×
1036
            }
1037
        }
1038
    }
1039

1040
    this->gs_preview_source.replace_with(preview_content);
×
1041
    this->gs_preview_view.set_selection(0_vl);
×
1042
    this->gs_preview_status_source.get_description().set_value(
×
1043
        " ID %.*s", id_sf.length(), id_sf.data());
1044
    auto err_count = level_stats.lls_error_count;
×
1045
    if (err_count == 0) {
×
1046
        this->gs_preview_status_source
×
1047
            .statusview_value_for_field(timeline_status_source::TSF_ERRORS)
×
1048
            .set_value("");
×
1049
    } else if (err_count > 1) {
×
1050
        this->gs_preview_status_source
×
1051
            .statusview_value_for_field(timeline_status_source::TSF_ERRORS)
×
1052
            .set_value("%'d errors", err_count);
×
1053
    } else {
1054
        this->gs_preview_status_source
×
1055
            .statusview_value_for_field(timeline_status_source::TSF_ERRORS)
×
1056
            .set_value("%'d error", err_count);
×
1057
    }
1058
    this->gs_preview_status_source
×
1059
        .statusview_value_for_field(timeline_status_source::TSF_TOTAL)
×
1060
        .set_value("%'d messages ", level_stats.lls_total_count);
×
1061
}
1062

1063
void
1064
timeline_source::text_filters_changed()
12✔
1065
{
1066
    this->rebuild_indexes();
12✔
1067
    this->tss_view->reload_data();
12✔
1068
    this->tss_view->redo_search();
12✔
1069
}
12✔
1070

1071
int
1072
timeline_source::get_filtered_count() const
45✔
1073
{
1074
    return this->gs_filtered_count;
45✔
1075
}
1076

1077
int
1078
timeline_source::get_filtered_count_for(size_t filter_index) const
×
1079
{
1080
    return this->gs_filter_hits[filter_index];
×
1081
}
1082

1083
static const std::vector<breadcrumb::possibility>&
1084
timestamp_poss()
×
1085
{
1086
    const static std::vector<breadcrumb::possibility> retval = {
1087
        breadcrumb::possibility{"-1 day"},
1088
        breadcrumb::possibility{"-1h"},
1089
        breadcrumb::possibility{"-30m"},
1090
        breadcrumb::possibility{"-15m"},
1091
        breadcrumb::possibility{"-5m"},
1092
        breadcrumb::possibility{"-1m"},
1093
        breadcrumb::possibility{"+1m"},
1094
        breadcrumb::possibility{"+5m"},
1095
        breadcrumb::possibility{"+15m"},
1096
        breadcrumb::possibility{"+30m"},
1097
        breadcrumb::possibility{"+1h"},
1098
        breadcrumb::possibility{"+1 day"},
1099
    };
1100

1101
    return retval;
×
1102
}
1103

1104
void
1105
timeline_source::text_crumbs_for_line(int line,
×
1106
                                      std::vector<breadcrumb::crumb>& crumbs)
1107
{
1108
    text_sub_source::text_crumbs_for_line(line, crumbs);
×
1109

1110
    if (line >= this->gs_time_order.size()) {
×
1111
        return;
×
1112
    }
1113

1114
    const auto& row = this->gs_time_order[line].get();
×
1115
    char ts[64];
1116

1117
    sql_strftime(ts, sizeof(ts), row.or_value.otr_range.tr_begin, 'T');
×
1118

1119
    crumbs.emplace_back(std::string(ts),
×
1120
                        timestamp_poss,
1121
                        [ec = this->gs_exec_context](const auto& ts) {
×
1122
                            auto cmd
×
1123
                                = fmt::format(FMT_STRING(":goto {}"),
×
1124
                                              ts.template get<std::string>());
1125
                            ec->execute(INTERNAL_SRC_LOC, cmd);
×
1126
                        });
×
1127
    crumbs.back().c_expected_input
×
1128
        = breadcrumb::crumb::expected_input_t::anything;
×
1129
    crumbs.back().c_search_placeholder = "(Enter an absolute or relative time)";
×
1130
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc