Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,29 @@ A failed unit is already known: systemd noticed, wrote it down, and stopped tryi

`ignore` takes exact names rather than patterns — a pattern that quietly grows to cover a unit somebody cared about is how this kind of list stops being trustworthy. Units systemd could not even load are counted separately from units that ran and failed, because they are a different problem: a typo in a name, a dropped file.

#### needrestart

A library was replaced under a running process, and the process is still using the copy that was deleted. Nothing fails, nothing is logged, and the fix that was supposed to be applied is not applied until somebody restarts it.

```json
{
"enabled" : true,
"needrestart" : "/usr/sbin/needrestart",
"ignore" : ["wigo.service"],
"kernel_status" : 200,
"services_status" : 101,
"timeout" : 30
}
```

**Two different things, told apart.** A kernel that has been replaced needs a reboot, which needs a window — that is a WARNING. A handful of services holding stale libraries need a restart, which is a Tuesday afternoon — that is INFO, and the message **names them**, since knowing that *something* needs restarting is not actionable.

`ignore` defaults to wigo itself: wigo restarting wigo to tell you it restarted is noise, and it would report itself forever after every upgrade of itself.

The counts are read from the plugin's perfdata rather than from its exit code. With both checks on, an exit of 1 may be a kernel ABI change, or services holding stale libraries, or both, and the code alone cannot say which.

`UNKN` — which is what needrestart answers when it is not running as root — is reported as the probe failing, not as nothing to restart. Not being able to look is not the same as having looked.

#### check_dns

When name resolution stops, everything on the machine breaks at once and nothing says why: connections do not fail, they hang, and the failure surfaces as every *other* service being slow.
Expand Down Expand Up @@ -472,6 +495,18 @@ A recheck asked for by hand takes whichever is shorter, the configured timeout o

The two `POST` endpoints return **403** unless `AllowWriteActions` is set in the `[Http]` section. They act on the probes directory directly, so the change takes effect on the next cycle without a restart, and it survives one.

**A host that pushes is governed by `AllowRemoteControl`, not by that.** Three different things can make a host read only from here, and each is fixed in a different file:

| what refuses | where to fix it |
|---|---|
| the caller's role | sign in, or present an operator token |
| this host's own writes | `AllowWriteActions` in `[Http]`, on that host |
| a pushing client that never opted in | `AllowRemoteControl` in `[PushClient]`, on the client |

The API says which, in `ReadOnlyReason` on the schedule, because only it can tell them apart — the interface sees one boolean. It used to print "set AllowWriteActions in `[Http]`" for all three, which sent anyone with a push client editing the wrong file on the wrong machine.

A client reports `AllowRemoteControl` on **every** push, so opening it takes effect on the next one — ten seconds by default, with nothing to do on the master.

A probe must be installed to be acted upon: a name that exists nowhere under `probes/` is refused.

