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-10 23:21:52
Captured SHAd9e3ea458c4f3d6aaca808fd1feb33916e9aa0579c3d66109c79efb7abf1ba2a
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, -231 deletions, 180 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);
138
139# ----- Per-site health (Feature 2) -----------------------------------
140# One row per active file monitor: total captured, active (non-deleted) files,
141# last change timestamp. Sparkline of last 30 days per site.
142my @per_site = $db->db_readwrite_multiple($dbh, q~
143 SELECT m.file_monitor_list_id AS mid,
144 m.scan_name,
145 m.scan_path,
146 s.server_name,
147 s.kind AS server_kind,
148 COUNT(fc.file_changes_id) AS captured_ct,
149 COUNT(DISTINCT CASE WHEN fc.status != 'deleted'
150 THEN fc.file_name END) AS active_files,
151 SUM(CASE WHEN fc.date_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
152 THEN 1 ELSE 0 END) AS changes_24h,
153 MAX(UNIX_TIMESTAMP(fc.date_time)) AS last_change_epoch,
154 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS last_change_h
155 FROM FILE_MONITOR_SETTINGS m
156 LEFT JOIN SERVERS s ON s.server_id = m.server_id
157 LEFT JOIN FILE_CHANGES fc ON fc.file_monitor_list_id = m.file_monitor_list_id
158 WHERE m.status = 1
159 GROUP BY m.file_monitor_list_id, m.scan_name, m.scan_path, s.server_name, s.kind
160 ORDER BY captured_ct DESC
161~, __FILE__, __LINE__);
162
163# 30-day sparkline per monitor (one query, bucket in Perl)
164my %spark_by_mid;
165{
166 my @day_rows = $db->db_readwrite_multiple($dbh, q~
167 SELECT file_monitor_list_id AS mid,
168 DATE_FORMAT(date_time, '%Y-%m-%d') AS d,
169 COUNT(*) AS n
170 FROM FILE_CHANGES
171 WHERE date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
172 GROUP BY file_monitor_list_id, d
173 ~, __FILE__, __LINE__);
174 my %by;
175 foreach my $r (@day_rows) { $by{$r->{mid}}{$r->{d}} = $r->{n}; }
176 my $now = time;
177 my @keys;
178 for (my $i = 29; $i >= 0; $i--) {
179 push @keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400));
180 }
181 foreach my $r (@per_site) {
182 my @series;
183 for my $k (@keys) { push @series, ($by{$r->{mid}}{$k} || 0); }
184 $spark_by_mid{$r->{mid}} = \@series;
185 }
186}
187foreach my $r (@per_site) {
188 $r->{active_files} //= 0;
189 $r->{captured_ct} //= 0;
190 $r->{changes_24h} //= 0;
191 $r->{last_change_epoch} //= 0;
192 $r->{last_change_h} //= 'never';
193 $r->{kind_pill} = ($r->{server_kind} // '') eq 'ssh_agent' ? 'pill-info' : 'pill-mute';
194 my $s = $spark_by_mid{$r->{mid}};
195 $r->{spark_svg} = $s ? MODS::Charts::sparkline(values => $s, width => 120, height => 28) : '';
196 $r->{drilldown_url} = "/file_changes.cgi?monitor=" . $r->{mid};
197 $r->{path_short} = length($r->{scan_path} // '') > 55
198 ? '...' . substr($r->{scan_path}, -52) : ($r->{scan_path} // '');
199}
200
201# ----- Change-frequency ranking (Feature 5) --------------------------
202# Top-20 files by change count in the last 30 days -- the "live-config"
203# heatmap. Shows which files are being edited most, per-site.
204my @top_files = $db->db_readwrite_multiple($dbh, q~
205 SELECT fc.file_name,
206 m.scan_name,
207 COUNT(*) AS change_ct,
208 MAX(fc.file_changes_id) AS latest_id,
209 MAX(UNIX_TIMESTAMP(fc.date_time)) AS latest_epoch,
210 DATE_FORMAT(MAX(fc.date_time), '%b %d %H:%i') AS latest_h
211 FROM FILE_CHANGES fc
212 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
213 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
214 GROUP BY fc.file_name, m.scan_name
215 ORDER BY change_ct DESC, latest_epoch DESC
216 LIMIT 20
217~, __FILE__, __LINE__);
218foreach my $t (@top_files) {
219 $t->{file_short} = length($t->{file_name} // '') > 70
220 ? '...' . substr($t->{file_name}, -67) : ($t->{file_name} // '');
221 $t->{diff_url} = "/diff.cgi?kind=file&id=" . ($t->{latest_id} || 0);
222 $t->{scan_name} //= 'unknown';
223 $t->{latest_epoch} //= 0;
224}
225
226# ----- Cross-site dedup surfacing (Feature 3, dashboard preview) -----
227# Blobs that appear in FILE_CHANGES rows tied to >1 distinct monitor.
228my @shared_blobs = $db->db_readwrite_multiple($dbh, q~
229 SELECT fc.blob_sha,
230 COUNT(DISTINCT fc.file_monitor_list_id) AS site_ct,
231 COUNT(DISTINCT fc.file_name) AS distinct_names,
232 GROUP_CONCAT(DISTINCT m.scan_name ORDER BY m.scan_name SEPARATOR ' | ') AS scans,
233 MAX(fc.file_changes_id) AS example_id,
234 bs.size_uncompressed,
235 bs.ref_count
236 FROM FILE_CHANGES fc
237 JOIN BLOB_STORE bs ON bs.blob_sha = fc.blob_sha
238 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
239 WHERE fc.blob_sha IS NOT NULL
240 GROUP BY fc.blob_sha, bs.size_uncompressed, bs.ref_count
241 HAVING site_ct > 1
242 ORDER BY site_ct DESC, distinct_names DESC
243 LIMIT 8
244~, __FILE__, __LINE__);
245foreach my $b (@shared_blobs) {
246 $b->{sha_short} = substr($b->{blob_sha} // '', 0, 12);
247 $b->{size_h} = _fmt_bytes($b->{size_uncompressed} || 0);
248 $b->{diff_url} = "/diff.cgi?kind=file&id=" . ($b->{example_id} || 0);
249 $b->{scans_h} = length($b->{scans} // '') > 90 ? substr($b->{scans}, 0, 87) . '...' : $b->{scans};
250}
251
252sub _fmt_bytes {
253 my $n = shift; $n //= 0;
254 return "$n B" if $n < 1024;
255 return sprintf('%.1f KB', $n/1024) if $n < 1024*1024;
256 return sprintf('%.1f MB', $n/1024/1024);
257}
258128
259129# ----- Named releases strip (last 5) ---------------------------------
260130my @releases = $db->db_readwrite_multiple($dbh, q~
261131 SELECT named_release_id, name, description,
262132 DATE_FORMAT(released_at, '%b %d, %Y') AS released_h,
263133 UNIX_TIMESTAMP(released_at) AS released_epoch,
264134 released_by
265135 FROM NAMED_RELEASES
266136 ORDER BY released_at DESC
267137 LIMIT 5
268138~, __FILE__, __LINE__);
269139$_->{released_epoch} //= 0 for @releases;
270140
271141# ----- Server status strip -------------------------------------------
272142my @servers = $db->db_readwrite_multiple($dbh, q~
273143 SELECT server_id, server_name, hostname, role, status,
274144 DATE_FORMAT(last_heartbeat_at, '%b %d %H:%i') AS last_heartbeat_h,
275145 UNIX_TIMESTAMP(last_heartbeat_at) AS heartbeat_epoch
276146 FROM SERVERS
277147 ORDER BY (status='active') DESC, server_id ASC
278148 LIMIT 8
279149~, __FILE__, __LINE__);
280150foreach my $s (@servers) {
281151 $s->{status_pill} = $s->{status} eq 'active' ? 'pill-ok'
282152 : $s->{status} eq 'offline' ? 'pill-bad'
283153 : 'pill-mute';
284154 $s->{last_heartbeat_h} //= 'never';
285155 $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;
336}
337
338# ----- Recent restores (last 5) --------------------------------------
339my @recent_restores = $db->db_readwrite_multiple($dbh, q~
340 SELECT rl.restore_id, rl.target_file, rl.status,
341 UNIX_TIMESTAMP(rl.restored_at) AS restored_epoch,
342 DATE_FORMAT(rl.restored_at, '%b %d %H:%i') AS restored_h,
343 LEFT(rl.error_message, 100) AS err_short,
344 s.server_name
345 FROM RESTORE_LOG rl
346 LEFT JOIN SERVERS s ON s.server_id = rl.server_id
347 ORDER BY rl.restore_id DESC
348 LIMIT 5
349~, __FILE__, __LINE__);
350foreach my $r (@recent_restores) {
351 $r->{status_pill} = $r->{status} eq 'success' ? 'pill-ok' :
352 $r->{status} eq 'failed' ? 'pill-bad' : 'pill-mute';
353 $r->{restored_epoch} //= 0;
354 $r->{file_short} = length($r->{target_file} // '') > 55
355 ? '...' . substr($r->{target_file}, -52) : $r->{target_file};
356156}
357157
358158$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}));
372159
373160# ----- Render body ---------------------------------------------------
374161my $tvars = {
375162 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,
378163 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,
381164 kpi_releases => ($releases_ct && $releases_ct->{n}) || 0,
382165 kpi_drifted => ($drifted_ct && $drifted_ct->{n}) || 0,
383166 timeline_svg => $timeline_svg,
384 per_site => \@per_site,
385 has_per_site => scalar(@per_site) ? 1 : 0,
386 top_files => \@top_files,
387 has_top_files => scalar(@top_files) ? 1 : 0,
388 shared_blobs => \@shared_blobs,
389 has_shared_blobs => scalar(@shared_blobs) ? 1 : 0,
390 recent_restores => \@recent_restores,
391 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,
398167 has_recent => scalar(@recent) ? 1 : 0,
399168 recent => \@recent,
400169 has_releases => scalar(@releases) ? 1 : 0,
401170 releases => \@releases,
402171 servers => \@servers,
403172};
404173my @body = $tpl->template('dashboard.html', $tvars);
405174
406175$wrap->wrapper(
407176 page_title => 'Dashboard',
408177 page_key => 'dashboard',
409178 body_html => join('', @body),
410179 userinfo => { display_name => 'Operator' }, # single-user install; auth added later
411180);
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.