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/file_changes.cgi |
| Site | DriftSense self-monitor on local |
| Kind | local |
| Captured at | 2026-07-10 22:19:06 |
| Captured SHA | 359054848baa834c7904d04338b83452032f66df61837cab5d876a7eb2266ac3 |
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
| File present | yes |
| Size | 4464 bytes |
| Current SHA | 5cd91f273405 |
| 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. +25 additions, -46 deletions, 83 unchanged context lines.
| 1 | 1 | #!/usr/bin/perl |
| 2 | 2 | use strict; |
| 3 | 3 | use warnings; |
| 4 | 4 | use CGI (); |
| 5 | 5 | use MODS::Config; |
| 6 | 6 | use MODS::DBConnect; |
| 7 | 7 | use MODS::Template; |
| 8 | 8 | use MODS::PageWrapper; |
| 9 | 9 | use MODS::Charts; |
| 10 | use MODS::RangePicker; | |
| 11 | 10 | use POSIX (); |
| 12 | 11 | |
| 13 | 12 | my $cgi = CGI->new; |
| 14 | 13 | my $db = MODS::DBConnect->new; |
| 15 | 14 | my $tpl = MODS::Template->new; |
| 16 | 15 | |
| 17 | 16 | $|=1; print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; |
| 18 | 17 | |
| 19 | 18 | my $dbh = $db->db_connect or die "DB connect failed\n"; |
| 20 | 19 | |
| 21 | my $r = MODS::RangePicker::parse($cgi); | |
| 22 | my $q = $cgi->param('q') || ''; | |
| 20 | my $days = int($cgi->param('days') || 7); $days = 1 if $days < 1; $days = 365 if $days > 365; | |
| 21 | my $q = $cgi->param('q') || ''; | |
| 23 | 22 | my $server_filter = int($cgi->param('server') || 0); |
| 24 | 23 | |
| 25 | my $where = $r->{sql_where}->('fc.date_time'); | |
| 26 | $where .= " AND fc.file_name LIKE " . $dbh->quote('%' . $q . '%') if length $q; | |
| 27 | $where .= " AND fc.server_id = $server_filter" if $server_filter; | |
| 24 | my $where = "date_time >= DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 25 | $where .= " AND file_name LIKE " . $dbh->quote('%' . $q . '%') if length $q; | |
| 26 | $where .= " AND server_id = $server_filter" if $server_filter; | |
| 28 | 27 | |
| 29 | 28 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 30 | SELECT fc.file_changes_id AS id, fc.file_name, fc.status, | |
| 31 | UNIX_TIMESTAMP(fc.date_time) AS ts_epoch, | |
| 32 | fc.date_time, | |
| 29 | SELECT fc.file_changes_id AS id, fc.file_name, fc.status, fc.date_time, | |
| 33 | 30 | fc.blob_sha, fc.diff_blob_sha, fc.is_ts_only, fc.uid, fc.gid, fc.permissions, |
| 34 | 31 | s.server_name |
| 35 | 32 | FROM FILE_CHANGES fc |
| 36 | 33 | LEFT JOIN SERVERS s ON s.server_id = fc.server_id |
| 37 | 34 | WHERE $where |
| 38 | 35 | ORDER BY fc.date_time DESC |
| 39 | 36 | LIMIT 200 |
| 40 | 37 | ~, __FILE__, __LINE__); |
| 41 | ||
| 42 | 38 | # --- Per-file 30-day sparkline data. One query, then bucket in Perl. --- |
| 43 | my %spark; | |
| 39 | my %spark; # file_name => arrayref [30 daily counts, oldest -> newest] | |
| 44 | 40 | { |
| 45 | 41 | my @dedupe_files; |
| 46 | 42 | my %seen; |
| 47 | foreach my $r_row (@rows) { | |
| 48 | next unless defined $r_row->{file_name}; | |
| 49 | next if $seen{$r_row->{file_name}}++; | |
| 50 | push @dedupe_files, $r_row->{file_name}; | |
| 43 | foreach my $r (@rows) { | |
| 44 | next unless defined $r->{file_name}; | |
| 45 | next if $seen{$r->{file_name}}++; | |
| 46 | push @dedupe_files, $r->{file_name}; | |
| 51 | 47 | } |
| 52 | 48 | if (@dedupe_files) { |
| 53 | 49 | my @quoted = map { $dbh->quote($_) } @dedupe_files; |
| 54 | 50 | my $in_list = join(',', @quoted); |
| 55 | 51 | my @day_rows = $db->db_readwrite_multiple($dbh, qq~ |
| 56 | 52 | SELECT file_name, |
| 57 | 53 | DATE_FORMAT(date_time, '%Y-%m-%d') AS d, |
| 58 | 54 | COUNT(*) AS n |
| 59 | 55 | FROM FILE_CHANGES |
| 60 | 56 | WHERE file_name IN ($in_list) |
| 61 | 57 | AND date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) |
| 62 | 58 | GROUP BY file_name, d |
| 63 | 59 | ~, __FILE__, __LINE__); |
| 64 | 60 | my %by_file; |
| 65 | 61 | foreach my $dr (@day_rows) { |
| 66 | 62 | $by_file{$dr->{file_name}}{$dr->{d}} = $dr->{n}; |
| 67 | 63 | } |
| 68 | 64 | my $now = time; |
| 69 | 65 | my @day_keys; |
| 70 | 66 | for (my $i = 29; $i >= 0; $i--) { |
| 71 | 67 | push @day_keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400)); |
| 72 | 68 | } |
| 73 | 69 | foreach my $fname (@dedupe_files) { |
| 74 | 70 | my @series; |
| 75 | 71 | for my $k (@day_keys) { |
| 76 | 72 | push @series, ($by_file{$fname}{$k} || 0); |
| 77 | 73 | } |
| 78 | 74 | $spark{$fname} = \@series; |
| 79 | 75 | } |
| 80 | 76 | } |
| 81 | 77 | } |
| 82 | 78 | |
| 83 | foreach my $row (@rows) { | |
| 84 | $row->{status_pill} = $row->{status} && $row->{status} eq 'added' ? 'pill-ok' | |
| 85 | : $row->{status} && $row->{status} eq 'modified' ? 'pill-warn' | |
| 86 | : $row->{status} && $row->{status} eq 'deleted' ? 'pill-bad' | |
| 87 | : 'pill-mute'; | |
| 88 | $row->{status} //= 'unknown'; | |
| 89 | $row->{file_name_h} = $row->{file_name} // ''; | |
| 90 | $row->{file_name_h} = length($row->{file_name_h}) > 90 | |
| 91 | ? '...' . substr($row->{file_name_h}, -87) | |
| 92 | : $row->{file_name_h}; | |
| 93 | $row->{blob_short} = $row->{blob_sha} ? substr($row->{blob_sha}, 0, 8) : '-'; | |
| 94 | $row->{diff_url} = "/diff.cgi?kind=file&id=$row->{id}"; | |
| 95 | $row->{restore_url} = "/restore.cgi?id=$row->{id}"; | |
| 96 | $row->{can_restore} = ($row->{blob_sha} && $row->{status} ne 'deleted') ? 1 : 0; | |
| 97 | $row->{ts_epoch} //= 0; | |
| 98 | my $series = $spark{$row->{file_name} // ''}; | |
| 99 | $row->{sparkline_svg} = $series | |
| 79 | foreach my $r (@rows) { | |
| 80 | $r->{status_pill} = $r->{status} && $r->{status} eq 'added' ? 'pill-ok' | |
| 81 | : $r->{status} && $r->{status} eq 'modified' ? 'pill-warn' | |
| 82 | : $r->{status} && $r->{status} eq 'deleted' ? 'pill-bad' | |
| 83 | : 'pill-mute'; | |
| 84 | $r->{status} //= 'unknown'; | |
| 85 | $r->{file_name_h} = $r->{file_name} // ''; | |
| 86 | $r->{file_name_h} = length($r->{file_name_h}) > 90 ? '...' . substr($r->{file_name_h}, -87) : $r->{file_name_h}; | |
| 87 | $r->{blob_short} = $r->{blob_sha} ? substr($r->{blob_sha}, 0, 8) : '-'; | |
| 88 | $r->{diff_url} = "/diff.cgi?kind=file&id=$r->{id}"; | |
| 89 | my $series = $spark{$r->{file_name} // ''}; | |
| 90 | $r->{sparkline_svg} = $series | |
| 100 | 91 | ? MODS::Charts::sparkline(values => $series, width => 88, height => 20, color => '#14b8a6') |
| 101 | 92 | : ''; |
| 102 | 93 | } |
| 103 | 94 | my $total = scalar @rows; |
| 104 | 95 | |
| 105 | 96 | $db->db_disconnect($dbh); |
| 106 | ||
| 107 | my $picker_html = MODS::RangePicker::picker_html( | |
| 108 | action => '/file_changes.cgi', | |
| 109 | range => $r, | |
| 110 | hidden => { q => $q, ($server_filter ? (server => $server_filter) : ()) }, | |
| 111 | ); | |
| 112 | 97 | |
| 113 | 98 | my $tvars = { |
| 114 | rows => \@rows, | |
| 115 | has_rows => $total ? 1 : 0, | |
| 116 | total => $total, | |
| 117 | q => $q, | |
| 118 | picker_html => $picker_html, | |
| 119 | range_label => $r->{range_label}, | |
| 120 | is_day_view => $r->{is_day} ? 1 : 0, | |
| 121 | day_label => $r->{on}, | |
| 99 | rows => \@rows, has_rows => $total ? 1 : 0, total => $total, | |
| 100 | days => $days, q => $q, | |
| 122 | 101 | }; |
| 123 | 102 | my @body = $tpl->template('file_changes.html', $tvars); |
| 124 | 103 | |
| 125 | 104 | MODS::PageWrapper->new->wrapper( |
| 126 | 105 | page_title => 'File changes', page_key => 'file_changes', |
| 127 | 106 | body_html => join('', @body), |
| 128 | 107 | userinfo => { display_name => 'Operator' }, |
| 129 | 108 | ); |
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.