**Disabling never destroys anything.** A schedule is usually a symlink into `examples/`, where the probe itself stays, so the symlink is simply removed. But an administrator may have dropped a script straight into an interval directory, or linked to one outside the probes tree — deleting that would be the only copy gone, and the probe would not even be listed any more, so there would be no way to turn it back on. In that case the entry is moved into `examples/` instead. Either way the probe ends up installed and unscheduled, which is what disabled means.
Expand Down Expand Up @@ -574,7 +609,11 @@ It is now written to the SQLite that is already there, bounded by `MetricsRetent

Points are **bucketed**: a week at one point a minute is ten thousand points per series, which no browser should be asked to draw. Each bucket carries its average *and* the range it covers, so the spike that woke somebody up is still visible after being averaged.

**Each wigo keeps its own history, and only its own.** A master reads a remote's through that remote's API, the same way it reads its schedule — storing the fleet's series on the master as well would write everything twice and make its database grow with the size of the fleet, which is the thing that pushes people towards a separate stack. A host that pushes rather than being polled cannot be asked, and says so.
**A wigo keeps its own history**, and a master reads a *polled* remote's through that remote's API, the same way it reads its schedule. Storing the whole fleet's series on the master would write everything twice and make its database grow with the size of the fleet, which is the thing that pushes people towards a separate stack.

**With one exception**, and it is not a softening of that rule but the only place it cannot hold: a host that **pushes** cannot be asked anything — it sits behind a NAT. Its measurements arrive with every push and used to be dropped, so those hosts had no graphs at all and the screen answered a 501 explaining why. They are kept now, under the name of the host they came from. The growth is bounded by the number of pushing clients rather than by the fleet, and those clients are precisely the ones with no other way of being read.

A client pushes far more often than its probes run — every ten seconds against every minute is a normal pairing — and every push carries the same result again. Only a measurement newer than the last one seen is written, so one probe run is one row rather than six. Without that, the master's database would grow at the push rate for a probe that answered once.

Nothing about the monitoring depends on this table: losing it loses history and nothing else.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
1.0.1
6 changes: 5 additions & 1 deletion etc/conf.d/needrestart.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"enabled" : true,
"needrestart" : "/usr/sbin/needrestart"
"needrestart" : "/usr/sbin/needrestart",
"ignore" : [ "wigo.service" ],
"kernel_status" : 200,
"services_status" : 101,
"timeout" : 30
}
193 changes: 176 additions & 17 deletions probes/examples/needrestart
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
#!/usr/bin/perl

# A library was replaced under a running process, and the process is still using
# the copy that was deleted. Nothing fails, nothing is logged, and the fix that
# was supposed to be applied is not applied until somebody restarts it.
#
# Two very different things are reported here, and they used not to be told
# apart. A kernel that has been replaced needs a reboot, which needs a window ;
# a handful of services holding stale libraries need a restart, which is a
# Tuesday afternoon. The first is a warning, the second is worth knowing.

use strict;
use warnings;

Expand All @@ -13,6 +22,19 @@ use Wigo::Probe qw/:all/;

my $conf = {
'needrestart' => '/usr/sbin/needrestart',

# Services never worth reporting. Wigo restarting wigo to tell you it
# restarted is noise, and the probe would report itself forever after any
# upgrade of itself.
'ignore' => ['wigo.service'],

# A kernel has to wait for a window, so it is a warning. Services are a
# routine restart : worth knowing, not worth waking anyone.
'kernel_status' => 200,
'services_status' => 101,

# Reading /proc for every process takes a moment on a busy machine.
'timeout' => 30,
};

init( config => $conf );
Expand All @@ -26,32 +48,169 @@ unless ( -x $needrestart )
}

###
# GET STATUS LIST
# ASK
###

my $outNeedRestart = `$needrestart -k -p`;
my $returnCode = $?;
my @lines = split("\n", $outNeedRestart);
detail->{'output'} = $lines[0];
detail->{'returncode'} = $returnCode;
if($returnCode == 512)
# -k for the kernel, -l for the libraries : without -l nothing is ever said
# about services, which is most of what this probe is for.
my $output;
eval {
local $SIG{"ALRM"} = sub { die "timed out\n" };
alarm config->{'timeout'};

$output = `$needrestart -p -k -l 2>&1`;

alarm 0;
};

if ($@)
{
status 200;
message "Restart needed";
my $failure = $@;
chomp $failure;

status 500;
message "Cannot ask needrestart : $failure";
output 1;
}
elsif($returnCode == 256)

my @lines = split( /\n/, $output || "" );

# A non zero exit means something needs restarting, which is the answer rather
# than a failure. Only a summary we cannot read is one.
my ($summary) = grep { /^(OK|WARN|CRIT|UNKN) - / } @lines;

unless ( defined $summary )
{
my $first = $lines[0] // "no output";
$first =~ s/^\s+|\s+$//g;

status 500;
message "Cannot read what needrestart answered : $first";
output 1;
}

# UNKN is needrestart saying it could not look -- usually because it is not
# running as root. Reading its counts then would answer "nothing to restart"
# about a machine nobody examined, which is the one answer that must never be
# guessed.
if ( $summary =~ /^UNKN - (.*)$/ )
{
my $why = $1;
$why =~ s/\|.*$//;
$why =~ s/^\s+|\s+$//g;

status 500;
message "needrestart could not look : $why";
output 1;
}

detail->{'summary'} = $summary;

###
# WHAT IT FOUND
###

# The counts come from the perfdata rather than from the exit code : with both
# checks on, an exit of 1 may be a kernel abi change, or services holding stale
# libraries, or both, and the code alone cannot say which.
my ($perfdata) = $summary =~ /\|(.*)$/;
$perfdata = "" unless defined $perfdata;

my $kernel = perf_count( $perfdata, 'Kernel' );
my $services = perf_count( $perfdata, 'Services' );

my %ignored = map { $_ => 1 } @{ config->{'ignore'} || [] };
my @toRestart = grep { !$ignored{$_} } listed_under( \@lines, 'Services' );

detail->{'kernel_restart_needed'} = $kernel > 0 ? 1 : 0;
detail->{'services_restart_needed'} = \@toRestart;

add_metric { "Tags" => { 'metric' => 'services' }, "Value" => scalar(@toRestart) };
add_metric { "Tags" => { 'metric' => 'kernel' }, "Value" => $kernel > 0 ? 1 : 0 };

###
# SAY IT
###

if ( $kernel > 0 )
{
message "Restart not required";
status config->{'kernel_status'};

my $also = @toRestart
? sprintf( ", and %d service%s to restart", scalar(@toRestart), @toRestart > 1 ? "s" : "" )
: "";

message "The running kernel has been replaced, a reboot is needed$also";
output 0;
}
elsif($returnCode)

if (@toRestart)
{
status 500;
message "Error while getting restart status";
output 1;
status config->{'services_status'};
message sprintf( "%d service%s still using replaced libraries : %s",
scalar(@toRestart), @toRestart > 1 ? "s are" : " is", join( ", ", @toRestart ) );
output 0;
}
else

# Services were reported but every one of them was ignored : that is not the
# same as nothing having been found, and saying "nothing to restart" would hide
# a list somebody chose to silence.
if ( $services > 0 )
{
message "Restart not needed";
message "Nothing to restart, apart from what is ignored";
output 0;
}

message "Nothing to restart";
output 0;

###
# The number behind a name in the perfdata : "Services=3;;0;0" is three.
###
sub perf_count
{
my ( $perfdata, $name ) = @_;

my ($value) = $perfdata =~ /\b\Q$name\E=(\d+)/;

return defined $value ? $value : 0;
}

###
# The entries of one section of the listing.
#
# needrestart prints up to three of them -- Services, Containers and Sessions --
# each a header followed by "- name" lines. Reading to the end of the output
# instead would fold a container, or a session that looks like "someone @ pts/0",
# into the list of services.
###
sub listed_under
{
my ( $lines, $section ) = @_;

my @found = ();
my $inside = 0;

foreach my $line (@$lines)
{
if ( $line =~ /^([A-Z][a-z]+):\s*$/ )
{
$inside = ( $1 eq $section );
next;
}

next unless $inside;

# An entry, and nothing else : anything that is not one ends the section
# as surely as a header would.
if ( my ($entry) = $line =~ /^-\s+(\S.*?)\s*$/ )
{
push @found, $entry;
next;
}

$inside = 0;
}

return @found;
}
53 changes: 46 additions & 7 deletions src/public/src/js/components/StatusTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@

<div
v-if="hovered"
ref="tooltip"
class="timeline-tooltip card shadow-sm"
:style="tooltipStyle"
>
Expand All @@ -136,7 +137,7 @@
</template>

<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import api from "../api/client.js";
import StatusBadge from "./StatusBadge.vue";
import { getLevel } from "../utils/status.js";
Expand Down Expand Up @@ -295,13 +296,41 @@ const xTicks = computed(() => {
return ticks;
});

const tooltip = ref(null);
const tooltipWidth = ref(0);

// Mesurée après le rendu : sa largeur vient de son contenu, et le contenu
// change à chaque segment survolé.
watch(hovered, async () => {
await nextTick();
tooltipWidth.value = tooltip.value ? tooltip.value.offsetWidth : 0;
});

/**
* Centrée sur le curseur, et rentrée dans le cadre.
*
* On calcule en pixels plutôt que de basculer d'un côté à l'autre au-delà d'un
* seuil : sur un écran étroit l'infobulle fait toute la largeur du cadre, et
* aucun seuil ne la fait tenir. La ramener dans les bornes est la seule règle
* qui marche aux deux tailles.
*
* Rien ici ne touche à sa largeur -- c'était le défaut d'avant, où un bloc posé
* sur `left` sans largeur se faisait comprimer par ce qui restait à sa droite.
*/
const tooltipStyle = computed(() => {
const ratio = hovered.value ? hovered.value.x / width.value : 0;
if (!hovered.value) return {};

return {
left: `${ratio * 100}%`,
transform: ratio > 0.6 ? "translate(-100%, 0)" : "translate(0, 0)",
};
const tip = tooltipWidth.value;
const room = width.value;

// Tant qu'on ne l'a pas mesurée, on la pose au curseur : un instant à la
// mauvaise place vaut mieux qu'un saut depuis le coin.
if (!tip) return { left: `${hovered.value.x}px` };

const wanted = hovered.value.x - tip / 2;
const left = Math.max(0, Math.min(wanted, room - tip));

return { left: `${left}px` };
});

function describeLevel(span) {
Expand Down Expand Up @@ -441,7 +470,17 @@ onUnmounted(() => {
position: absolute;
top: 2rem;
pointer-events: none;
min-width: 12rem;
z-index: 5;

/* Sa taille vient de son contenu, pas de sa position.

Sans largeur, un bloc absolu posé sur `left` se fait comprimer dans ce qui
reste à sa droite : la même infobulle faisait 64px de haut à gauche de la
frise et 152 à droite, le texte s'enroulant de plus en plus. Le
`translate(-100%)` la déplace bien de l'autre côté, mais un transform
n'affecte pas la mise en page -- la largeur était déjà écrasée. */
width: max-content;
min-width: 12rem;
max-width: min(24rem, 100%);
}
</style>
Loading
Loading