Skip to content

Commit 7d782f0

Browse files
ewelsbentshermanpditommasomarcodelapierre
authored andcommitted
Add colours to ansi logs (nextflow-io#4573)
Signed-off-by: Phil Ewels <[email protected]> Signed-off-by: Ben Sherman <[email protected]> Signed-off-by: Paolo Di Tommaso <[email protected]> Co-authored-by: Ben Sherman <[email protected]> Co-authored-by: Paolo Di Tommaso <[email protected]> Co-authored-by: Dr Marco Claudio De La Pierre <[email protected]> Signed-off-by: Niklas Schandry <[email protected]>
1 parent d9b9dd5 commit 7d782f0

File tree

4 files changed

+199
-49
lines changed

4 files changed

+199
-49
lines changed

build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ allprojects {
108108
}
109109

110110
// Documentation required libraries
111-
groovyDoc 'org.fusesource.jansi:jansi:1.11'
111+
groovyDoc 'org.fusesource.jansi:jansi:2.4.0'
112112
groovyDoc "org.apache.groovy:groovy-groovydoc:4.0.18"
113113
groovyDoc "org.apache.groovy:groovy-ant:4.0.18"
114114
}

modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy

+63-8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package nextflow.cli
1818

19+
20+
import static org.fusesource.jansi.Ansi.*
21+
1922
import java.nio.file.NoSuchFileException
2023
import java.nio.file.Path
2124
import java.util.regex.Pattern
@@ -45,6 +48,7 @@ import nextflow.secret.SecretsLoader
4548
import nextflow.util.CustomPoolFactory
4649
import nextflow.util.Duration
4750
import nextflow.util.HistoryFile
51+
import org.fusesource.jansi.AnsiConsole
4852
import org.yaml.snakeyaml.Yaml
4953
/**
5054
* CLI sub-command RUN
@@ -60,8 +64,8 @@ class CmdRun extends CmdBase implements HubOptions {
6064

6165
static final public List<String> VALID_PARAMS_FILE = ['json', 'yml', 'yaml']
6266

63-
static final public DSL2 = '2'
64-
static final public DSL1 = '1'
67+
static final public String DSL2 = '2'
68+
static final public String DSL1 = '1'
6569

6670
static {
6771
// install the custom pool factory for GPars threads
@@ -310,7 +314,7 @@ class CmdRun extends CmdBase implements HubOptions {
310314

311315
checkRunName()
312316

313-
log.info "N E X T F L O W ~ version ${BuildInfo.version}"
317+
printBanner()
314318
Plugins.init()
315319

316320
// -- specify the arguments
@@ -372,6 +376,37 @@ class CmdRun extends CmdBase implements HubOptions {
372376
runner.execute(scriptArgs, this.entryName)
373377
}
374378

379+
protected void printBanner() {
380+
if( launcher.options.ansiLog ){
381+
// Plain header for verbose log
382+
log.debug "N E X T F L O W ~ version ${BuildInfo.version}"
383+
384+
// Fancy coloured header for the ANSI console output
385+
def fmt = ansi()
386+
fmt.a("\n")
387+
// Use exact colour codes so that they render the same on every terminal,
388+
// irrespective of terminal colour scheme.
389+
// Nextflow green RGB (13, 192, 157) and exact black text (0,0,0),
390+
// Apple Terminal only supports 256 colours, so use the closest match:
391+
// light sea green | #20B2AA | 38;5;0
392+
// Don't use black for text as terminals mess with this in their colour schemes.
393+
// Use very dark grey, which is more reliable.
394+
// Jansi library bundled in Jline can't do exact RGBs,
395+
// so just do the ANSI codes manually
396+
final BACKGROUND = "\033[1m\033[38;5;232m\033[48;5;43m"
397+
fmt.a("$BACKGROUND N E X T F L O W ").reset()
398+
399+
// Show Nextflow version
400+
fmt.a(Attribute.INTENSITY_FAINT).a(" ~ ").reset().a("version " + BuildInfo.version).reset()
401+
fmt.a("\n")
402+
AnsiConsole.out.println(fmt.eraseLine())
403+
}
404+
else {
405+
// Plain header to the console if ANSI is disabled
406+
log.info "N E X T F L O W ~ version ${BuildInfo.version}"
407+
}
408+
}
409+
375410
protected checkConfigEnv(ConfigMap config) {
376411
// Warn about setting NXF_ environment variables within env config scope
377412
final env = config.env as Map<String, String>
@@ -396,12 +431,32 @@ class CmdRun extends CmdBase implements HubOptions {
396431
NextflowMeta.instance.enableDsl(dsl)
397432
// -- show launch info
398433
final ver = NF.dsl2 ? DSL2 : DSL1
399-
final repo = scriptFile.repository ?: scriptFile.source
434+
final repo = scriptFile.repository ?: scriptFile.source.toString()
400435
final head = preview ? "* PREVIEW * $scriptFile.repository" : "Launching `$repo`"
401-
if( scriptFile.repository )
402-
log.info "${head} [$runName] DSL${ver} - revision: ${scriptFile.revisionInfo}"
403-
else
404-
log.info "${head} [$runName] DSL${ver} - revision: ${scriptFile.getScriptId()?.substring(0,10)}"
436+
final revision = scriptFile.repository
437+
? scriptFile.revisionInfo.toString()
438+
: scriptFile.getScriptId()?.substring(0,10)
439+
printLaunchInfo(ver, repo, head, revision)
440+
}
441+
442+
protected void printLaunchInfo(String ver, String repo, String head, String revision) {
443+
if( launcher.options.ansiLog ){
444+
log.debug "${head} [$runName] DSL${ver} - revision: ${revision}"
445+
446+
def fmt = ansi()
447+
fmt.a(" ┃ Launching").fg(Color.MAGENTA).a(" `$repo` ").reset()
448+
fmt.a(Attribute.INTENSITY_FAINT).a("[").reset()
449+
fmt.bold().fg(Color.CYAN).a(runName).reset()
450+
fmt.a(Attribute.INTENSITY_FAINT).a("]")
451+
fmt.a(" DSL${ver} - ")
452+
fmt.fg(Color.CYAN).a("revision: ").reset()
453+
fmt.fg(Color.CYAN).a(revision).reset()
454+
fmt.a("\n")
455+
AnsiConsole.out().println(fmt.eraseLine())
456+
}
457+
else {
458+
log.info "${head} [$runName] DSL${ver} - revision: ${revision}"
459+
}
405460
}
406461

407462
static String detectDslMode(ConfigMap config, String scriptText, Map sysEnv) {

modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy

+111-23
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package nextflow.trace
1818

19+
import java.util.regex.Pattern
20+
1921
import groovy.transform.CompileStatic
2022
import jline.TerminalFactory
2123
import nextflow.Session
@@ -85,6 +87,8 @@ class AnsiLogObserver implements TraceObserver {
8587

8688
private volatile int cols = 80
8789

90+
private volatile int rows = 24
91+
8892
private long startTimestamp
8993

9094
private long endTimestamp
@@ -181,7 +185,7 @@ class AnsiLogObserver implements TraceObserver {
181185
wait(200)
182186
}
183187
}
184-
//
188+
//
185189
final stats = statsObserver.getStats()
186190
renderProgress(stats)
187191
renderSummary(stats)
@@ -227,7 +231,7 @@ class AnsiLogObserver implements TraceObserver {
227231
protected String getExecutorName(String key) {
228232
session.getExecutorFactory().getDisplayName(key)
229233
}
230-
234+
231235
protected void renderExecutors(Ansi term) {
232236
int count=0
233237
def line = ''
@@ -237,7 +241,7 @@ class AnsiLogObserver implements TraceObserver {
237241
}
238242

239243
if( count ) {
240-
term.a("executor > " + line)
244+
term.a(Attribute.INTENSITY_FAINT).a("executor > " + line).reset()
241245
term.newline()
242246
}
243247
}
@@ -251,6 +255,7 @@ class AnsiLogObserver implements TraceObserver {
251255
}
252256

253257
cols = TerminalFactory.get().getWidth()
258+
rows = TerminalFactory.get().getHeight()
254259

255260
// calc max width
256261
final now = System.currentTimeMillis()
@@ -265,9 +270,25 @@ class AnsiLogObserver implements TraceObserver {
265270
lastWidthReset = now
266271

267272
// render line
273+
def renderedLines = 0
274+
def skippedLines = 0
268275
for( ProgressRecord entry : processes ) {
269-
term.a(line(entry))
270-
term.newline()
276+
// Only show line if we have space in the visible terminal area
277+
// or if the process has some submitted tasks
278+
if( renderedLines <= rows - 5 || entry.getTotalCount() > 0 ) {
279+
term.a(line(entry))
280+
term.newline()
281+
renderedLines += 1
282+
}
283+
// Process with no active tasks and we're out of screen space, skip
284+
else {
285+
skippedLines += 1
286+
}
287+
}
288+
// Tell the user how many processes without active tasks were hidden
289+
if( skippedLines > 0 ){
290+
term.a(Attribute.ITALIC).a(Attribute.INTENSITY_FAINT).a("Plus ").bold().a(skippedLines).reset()
291+
term.a(Attribute.ITALIC).a(Attribute.INTENSITY_FAINT).a(" more processes waiting for tasks…").reset().newline()
271292
}
272293
rendered = true
273294
}
@@ -322,7 +343,7 @@ class AnsiLogObserver implements TraceObserver {
322343
return
323344
if( enableSummary == null && delta <= 60*1_000 )
324345
return
325-
346+
326347
if( session.isSuccess() && stats.progressLength>0 ) {
327348
def report = ""
328349
report += "Completed at: ${new Date(endTimestamp).format('dd-MMM-yyyy HH:mm:ss')}\n"
@@ -350,13 +371,13 @@ class AnsiLogObserver implements TraceObserver {
350371
if( color ) fmt = fmt.fg(Color.DEFAULT)
351372
AnsiConsole.out.println(fmt.eraseLine())
352373
}
353-
374+
354375
protected void printAnsiLines(String lines) {
355376
final text = lines
356377
.replace('\r','')
357378
.replace(NEWLINE, ansi().eraseLine().toString() + NEWLINE)
358379
AnsiConsole.out.print(text)
359-
}
380+
}
360381

361382
protected String fmtWidth(String name, int width, int cols) {
362383
assert name.size() <= width
@@ -369,36 +390,103 @@ class AnsiLogObserver implements TraceObserver {
369390
}
370391

371392
protected String fmtChop(String str, int cols) {
393+
// Truncate the process name to fit the terminal width
372394
if( str.size() <= cols )
373395
return str
374-
return cols>3 ? str[0..(cols-3-1)] + '...' : str[0..cols-1]
396+
// Take the first 3 characters and the final chunk of text
397+
// eg. for: NFCORE_RNASEQ:RNASEQ:FASTQ_SUBSAMPLE_FQ_SALMON:FQ_SUBSAMPLE
398+
// truncate to: NFC…_SALMON:FQ_SUBSAMPLE
399+
return cols>5 ? str.take(3) + '' + str.takeRight(cols-1-3) : str[0..cols-1]
375400
}
376401

377-
protected String line(ProgressRecord stats) {
402+
private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/
403+
private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/
404+
405+
protected Ansi line(ProgressRecord stats) {
406+
final term = ansi()
378407
final float tot = stats.getTotalCount()
379408
final float com = stats.getCompletedCount()
409+
// Truncate or pad the label to the correct width
380410
final label = fmtWidth(stats.taskName, labelWidth, Math.max(cols-50, 5))
411+
// Break up the process label into components for styling. eg:
412+
// NFCORE_RNASEQ:RNASEQ:PREPARE_GENOME:GUNZIP_GTF (genes.gtf.gz)
413+
// labelTag = genes.gtf.gz
414+
// labelSpaces = whitespace padding after process name
415+
// labelFinalProcess = GUNZIP_GTF
416+
// labelNoFinalProcess = NFCORE_RNASEQ:RNASEQ:PREPARE_GENOME:
417+
final tagMatch = TAG_REGEX.matcher(label)
418+
final labelTag = tagMatch ? tagMatch.group(1) : ''
419+
final labelSpaces = tagMatch ? tagMatch.group(2) : ''
420+
final labelNoTag = LBL_REPLACE.matcher(label).replaceFirst("")
421+
final labelFinalProcess = labelNoTag.tokenize(':')[-1]
422+
final labelNoFinalProcess = labelFinalProcess.length() > 0 ? labelNoTag - labelFinalProcess : labelNoTag
381423
final hh = (stats.hash && tot>0 ? stats.hash : '-').padRight(9)
382424

383-
if( tot == 0 )
384-
return "[$hh] process > $label -"
385-
386425
final x = tot ? Math.floor(com / tot * 100f).toInteger() : 0
387-
final pct = "[${String.valueOf(x).padLeft(3)}%]".toString()
426+
// eg. 100% (whitespace padded for alignment)
427+
final pct = "${String.valueOf(x).padLeft(3)}%".toString()
428+
// eg. 1 of 1
429+
final numbs = " ${(int)com} of ${(int)tot}".toString()
430+
431+
// Task hash, eg: [fa/71091a]
432+
term.a(Attribute.INTENSITY_FAINT).a('[').reset()
433+
term.fg(Color.BLUE).a(hh).reset()
434+
term.a(Attribute.INTENSITY_FAINT).a('] ').reset()
435+
436+
// Only show 'process > ' if the terminal has lots of width
437+
if( cols > 180 )
438+
term.a(Attribute.INTENSITY_FAINT).a('process > ').reset()
439+
// Stem of process name, dim text
440+
term.a(Attribute.INTENSITY_FAINT).a(labelNoFinalProcess).reset()
441+
// Final process name, regular text
442+
term.a(labelFinalProcess)
443+
// Active process with a tag, eg: (genes.gtf.gz)
444+
if( labelTag ){
445+
// Tag in yellow, () dim but tag text regular
446+
term.fg(Color.YELLOW).a(Attribute.INTENSITY_FAINT).a(' (').reset()
447+
term.fg(Color.YELLOW).a(labelTag)
448+
term.a(Attribute.INTENSITY_FAINT).a(')').reset().a(labelSpaces)
449+
}
450+
451+
// No tasks
452+
if( tot == 0 ) {
453+
term.a(' -')
454+
return term
455+
}
388456

389-
final numbs = "${(int)com} of ${(int)tot}".toString()
390-
def result = "[${hh}] process > $label $pct $numbs"
457+
// Progress percentage, eg: [ 80%]
458+
if( cols > 120 ) {
459+
// Only show the percentage if we have lots of width
460+
// Percentage text in green if 100%, otherwise blue
461+
term.a(Attribute.INTENSITY_FAINT).a(' [').reset()
462+
.fg(pct == '100%' ? Color.GREEN : Color.BLUE).a(pct).reset()
463+
.a(Attribute.INTENSITY_FAINT).a(']').reset()
464+
}
465+
else {
466+
// If narrow terminal, show single pipe char instead of percentage to save space
467+
term.a(Attribute.INTENSITY_FAINT).a(' |').reset()
468+
}
469+
// Progress active task count, eg: 8 of 10
470+
term.a(numbs)
471+
472+
// Completed task counts and status
473+
// Dim text for cached, otherwise regular
391474
if( stats.cached )
392-
result += ", cached: $stats.cached"
475+
term.a(Attribute.INTENSITY_FAINT).a(", cached: $stats.cached").reset()
393476
if( stats.stored )
394-
result += ", stored: $stats.stored"
477+
term.a(", stored: $stats.stored")
395478
if( stats.failed )
396-
result += ", failed: $stats.failed"
479+
term.a(", failed: $stats.failed")
397480
if( stats.retries )
398-
result += ", retries: $stats.retries"
399-
if( stats.terminated && tot )
400-
result += stats.errored ? ' \u2718' : ' \u2714'
401-
return fmtChop(result, cols)
481+
term.a(", retries: $stats.retries")
482+
// Show red cross ('✘') or green tick ('✔') according to status
483+
if( stats.terminated && tot ) {
484+
if( stats.errored )
485+
term.fg(Color.RED).a(' \u2718').reset()
486+
else
487+
term.fg(Color.GREEN).a(' \u2714').reset()
488+
}
489+
return term
402490
}
403491

404492
@Override

0 commit comments

Comments
 (0)