Restore

O Operator
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-11 23:34:26
Captured SHAb7658e07661d45aff519143566af43c7f663191232dcb45085e008761036a05c
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size17372 bytes
Current SHA0fee4fd03c84
Same as captured?no -- restore will change it
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +0 additions, -83 deletions, 328 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;
1919
2020my $cfg = MODS::Config->new;
2121my $db = MODS::DBConnect->new;
2222my $tpl = MODS::Template->new;
2323my $wrap = MODS::PageWrapper->new;
2424
2525# Headers
2626$|=1;
2727print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
2828
2929my $dbh = $db->db_connect
3030 or die "DriftSense: cannot connect to storage DB. Check /etc/drift_sense/drift_sense.conf.\n";
3131
3232# ----- KPI queries ----------------------------------------------------
3333my $file_today = $db->db_readwrite($dbh, q~
3434 SELECT COUNT(*) AS n FROM FILE_CHANGES
3535 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 1 DAY)
36~, __FILE__, __LINE__);
37my $file_prev = $db->db_readwrite($dbh, q~
38 SELECT COUNT(*) AS n FROM FILE_CHANGES
39 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 2 DAY)
40 AND date_time < DATE_SUB(NOW(), INTERVAL 1 DAY)
4136~, __FILE__, __LINE__);
4237
4338my $schema_7d = $db->db_readwrite($dbh, q~
4439 SELECT COUNT(*) AS n FROM SCHEMA_CHANGE
4540 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 7 DAY)
46~, __FILE__, __LINE__);
47my $schema_prev = $db->db_readwrite($dbh, q~
48 SELECT COUNT(*) AS n FROM SCHEMA_CHANGE
49 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 14 DAY)
50 AND change_datetime < DATE_SUB(NOW(), INTERVAL 7 DAY)
5141~, __FILE__, __LINE__);
5242
5343my $releases_ct = $db->db_readwrite($dbh, q~
5444 SELECT COUNT(*) AS n FROM NAMED_RELEASES
5545~, __FILE__, __LINE__);
5646
5747my $drifted_ct = $db->db_readwrite($dbh, q~
5848 SELECT COUNT(*) AS n FROM CONFIG_DRIFT_BASELINES WHERE is_drifted = 1
5949~, __FILE__, __LINE__);
6050
6151# ----- Recent activity (merged file + schema changes, newest first) --
6252my @file_recent = $db->db_readwrite_multiple($dbh, q~
6353 SELECT 'file' AS kind,
6454 file_changes_id AS id,
6555 file_name AS what,
6656 status AS detail,
6757 date_time AS ts,
6858 UNIX_TIMESTAMP(date_time) AS ts_epoch,
6959 server_id
7060 FROM FILE_CHANGES
7161 ORDER BY date_time DESC
7262 LIMIT 20
7363~, __FILE__, __LINE__);
7464my @schema_recent = $db->db_readwrite_multiple($dbh, q~
7565 SELECT 'schema' AS kind,
7666 schema_change_id AS id,
7767 CONCAT(database_name, '.', table_name) AS what,
7868 changes AS detail,
7969 change_datetime AS ts,
8070 UNIX_TIMESTAMP(change_datetime) AS ts_epoch,
8171 server_id
8272 FROM SCHEMA_CHANGE
8373 ORDER BY change_datetime DESC
8474 LIMIT 20
8575~, __FILE__, __LINE__);
8676my @recent = sort { ($b->{ts} || '') cmp ($a->{ts} || '') } (@file_recent, @schema_recent);
8777@recent = splice(@recent, 0, 20) if @recent > 20;
8878foreach my $r (@recent) {
8979 my $d = $r->{detail} // '';
9080 $d =~ s/\s+/ /g;
9181 $r->{detail} = length($d) > 90 ? substr($d, 0, 87) . '...' : $d;
9282 $r->{what} = length($r->{what} // '') > 60 ? substr($r->{what}, 0, 57) . '...' : $r->{what};
9383 $r->{diff_url} = ($r->{kind} eq 'file'
9484 ? "/diff.cgi?kind=file&id=$r->{id}"
9585 : "/diff.cgi?kind=schema&id=$r->{id}");
9686 $r->{ts} //= '';
9787 $r->{ts_epoch} //= 0;
9888 $r->{kind_pill} = ($r->{kind} eq 'file') ? 'pill-info' : 'pill-warn';
9989}
10090
10191# ----- 90-day activity timeline (stacked bars) -----------------------
10292my $today_dow = 0;
10393my @file_by_day = $db->db_readwrite_multiple($dbh, q~
10494 SELECT DATE_FORMAT(date_time, '%Y-%m-%d') AS d, COUNT(*) AS n
10595 FROM FILE_CHANGES
10696 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 90 DAY)
10797 GROUP BY d
10898~, __FILE__, __LINE__);
10999my @schema_by_day = $db->db_readwrite_multiple($dbh, q~
110100 SELECT DATE_FORMAT(change_datetime, '%Y-%m-%d') AS d, COUNT(*) AS n
111101 FROM SCHEMA_CHANGE
112102 WHERE change_datetime >= DATE_SUB(NOW(), INTERVAL 90 DAY)
113103 GROUP BY d
114104~, __FILE__, __LINE__);
115105my (%file_map, %schema_map);
116106$file_map{$_->{d}} = $_->{n} for @file_by_day;
117107$schema_map{$_->{d}} = $_->{n} for @schema_by_day;
118108
119109use POSIX ();
120110my (@days, @day_keys, @file_series, @schema_series);
121111my $today = time;
122112for (my $i = 89; $i >= 0; $i--) {
123113 my $t = $today - ($i * 86400);
124114 my $key = POSIX::strftime('%Y-%m-%d', localtime($t));
125115 my $lbl = POSIX::strftime('%b %d', localtime($t));
126116 push @days, $lbl;
127117 push @day_keys, $key;
128118 push @file_series, ($file_map{$key} || 0);
129119 push @schema_series, ($schema_map{$key} || 0);
130120}
131121my $timeline_svg = MODS::Charts::stacked_bars(
132122 days => \@days,
133123 day_keys => \@day_keys,
134124 series => { file => \@file_series, schema => \@schema_series },
135125 width => 1080,
136126 height => 180,
137127);
138128
139129# ----- Per-site health (Feature 2) -----------------------------------
140130# One row per active file monitor: total captured, active (non-deleted) files,
141131# last change timestamp. Sparkline of last 30 days per site.
142132my @per_site = $db->db_readwrite_multiple($dbh, q~
143133 SELECT m.file_monitor_list_id AS mid,
144134 m.scan_name,
145135 m.scan_path,
146136 s.server_name,
147137 s.kind AS server_kind,
148138 COUNT(fc.file_changes_id) AS captured_ct,
149139 COUNT(DISTINCT CASE WHEN fc.status != 'deleted'
150140 THEN fc.file_name END) AS active_files,
151141 SUM(CASE WHEN fc.date_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
152142 THEN 1 ELSE 0 END) AS changes_24h,
153143 MAX(UNIX_TIMESTAMP(fc.date_time)) AS last_change_epoch,
154144 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS last_change_h
155145 FROM FILE_MONITOR_SETTINGS m
156146 LEFT JOIN SERVERS s ON s.server_id = m.server_id
157147 LEFT JOIN FILE_CHANGES fc ON fc.file_monitor_list_id = m.file_monitor_list_id
158148 WHERE m.status = 1
159149 GROUP BY m.file_monitor_list_id, m.scan_name, m.scan_path, s.server_name, s.kind
160150 ORDER BY captured_ct DESC
161151~, __FILE__, __LINE__);
162152
163153# 30-day sparkline per monitor (one query, bucket in Perl)
164154my %spark_by_mid;
165155{
166156 my @day_rows = $db->db_readwrite_multiple($dbh, q~
167157 SELECT file_monitor_list_id AS mid,
168158 DATE_FORMAT(date_time, '%Y-%m-%d') AS d,
169159 COUNT(*) AS n
170160 FROM FILE_CHANGES
171161 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
172162 GROUP BY file_monitor_list_id, d
173163 ~, __FILE__, __LINE__);
174164 my %by;
175165 foreach my $r (@day_rows) { $by{$r->{mid}}{$r->{d}} = $r->{n}; }
176166 my $now = time;
177167 my @keys;
178168 for (my $i = 29; $i >= 0; $i--) {
179169 push @keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400));
180170 }
181171 foreach my $r (@per_site) {
182172 my @series;
183173 for my $k (@keys) { push @series, ($by{$r->{mid}}{$k} || 0); }
184174 $spark_by_mid{$r->{mid}} = \@series;
185175 }
186176}
187177foreach my $r (@per_site) {
188178 $r->{active_files} //= 0;
189179 $r->{captured_ct} //= 0;
190180 $r->{changes_24h} //= 0;
191181 $r->{last_change_epoch} //= 0;
192182 $r->{last_change_h} //= 'never';
193183 $r->{kind_pill} = ($r->{server_kind} // '') eq 'ssh_agent' ? 'pill-info' : 'pill-mute';
194184 my $s = $spark_by_mid{$r->{mid}};
195185 $r->{spark_svg} = $s ? MODS::Charts::sparkline(values => $s, width => 120, height => 28) : '';
196186 $r->{drilldown_url} = "/file_changes.cgi?monitor=" . $r->{mid};
197187 $r->{path_short} = length($r->{scan_path} // '') > 55
198188 ? '...' . substr($r->{scan_path}, -52) : ($r->{scan_path} // '');
199189}
200190
201191# ----- Change-frequency ranking (Feature 5) --------------------------
202192# Top-20 files by change count in the last 30 days -- the "live-config"
203193# heatmap. Shows which files are being edited most, per-site.
204194my @top_files = $db->db_readwrite_multiple($dbh, q~
205195 SELECT fc.file_name,
206196 m.scan_name,
207197 COUNT(*) AS change_ct,
208198 MAX(fc.file_changes_id) AS latest_id,
209199 MAX(UNIX_TIMESTAMP(fc.date_time)) AS latest_epoch,
210200 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS latest_h
211201 FROM FILE_CHANGES fc
212202 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
213203 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
214204 GROUP BY fc.file_name, m.scan_name
215205 ORDER BY change_ct DESC, latest_epoch DESC
216206 LIMIT 20
217207~, __FILE__, __LINE__);
218208foreach my $t (@top_files) {
219209 $t->{file_short} = length($t->{file_name} // '') > 70
220210 ? '...' . substr($t->{file_name}, -67) : ($t->{file_name} // '');
221211 $t->{diff_url} = "/diff.cgi?kind=file&id=" . ($t->{latest_id} || 0);
222212 $t->{scan_name} //= 'unknown';
223213 $t->{latest_epoch} //= 0;
224214}
225215
226216# ----- Cross-site dedup surfacing (Feature 3, dashboard preview) -----
227217# Blobs that appear in FILE_CHANGES rows tied to >1 distinct monitor.
228218my @shared_blobs = $db->db_readwrite_multiple($dbh, q~
229219 SELECT fc.blob_sha,
230220 COUNT(DISTINCT fc.file_monitor_list_id) AS site_ct,
231221 COUNT(DISTINCT fc.file_name) AS distinct_names,
232222 GROUP_CONCAT(DISTINCT m.scan_name ORDER BY m.scan_name SEPARATOR ' | ') AS scans,
233223 MAX(fc.file_changes_id) AS example_id,
234224 bs.size_uncompressed,
235225 bs.ref_count
236226 FROM FILE_CHANGES fc
237227 JOIN BLOB_STORE bs ON bs.blob_sha = fc.blob_sha
238228 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
239229 WHERE fc.blob_sha IS NOT NULL
240230 GROUP BY fc.blob_sha, bs.size_uncompressed, bs.ref_count
241231 HAVING site_ct > 1
242232 ORDER BY site_ct DESC, distinct_names DESC
243233 LIMIT 8
244234~, __FILE__, __LINE__);
245235foreach my $b (@shared_blobs) {
246236 $b->{sha_short} = substr($b->{blob_sha} // '', 0, 12);
247237 $b->{size_h} = _fmt_bytes($b->{size_uncompressed} || 0);
248238 $b->{diff_url} = "/diff.cgi?kind=file&id=" . ($b->{example_id} || 0);
249239 $b->{scans_h} = length($b->{scans} // '') > 90 ? substr($b->{scans}, 0, 87) . '...' : $b->{scans};
250240}
251241
252242sub _fmt_bytes {
253243 my $n = shift; $n //= 0;
254244 return "$n B" if $n < 1024;
255245 return sprintf('%.1f KB', $n/1024) if $n < 1024*1024;
256246 return sprintf('%.1f MB', $n/1024/1024);
257247}
258248
259249# ----- Named releases strip (last 5) ---------------------------------
260250my @releases = $db->db_readwrite_multiple($dbh, q~
261251 SELECT named_release_id, name, description,
262252 DATE_FORMAT(released_at, '%b %d, %Y') AS released_h,
263253 UNIX_TIMESTAMP(released_at) AS released_epoch,
264254 released_by
265255 FROM NAMED_RELEASES
266256 ORDER BY released_at DESC
267257 LIMIT 5
268258~, __FILE__, __LINE__);
269259$_->{released_epoch} //= 0 for @releases;
270260
271261# ----- Server status strip -------------------------------------------
272262my @servers = $db->db_readwrite_multiple($dbh, q~
273263 SELECT server_id, server_name, hostname, role, status,
274264 DATE_FORMAT(last_heartbeat_at, '%b %d %H:%i') AS last_heartbeat_h,
275265 UNIX_TIMESTAMP(last_heartbeat_at) AS heartbeat_epoch
276266 FROM SERVERS
277267 ORDER BY (status='active') DESC, server_id ASC
278268 LIMIT 8
279269~, __FILE__, __LINE__);
280270foreach my $s (@servers) {
281271 $s->{status_pill} = $s->{status} eq 'active' ? 'pill-ok'
282272 : $s->{status} eq 'offline' ? 'pill-bad'
283273 : 'pill-mute';
284274 $s->{last_heartbeat_h} //= 'never';
285275 $s->{heartbeat_epoch} //= 0;
286}
287
288# ----- Stable versions tile (Wave 5 F2 / user ask) ------------------
289my @stable_versions = $db->db_readwrite_multiple($dbh, q~
290 SELECT nr.named_release_id AS id, nr.name, nr.version_label, nr.tier,
291 nr.description,
292 DATE_FORMAT(nr.released_at, '%b %d, %Y') AS released_h,
293 UNIX_TIMESTAMP(nr.released_at) AS released_epoch,
294 (SELECT COUNT(*) FROM NAMED_RELEASE_PINS p WHERE p.named_release_id = nr.named_release_id) AS pin_ct
295 FROM NAMED_RELEASES nr
296 WHERE nr.tier IN ('stable', 'working')
297 ORDER BY (nr.tier = 'stable') DESC, nr.released_at DESC
298 LIMIT 6
299~, __FILE__, __LINE__);
300foreach my $r (@stable_versions) {
301 $r->{label} = $r->{version_label} || $r->{name};
302 $r->{tier_pill} = $r->{tier} eq 'stable' ? 'pill-ok' : 'pill-info';
303 $r->{tier_lbl} = uc($r->{tier});
304 $r->{restore_url} = "/restore_release.cgi?release_id=$r->{id}";
305 $r->{released_epoch} //= 0;
306 $r->{pin_ct} //= 0;
307}
308
309# ----- Pin-drift tile (Wave 5 feature 1) ----------------------------
310my $drift_kpi = $db->db_readwrite($dbh, q~
311 SELECT COUNT(*) AS drifted_ct,
312 COUNT(DISTINCT named_release_id) AS releases_affected
313 FROM PIN_DRIFT_LOG
314 WHERE notified = 0
315 AND detected_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
316~, __FILE__, __LINE__);
317
318my @drift_rows = $db->db_readwrite_multiple($dbh, q~
319 SELECT d.file_name, d.pinned_sha, d.current_sha,
320 r.name AS release_name, r.named_release_id AS rid,
321 UNIX_TIMESTAMP(d.detected_at) AS detected_epoch,
322 DATE_FORMAT(d.detected_at, '%b %d %H:%i') AS detected_h
323 FROM PIN_DRIFT_LOG d
324 LEFT JOIN NAMED_RELEASES r ON r.named_release_id = d.named_release_id
325 WHERE d.notified = 0
326 ORDER BY d.drift_id DESC
327 LIMIT 5
328~, __FILE__, __LINE__);
329foreach my $d (@drift_rows) {
330 $d->{file_short} = length($d->{file_name} // '') > 55
331 ? '...' . substr($d->{file_name}, -52) : $d->{file_name};
332 $d->{pinned_short} = substr($d->{pinned_sha} // '', 0, 12);
333 $d->{current_short} = substr($d->{current_sha} // '', 0, 12);
334 $d->{restore_url} = "/restore_release.cgi?release_id=" . ($d->{rid} || 0);
335 $d->{detected_epoch} //= 0;
336276}
337277
338278# ----- Recent restores (last 5) --------------------------------------
339279my @recent_restores = $db->db_readwrite_multiple($dbh, q~
340280 SELECT rl.restore_id, rl.target_file, rl.status,
341281 UNIX_TIMESTAMP(rl.restored_at) AS restored_epoch,
342282 DATE_FORMAT(rl.restored_at, '%b %d %H:%i') AS restored_h,
343283 LEFT(rl.error_message, 100) AS err_short,
344284 s.server_name
345285 FROM RESTORE_LOG rl
346286 LEFT JOIN SERVERS s ON s.server_id = rl.server_id
347287 ORDER BY rl.restore_id DESC
348288 LIMIT 5
349289~, __FILE__, __LINE__);
350290foreach my $r (@recent_restores) {
351291 $r->{status_pill} = $r->{status} eq 'success' ? 'pill-ok' :
352292 $r->{status} eq 'failed' ? 'pill-bad' : 'pill-mute';
353293 $r->{restored_epoch} //= 0;
354294 $r->{file_short} = length($r->{target_file} // '') > 55
355295 ? '...' . substr($r->{target_file}, -52) : $r->{target_file};
356296}
357297
358298$db->db_disconnect($dbh);
359
360sub _trend {
361 my ($cur, $prev) = @_;
362 $cur //= 0; $prev //= 0;
363 if ($prev == 0 && $cur == 0) { return ('flat', '&mdash;'); }
364 if ($prev == 0) { return ('up', '&uarr; new'); }
365 my $delta = ($cur - $prev) / $prev * 100;
366 my $sign = $delta > 5 ? 'up' : $delta < -5 ? 'down' : 'flat';
367 my $arrow = $sign eq 'up' ? '&uarr;' : $sign eq 'down' ? '&darr;' : '&#8226;';
368 return ($sign, sprintf('%s %+.0f%%', $arrow, $delta));
369}
370my ($ft_class, $ft_txt) = _trend(($file_today && $file_today->{n}), ($file_prev && $file_prev->{n}));
371my ($st_class, $st_txt) = _trend(($schema_7d && $schema_7d->{n}), ($schema_prev && $schema_prev->{n}));
372299
373300# ----- Render body ---------------------------------------------------
374301my $tvars = {
375302 kpi_file_today => ($file_today && $file_today->{n}) || 0,
376 kpi_file_prev => ($file_prev && $file_prev->{n}) || 0,
377 kpi_file_class => $ft_class, kpi_file_txt => $ft_txt,
378303 kpi_schema_7d => ($schema_7d && $schema_7d->{n}) || 0,
379 kpi_schema_prev => ($schema_prev && $schema_prev->{n}) || 0,
380 kpi_schema_class => $st_class, kpi_schema_txt => $st_txt,
381304 kpi_releases => ($releases_ct && $releases_ct->{n}) || 0,
382305 kpi_drifted => ($drifted_ct && $drifted_ct->{n}) || 0,
383306 timeline_svg => $timeline_svg,
384307 per_site => \@per_site,
385308 has_per_site => scalar(@per_site) ? 1 : 0,
386309 top_files => \@top_files,
387310 has_top_files => scalar(@top_files) ? 1 : 0,
388311 shared_blobs => \@shared_blobs,
389312 has_shared_blobs => scalar(@shared_blobs) ? 1 : 0,
390313 recent_restores => \@recent_restores,
391314 has_restores => scalar(@recent_restores) ? 1 : 0,
392 drifted_ct => ($drift_kpi && $drift_kpi->{drifted_ct}) || 0,
393 releases_affected=> ($drift_kpi && $drift_kpi->{releases_affected}) || 0,
394 drift_rows => \@drift_rows,
395 has_drifts => scalar(@drift_rows) ? 1 : 0,
396 stable_versions => \@stable_versions,
397 has_stables => scalar(@stable_versions) ? 1 : 0,
398315 has_recent => scalar(@recent) ? 1 : 0,
399316 recent => \@recent,
400317 has_releases => scalar(@releases) ? 1 : 0,
401318 releases => \@releases,
402319 servers => \@servers,
403320};
404321my @body = $tpl->template('dashboard.html', $tvars);
405322
406323$wrap->wrapper(
407324 page_title => 'Dashboard',
408325 page_key => 'dashboard',
409326 body_html => join('', @body),
410327 userinfo => { display_name => 'Operator' }, # single-user install; auth added later
411328);
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.