Restore
Restore

Restore file to captured state

Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.

What will change
Preview -- nothing has been written yet.
Target file/var/www/vhosts/3dshawn.com/site1/dashboard.cgi
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-13 15:47:48
Captured SHA26965f2169c9b8656f9312f5f7525e7de9bcce97ad5cbd5d749f66f4ed62ea64
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size20548 bytes
Current SHA195dfc3bbe44
Same as captured?no -- restore will change it
Preflight compile-check ?
perl compiles cleanly — restore is safe to proceed.
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +20 additions, -98 deletions, 395 unchanged context lines.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- /dashboard.cgi
44#
55# Landing page. Shows portfolio-wide health at a glance:
66# * KPI tiles: file changes today | schema changes 7d | named releases | drift alerts
77# * Recent activity table (last 30 file + schema changes, merged, newest first)
88# * Named releases strip (last 5 bookmarks)
99# * Servers status strip
1010#======================================================================
1111use strict;
1212use warnings;
1313use CGI ();
1414use MODS::Config;
1515use MODS::DBConnect;
1616use MODS::Template;
1717use MODS::PageWrapper;
1818use MODS::Charts;
19use MODS::RangePicker;
2019
2120my $cfg = MODS::Config->new;
2221my $db = MODS::DBConnect->new;
2322my $tpl = MODS::Template->new;
2423my $wrap = MODS::PageWrapper->new;
2524
2625# Headers
2726$|=1;
2827print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
2928
3029my $dbh = $db->db_connect
3130 or die "DriftSense: cannot connect to storage DB. Check /etc/drift_sense/drift_sense.conf.\n";
32
33# ----- Unified range: BOTH file and schema tiles use the same window ----
34# RangePicker honors the ds_range localStorage persistence + defaults to
35# viewer-local "today" via the ds_tzo cookie.
36my $cgi_obj = CGI->new;
37my $r_pick = MODS::RangePicker::parse($cgi_obj);
38my $range_lower = $r_pick->{sql_lower_epoch};
39my $range_upper = $r_pick->{sql_upper_epoch};
40my $range_span = $range_upper - $range_lower;
41$range_span = 86400 if $range_span < 60; # sanity guard
42my $prev_upper = $range_lower;
43my $prev_lower = $range_lower - $range_span;
44# For linking to file_changes/schema_changes from the tile
45my $tr_qs = "tr=" . ($r_pick->{active_token} || 'today');
46my $R = {
47 sql_since => "FROM_UNIXTIME($range_lower)",
48 prev_sql => "FROM_UNIXTIME($prev_lower) AND date_time < FROM_UNIXTIME($prev_upper)",
49 prev_sql_schema
50 => "FROM_UNIXTIME($prev_lower) AND change_datetime < FROM_UNIXTIME($prev_upper)",
51 label => $r_pick->{range_label} || 'Today',
52 fc_qs => $tr_qs,
53 picker_html => MODS::RangePicker::picker_html(
54 action => '/dashboard.cgi',
55 range => $r_pick,
56 hidden => {},
57 ),
58};
5931
60# Per-status counts in the current range (baseline hidden).
61my $fc_by_status = $db->db_readwrite_multiple($dbh, qq~
62 SELECT status, COUNT(*) AS n
63 FROM FILE_CHANGES
64 WHERE date_time >= $R->{sql_since}
32# ----- KPI queries ----------------------------------------------------
33# Exclude is_baseline=1 rows -- those are initial-inventory captures
34# from a fresh monitor scan, not real "changes" the operator should see.
35my $file_today = $db->db_readwrite($dbh, q~
36 SELECT COUNT(*) AS n FROM FILE_CHANGES
37 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 1 DAY)
6538 AND is_baseline = 0
66 GROUP BY status
6739~, __FILE__, __LINE__);
68my %fc = (modified => 0, added => 0, deleted => 0);
69foreach my $r (@$fc_by_status) {
70 my $s = lc($r->{status} // '');
71 $fc{$s} = $r->{n} if exists $fc{$s};
72}
73my $fc_all = $fc{modified} + $fc{added} + $fc{deleted};
74
75my $file_today = { n => $fc_all };
76my $file_prev = $db->db_readwrite($dbh, qq~
40my $file_prev = $db->db_readwrite($dbh, q~
7741 SELECT COUNT(*) AS n FROM FILE_CHANGES
78 WHERE date_time >= $R->{prev_sql}
42 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 2 DAY)
43 AND date_time < DATE_SUB(NOW(), INTERVAL 1 DAY)
7944 AND is_baseline = 0
8045~, __FILE__, __LINE__);
8146
82my $schema_7d = $db->db_readwrite($dbh, qq~
47my $schema_7d = $db->db_readwrite($dbh, q~
8348 SELECT COUNT(*) AS n FROM SCHEMA_CHANGE
84 WHERE change_datetime >= $R->{sql_since}
85 AND is_baseline = 0
86~, __FILE__, __LINE__);
87
88# Per-status counts for the schema tile sub-grid (same window as file).
89my $sc_by_status = $db->db_readwrite_multiple($dbh, qq~
90 SELECT CASE
91 WHEN changes LIKE 'first snapshot%' THEN 'added'
92 WHEN changes LIKE 'table dropped%' THEN 'deleted'
93 ELSE 'modified'
94 END AS kind,
95 COUNT(*) AS n
96 FROM SCHEMA_CHANGE
97 WHERE change_datetime >= $R->{sql_since}
98 AND is_baseline = 0
99 GROUP BY kind
49 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 7 DAY)
10050~, __FILE__, __LINE__);
101my %sc = (modified => 0, added => 0, deleted => 0);
102foreach my $row (@$sc_by_status) {
103 my $k = lc($row->{kind} // '');
104 $sc{$k} = $row->{n} if exists $sc{$k};
105}
106my $sc_all = $sc{modified} + $sc{added} + $sc{deleted};
107my $schema_prev = $db->db_readwrite($dbh, qq~
51my $schema_prev = $db->db_readwrite($dbh, q~
10852 SELECT COUNT(*) AS n FROM SCHEMA_CHANGE
109 WHERE change_datetime >= $R->{prev_sql_schema}
110 AND is_baseline = 0
53 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 14 DAY)
54 AND change_datetime < DATE_SUB(NOW(), INTERVAL 7 DAY)
11155~, __FILE__, __LINE__);
11256
11357my $releases_ct = $db->db_readwrite($dbh, q~
11458 SELECT COUNT(*) AS n FROM NAMED_RELEASES
11559~, __FILE__, __LINE__);
11660
11761my $drifted_ct = $db->db_readwrite($dbh, q~
11862 SELECT COUNT(*) AS n FROM CONFIG_DRIFT_BASELINES WHERE is_drifted = 1
11963~, __FILE__, __LINE__);
12064
12165# ----- Recent activity (merged file + schema changes, newest first) --
12266my @file_recent = $db->db_readwrite_multiple($dbh, q~
12367 SELECT 'file' AS kind,
12468 file_changes_id AS id,
12569 file_name AS what,
12670 status AS detail,
12771 date_time AS ts,
12872 UNIX_TIMESTAMP(date_time) AS ts_epoch,
12973 server_id
13074 FROM FILE_CHANGES
13175 ORDER BY date_time DESC
13276 LIMIT 20
13377~, __FILE__, __LINE__);
13478my @schema_recent = $db->db_readwrite_multiple($dbh, q~
13579 SELECT 'schema' AS kind,
13680 schema_change_id AS id,
13781 CONCAT(database_name, '.', table_name) AS what,
13882 changes AS detail,
13983 change_datetime AS ts,
14084 UNIX_TIMESTAMP(change_datetime) AS ts_epoch,
14185 server_id
14286 FROM SCHEMA_CHANGE
14387 ORDER BY change_datetime DESC
14488 LIMIT 20
14589~, __FILE__, __LINE__);
14690my @recent = sort { ($b->{ts} || '') cmp ($a->{ts} || '') } (@file_recent, @schema_recent);
14791@recent = splice(@recent, 0, 20) if @recent > 20;
14892foreach my $r (@recent) {
14993 my $d = $r->{detail} // '';
15094 $d =~ s/\s+/ /g;
15195 $r->{detail} = length($d) > 90 ? substr($d, 0, 87) . '...' : $d;
15296 $r->{what} = length($r->{what} // '') > 60 ? substr($r->{what}, 0, 57) . '...' : $r->{what};
15397 $r->{diff_url} = ($r->{kind} eq 'file'
15498 ? "/diff.cgi?kind=file&id=$r->{id}"
15599 : "/diff.cgi?kind=schema&id=$r->{id}");
156100 $r->{ts} //= '';
157101 $r->{ts_epoch} //= 0;
158102 $r->{kind_pill} = ($r->{kind} eq 'file') ? 'pill-info' : 'pill-warn';
159103}
160104
161105# ----- 90-day activity timeline (stacked bars) -----------------------
162106my $today_dow = 0;
163107my @file_by_day = $db->db_readwrite_multiple($dbh, q~
164108 SELECT DATE_FORMAT(date_time, '%Y-%m-%d') AS d, COUNT(*) AS n
165109 FROM FILE_CHANGES
166110 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 90 DAY)
167111 GROUP BY d
168112~, __FILE__, __LINE__);
169113my @schema_by_day = $db->db_readwrite_multiple($dbh, q~
170114 SELECT DATE_FORMAT(change_datetime, '%Y-%m-%d') AS d, COUNT(*) AS n
171115 FROM SCHEMA_CHANGE
172116 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 90 DAY)
173117 GROUP BY d
174118~, __FILE__, __LINE__);
175119my (%file_map, %schema_map);
176120$file_map{$_->{d}} = $_->{n} for @file_by_day;
177121$schema_map{$_->{d}} = $_->{n} for @schema_by_day;
178122
179123use POSIX ();
180124my (@days, @day_keys, @file_series, @schema_series);
181125my $today = time;
182126for (my $i = 89; $i >= 0; $i--) {
183127 my $t = $today - ($i * 86400);
184128 my $key = POSIX::strftime('%Y-%m-%d', localtime($t));
185129 my $lbl = POSIX::strftime('%b %d', localtime($t));
186130 push @days, $lbl;
187131 push @day_keys, $key;
188132 push @file_series, ($file_map{$key} || 0);
189133 push @schema_series, ($schema_map{$key} || 0);
190134}
191135my $timeline_svg = MODS::Charts::stacked_bars(
192136 days => \@days,
193137 day_keys => \@day_keys,
194138 series => { file => \@file_series, schema => \@schema_series },
195139 width => 1080,
196140 height => 180,
197141);
198142
199143# ----- Per-site health (Feature 2) -----------------------------------
200144# One row per active file monitor: total captured, active (non-deleted) files,
201145# last change timestamp. Sparkline of last 30 days per site.
202146my @per_site = $db->db_readwrite_multiple($dbh, q~
203147 SELECT m.file_monitor_list_id AS mid,
204148 m.scan_name,
205149 m.scan_path,
206150 s.server_name,
207151 s.kind AS server_kind,
208152 COUNT(fc.file_changes_id) AS captured_ct,
209153 COUNT(DISTINCT CASE WHEN fc.status != 'deleted'
210154 THEN fc.file_name END) AS active_files,
211155 SUM(CASE WHEN fc.date_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
212156 THEN 1 ELSE 0 END) AS changes_24h,
213157 MAX(UNIX_TIMESTAMP(fc.date_time)) AS last_change_epoch,
214158 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS last_change_h
215159 FROM FILE_MONITOR_SETTINGS m
216160 LEFT JOIN SERVERS s ON s.server_id = m.server_id
217161 LEFT JOIN FILE_CHANGES fc ON fc.file_monitor_list_id = m.file_monitor_list_id
218162 WHERE m.status = 1
219163 GROUP BY m.file_monitor_list_id, m.scan_name, m.scan_path, s.server_name, s.kind
220164 ORDER BY captured_ct DESC
221165~, __FILE__, __LINE__);
222166
223167# 30-day sparkline per monitor (one query, bucket in Perl)
224168my %spark_by_mid;
225169{
226170 my @day_rows = $db->db_readwrite_multiple($dbh, q~
227171 SELECT file_monitor_list_id AS mid,
228172 DATE_FORMAT(date_time, '%Y-%m-%d') AS d,
229173 COUNT(*) AS n
230174 FROM FILE_CHANGES
231175 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
232176 GROUP BY file_monitor_list_id, d
233177 ~, __FILE__, __LINE__);
234178 my %by;
235179 foreach my $r (@day_rows) { $by{$r->{mid}}{$r->{d}} = $r->{n}; }
236180 my $now = time;
237181 my @keys;
238182 for (my $i = 29; $i >= 0; $i--) {
239183 push @keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400));
240184 }
241185 foreach my $r (@per_site) {
242186 my @series;
243187 for my $k (@keys) { push @series, ($by{$r->{mid}}{$k} || 0); }
244188 $spark_by_mid{$r->{mid}} = \@series;
245189 }
246190}
247191foreach my $r (@per_site) {
248192 $r->{active_files} //= 0;
249193 $r->{captured_ct} //= 0;
250194 $r->{changes_24h} //= 0;
251195 $r->{last_change_epoch} //= 0;
252196 $r->{last_change_h} //= 'never';
253197 $r->{kind_pill} = ($r->{server_kind} // '') eq 'ssh_agent' ? 'pill-info' : 'pill-mute';
254198 my $s = $spark_by_mid{$r->{mid}};
255199 $r->{spark_svg} = $s ? MODS::Charts::sparkline(values => $s, width => 120, height => 28) : '';
256200 $r->{drilldown_url} = "/file_changes.cgi?monitor=" . $r->{mid};
257201 $r->{path_short} = length($r->{scan_path} // '') > 55
258202 ? '...' . substr($r->{scan_path}, -52) : ($r->{scan_path} // '');
259203}
260204
261205# ----- Change-frequency ranking (Feature 5) --------------------------
262206# Top-20 files by change count in the last 30 days -- the "live-config"
263207# heatmap. Shows which files are being edited most, per-site.
264208my @top_files = $db->db_readwrite_multiple($dbh, q~
265209 SELECT fc.file_name,
266210 m.scan_name,
267211 COUNT(*) AS change_ct,
268212 MAX(fc.file_changes_id) AS latest_id,
269213 MAX(UNIX_TIMESTAMP(fc.date_time)) AS latest_epoch,
270214 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS latest_h
271215 FROM FILE_CHANGES fc
272216 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
273217 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
274218 GROUP BY fc.file_name, m.scan_name
275219 ORDER BY change_ct DESC, latest_epoch DESC
276220 LIMIT 20
277221~, __FILE__, __LINE__);
278222foreach my $t (@top_files) {
279223 $t->{file_short} = length($t->{file_name} // '') > 70
280224 ? '...' . substr($t->{file_name}, -67) : ($t->{file_name} // '');
281225 $t->{diff_url} = "/diff.cgi?kind=file&id=" . ($t->{latest_id} || 0);
282226 $t->{scan_name} //= 'unknown';
283227 $t->{latest_epoch} //= 0;
284228}
285229
286230# ----- Cross-site dedup surfacing (Feature 3, dashboard preview) -----
287231# Blobs that appear in FILE_CHANGES rows tied to >1 distinct monitor.
288232my @shared_blobs = $db->db_readwrite_multiple($dbh, q~
289233 SELECT fc.blob_sha,
290234 COUNT(DISTINCT fc.file_monitor_list_id) AS site_ct,
291235 COUNT(DISTINCT fc.file_name) AS distinct_names,
292236 GROUP_CONCAT(DISTINCT m.scan_name ORDER BY m.scan_name SEPARATOR ' | ') AS scans,
293237 MAX(fc.file_changes_id) AS example_id,
294238 bs.size_uncompressed,
295239 bs.ref_count
296240 FROM FILE_CHANGES fc
297241 JOIN BLOB_STORE bs ON bs.blob_sha = fc.blob_sha
298242 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
299243 WHERE fc.blob_sha IS NOT NULL
300244 GROUP BY fc.blob_sha, bs.size_uncompressed, bs.ref_count
301245 HAVING site_ct > 1
302246 ORDER BY site_ct DESC, distinct_names DESC
303247 LIMIT 8
304248~, __FILE__, __LINE__);
305249foreach my $b (@shared_blobs) {
306250 $b->{sha_short} = substr($b->{blob_sha} // '', 0, 12);
307251 $b->{size_h} = _fmt_bytes($b->{size_uncompressed} || 0);
308252 $b->{diff_url} = "/diff.cgi?kind=file&id=" . ($b->{example_id} || 0);
309253 $b->{scans_h} = length($b->{scans} // '') > 90 ? substr($b->{scans}, 0, 87) . '...' : $b->{scans};
310254}
311255
312256sub _fmt_bytes {
313257 my $n = shift; $n //= 0;
314258 return "$n B" if $n < 1024;
315259 return sprintf('%.1f KB', $n/1024) if $n < 1024*1024;
316260 return sprintf('%.1f MB', $n/1024/1024);
317261}
318262
319263# ----- Named releases strip (last 5) ---------------------------------
320264my @releases = $db->db_readwrite_multiple($dbh, q~
321265 SELECT named_release_id, name, description,
322266 DATE_FORMAT(released_at, '%b %d, %Y') AS released_h,
323267 UNIX_TIMESTAMP(released_at) AS released_epoch,
324268 released_by
325269 FROM NAMED_RELEASES
326270 ORDER BY released_at DESC
327271 LIMIT 5
328272~, __FILE__, __LINE__);
329273$_->{released_epoch} //= 0 for @releases;
330274
331275# ----- Server status strip -------------------------------------------
332276my @servers = $db->db_readwrite_multiple($dbh, q~
333277 SELECT server_id, server_name, hostname, role, status,
334278 DATE_FORMAT(last_heartbeat_at, '%b %d %H:%i') AS last_heartbeat_h,
335279 UNIX_TIMESTAMP(last_heartbeat_at) AS heartbeat_epoch
336280 FROM SERVERS
337281 ORDER BY (status='active') DESC, server_id ASC
338282 LIMIT 8
339283~, __FILE__, __LINE__);
340284foreach my $s (@servers) {
341285 $s->{status_pill} = $s->{status} eq 'active' ? 'pill-ok'
342286 : $s->{status} eq 'offline' ? 'pill-bad'
343287 : 'pill-mute';
344288 $s->{last_heartbeat_h} //= 'never';
345289 $s->{heartbeat_epoch} //= 0;
346290}
347291
348292# ----- Stable versions tile (Wave 5 F2 / user ask) ------------------
349293my @stable_versions = $db->db_readwrite_multiple($dbh, q~
350294 SELECT nr.named_release_id AS id, nr.name, nr.version_label, nr.tier,
351295 nr.description,
352296 DATE_FORMAT(nr.released_at, '%b %d, %Y') AS released_h,
353297 UNIX_TIMESTAMP(nr.released_at) AS released_epoch,
354298 (SELECT COUNT(*) FROM NAMED_RELEASE_PINS p WHERE p.named_release_id = nr.named_release_id) AS pin_ct
355299 FROM NAMED_RELEASES nr
356300 WHERE nr.tier IN ('stable', 'working')
357301 ORDER BY (nr.tier = 'stable') DESC, nr.released_at DESC
358302 LIMIT 6
359303~, __FILE__, __LINE__);
360304foreach my $r (@stable_versions) {
361305 $r->{label} = $r->{version_label} || $r->{name};
362306 $r->{tier_pill} = $r->{tier} eq 'stable' ? 'pill-ok' : 'pill-info';
363307 $r->{tier_lbl} = uc($r->{tier});
364308 $r->{restore_url} = "/restore_release.cgi?release_id=$r->{id}";
365309 $r->{released_epoch} //= 0;
366310 $r->{pin_ct} //= 0;
367311}
368312
369313# ----- Pin-drift tile (Wave 5 feature 1) ----------------------------
370314my $drift_kpi = $db->db_readwrite($dbh, q~
371315 SELECT COUNT(*) AS drifted_ct,
372316 COUNT(DISTINCT named_release_id) AS releases_affected
373317 FROM PIN_DRIFT_LOG
374318 WHERE notified = 0
375319 AND detected_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
376320~, __FILE__, __LINE__);
377321
378322my @drift_rows = $db->db_readwrite_multiple($dbh, q~
379323 SELECT d.file_name, d.pinned_sha, d.current_sha,
380324 r.name AS release_name, r.named_release_id AS rid,
381325 UNIX_TIMESTAMP(d.detected_at) AS detected_epoch,
382326 DATE_FORMAT(d.detected_at, '%b %d %H:%i') AS detected_h
383327 FROM PIN_DRIFT_LOG d
384328 LEFT JOIN NAMED_RELEASES r ON r.named_release_id = d.named_release_id
385329 WHERE d.notified = 0
386330 ORDER BY d.drift_id DESC
387331 LIMIT 5
388332~, __FILE__, __LINE__);
389333foreach my $d (@drift_rows) {
390334 $d->{file_short} = length($d->{file_name} // '') > 55
391335 ? '...' . substr($d->{file_name}, -52) : $d->{file_name};
392336 $d->{pinned_short} = substr($d->{pinned_sha} // '', 0, 12);
393337 $d->{current_short} = substr($d->{current_sha} // '', 0, 12);
394338 $d->{restore_url} = "/restore_release.cgi?release_id=" . ($d->{rid} || 0);
395339 $d->{detected_epoch} //= 0;
396340}
397341
398342# ----- Recent restores (last 5) --------------------------------------
399343my @recent_restores = $db->db_readwrite_multiple($dbh, q~
400344 SELECT rl.restore_id, rl.target_file, rl.status,
401345 UNIX_TIMESTAMP(rl.restored_at) AS restored_epoch,
402346 DATE_FORMAT(rl.restored_at, '%b %d %H:%i') AS restored_h,
403347 LEFT(rl.error_message, 100) AS err_short,
404348 s.server_name
405349 FROM RESTORE_LOG rl
406350 LEFT JOIN SERVERS s ON s.server_id = rl.server_id
407351 ORDER BY rl.restore_id DESC
408352 LIMIT 5
409353~, __FILE__, __LINE__);
410354foreach my $r (@recent_restores) {
411355 $r->{status_pill} = $r->{status} eq 'success' ? 'pill-ok' :
412356 $r->{status} eq 'failed' ? 'pill-bad' : 'pill-mute';
413357 $r->{restored_epoch} //= 0;
414358 $r->{file_short} = length($r->{target_file} // '') > 55
415359 ? '...' . substr($r->{target_file}, -52) : $r->{target_file};
416360}
417361
418362$db->db_disconnect($dbh);
419363
420364sub _trend {
421 my ($cur, $prev, $noun) = @_;
422 $cur //= 0; $prev //= 0; $noun //= 'changes';
423 # Always include the noun so a bare zero reads as "0 schema changes"
424 # not just an em-dash the operator has to decode.
425 if ($prev == 0 && $cur == 0) { return ('flat', "$noun"); }
426 if ($prev == 0) { return ('up', "&uarr; $noun"); }
365 my ($cur, $prev) = @_;
366 $cur //= 0; $prev //= 0;
367 if ($prev == 0 && $cur == 0) { return ('flat', '&mdash;'); }
368 if ($prev == 0) { return ('up', '&uarr; new'); }
427369 my $delta = ($cur - $prev) / $prev * 100;
428370 my $sign = $delta > 5 ? 'up' : $delta < -5 ? 'down' : 'flat';
429371 my $arrow = $sign eq 'up' ? '&uarr;' : $sign eq 'down' ? '&darr;' : '&#8226;';
430372 return ($sign, sprintf('%s %+.0f%%', $arrow, $delta));
431373}
432my ($ft_class, $ft_txt) = _trend(($file_today && $file_today->{n}), ($file_prev && $file_prev->{n}), 'file changes');
433my ($st_class, $st_txt) = _trend(($schema_7d && $schema_7d->{n}), ($schema_prev && $schema_prev->{n}), 'schema changes');
374my ($ft_class, $ft_txt) = _trend(($file_today && $file_today->{n}), ($file_prev && $file_prev->{n}));
375my ($st_class, $st_txt) = _trend(($schema_7d && $schema_7d->{n}), ($schema_prev && $schema_prev->{n}));
434376
435377# ----- Render body ---------------------------------------------------
436378my $tvars = {
437379 kpi_file_today => ($file_today && $file_today->{n}) || 0,
438380 kpi_file_prev => ($file_prev && $file_prev->{n}) || 0,
439381 kpi_file_class => $ft_class, kpi_file_txt => $ft_txt,
440 kpi_fc_modified => $fc{modified},
441 kpi_fc_added => $fc{added},
442 kpi_fc_deleted => $fc{deleted},
443 kpi_fc_all => $fc_all,
444 kpi_fc_range => $R->{fc_qs} || 'tr=today',
445 fc_mod_hot => $fc{modified} > 0 ? 1 : 0,
446 fc_add_hot => $fc{added} > 0 ? 1 : 0,
447 fc_del_hot => $fc{deleted} > 0 ? 1 : 0,
448 fc_all_hot => $fc_all > 0 ? 1 : 0,
449 kpi_sc_modified => $sc{modified},
450 kpi_sc_added => $sc{added},
451 kpi_sc_deleted => $sc{deleted},
452 kpi_sc_all => $sc_all,
453 sc_mod_hot => $sc{modified} > 0 ? 1 : 0,
454 sc_add_hot => $sc{added} > 0 ? 1 : 0,
455 sc_del_hot => $sc{deleted} > 0 ? 1 : 0,
456 sc_all_hot => $sc_all > 0 ? 1 : 0,
457 range_label => $R->{label},
458 range_picker => $R->{picker_html},
459 range_lower_epoch=> $range_lower,
460382 kpi_schema_7d => ($schema_7d && $schema_7d->{n}) || 0,
461383 kpi_schema_prev => ($schema_prev && $schema_prev->{n}) || 0,
462384 kpi_schema_class => $st_class, kpi_schema_txt => $st_txt,
463385 kpi_releases => ($releases_ct && $releases_ct->{n}) || 0,
464386 kpi_drifted => ($drifted_ct && $drifted_ct->{n}) || 0,
465387 timeline_svg => $timeline_svg,
466388 per_site => \@per_site,
467389 has_per_site => scalar(@per_site) ? 1 : 0,
468390 top_files => \@top_files,
469391 has_top_files => scalar(@top_files) ? 1 : 0,
470392 shared_blobs => \@shared_blobs,
471393 has_shared_blobs => scalar(@shared_blobs) ? 1 : 0,
472394 recent_restores => \@recent_restores,
473395 has_restores => scalar(@recent_restores) ? 1 : 0,
474396 drifted_ct => ($drift_kpi && $drift_kpi->{drifted_ct}) || 0,
475397 releases_affected=> ($drift_kpi && $drift_kpi->{releases_affected}) || 0,
476398 drift_rows => \@drift_rows,
477399 has_drifts => scalar(@drift_rows) ? 1 : 0,
478400 stable_versions => \@stable_versions,
479401 has_stables => scalar(@stable_versions) ? 1 : 0,
480402 has_recent => scalar(@recent) ? 1 : 0,
481403 recent => \@recent,
482404 has_releases => scalar(@releases) ? 1 : 0,
483405 releases => \@releases,
484406 servers => \@servers,
485407};
486408my @body = $tpl->template('dashboard.html', $tvars);
487409
488410$wrap->wrapper(
489411 page_title => 'Dashboard',
490412 page_key => 'dashboard',
491413 body_html => join('', @body),
492414 userinfo => { display_name => 'Operator' }, # single-user install; auth added later
493415);
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.