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/schema_changes.cgi |
| Site | DriftSense self-monitor on local |
| Kind | local |
| Captured at | 2026-07-10 22:19:07 |
| Captured SHA | 9efbed09b8bf9803dc8ea232e1a8b7e672b0ac59ddfcb4e8348ef0ccceff0b09 |
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
| File present | yes |
| Size | 3827 bytes |
| Current SHA | dbd198527dfc |
| 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. +18 additions, -34 deletions, 75 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; use MODS::DBConnect; use MODS::Template; use MODS::PageWrapper; |
| 6 | 6 | use MODS::Charts; |
| 7 | use MODS::RangePicker; | |
| 8 | 7 | use POSIX (); |
| 9 | 8 | my $cgi = CGI->new; my $db = MODS::DBConnect->new; my $tpl = MODS::Template->new; |
| 10 | 9 | $|=1; print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; |
| 11 | 10 | my $dbh = $db->db_connect or die "DB connect failed\n"; |
| 12 | 11 | |
| 13 | my $r = MODS::RangePicker::parse($cgi); | |
| 14 | my $q = $cgi->param('q') || ''; | |
| 12 | my $days = int($cgi->param('days') || 30); $days = 1 if $days < 1; $days = 365 if $days > 365; | |
| 13 | my $q = $cgi->param('q') || ''; | |
| 15 | 14 | |
| 16 | my $where = $r->{sql_where}->('sc.change_datetime'); | |
| 17 | $where .= " AND (sc.database_name LIKE " . $dbh->quote('%'.$q.'%') | |
| 18 | . " OR sc.table_name LIKE " . $dbh->quote('%'.$q.'%') . ")" if length $q; | |
| 15 | my $where = "change_datetime >= DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 16 | $where .= " AND (database_name LIKE " . $dbh->quote('%'.$q.'%') . " OR table_name LIKE " . $dbh->quote('%'.$q.'%') . ")" if length $q; | |
| 19 | 17 | |
| 20 | 18 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 21 | 19 | SELECT sc.schema_change_id AS id, sc.database_name, sc.table_name, sc.changes, |
| 22 | UNIX_TIMESTAMP(sc.change_datetime) AS ts_epoch, | |
| 23 | 20 | sc.change_datetime, sc.is_ts_only, s.server_name |
| 24 | 21 | FROM SCHEMA_CHANGE sc |
| 25 | 22 | LEFT JOIN SERVERS s ON s.server_id = sc.server_id |
| 26 | 23 | WHERE $where |
| 27 | 24 | ORDER BY sc.change_datetime DESC |
| 28 | 25 | LIMIT 200 |
| 29 | 26 | ~, __FILE__, __LINE__); |
| 30 | ||
| 27 | # --- 30-day sparkline per database.table (schema DDL is much sparser | |
| 28 | # than file activity, so one bar per week reads better than one | |
| 29 | # per day, but we still ask for 30 days of buckets to reuse the | |
| 30 | # helper unchanged). | |
| 31 | 31 | my %spark; |
| 32 | 32 | { |
| 33 | 33 | my %pairs; |
| 34 | foreach my $row (@rows) { | |
| 35 | my $key = ($row->{database_name} // '') . '|' . ($row->{table_name} // ''); | |
| 36 | $pairs{$key} = [ $row->{database_name}, $row->{table_name} ]; | |
| 34 | foreach my $r (@rows) { | |
| 35 | my $key = ($r->{database_name} // '') . '|' . ($r->{table_name} // ''); | |
| 36 | $pairs{$key} = [ $r->{database_name}, $r->{table_name} ]; | |
| 37 | 37 | } |
| 38 | 38 | my @conds; |
| 39 | 39 | foreach my $p (values %pairs) { |
| 40 | 40 | my $qd = $dbh->quote($p->[0] // ''); |
| 41 | 41 | my $qt = $dbh->quote($p->[1] // ''); |
| 42 | 42 | push @conds, "(database_name = $qd AND table_name = $qt)"; |
| 43 | 43 | } |
| 44 | 44 | if (@conds) { |
| 45 | 45 | my $or = join(' OR ', @conds); |
| 46 | 46 | my @day_rows = $db->db_readwrite_multiple($dbh, qq~ |
| 47 | 47 | SELECT database_name, table_name, |
| 48 | 48 | DATE_FORMAT(change_datetime, '%Y-%m-%d') AS d, |
| 49 | 49 | COUNT(*) AS n |
| 50 | 50 | FROM SCHEMA_CHANGE |
| 51 | 51 | WHERE ($or) |
| 52 | 52 | AND change_datetime >= DATE_SUB(NOW(), INTERVAL 30 DAY) |
| 53 | 53 | GROUP BY database_name, table_name, d |
| 54 | 54 | ~, __FILE__, __LINE__); |
| 55 | 55 | my %by; |
| 56 | 56 | foreach my $dr (@day_rows) { |
| 57 | 57 | my $key = ($dr->{database_name} // '') . '|' . ($dr->{table_name} // ''); |
| 58 | 58 | $by{$key}{$dr->{d}} = $dr->{n}; |
| 59 | 59 | } |
| 60 | 60 | my $now = time; |
| 61 | 61 | my @day_keys; |
| 62 | 62 | for (my $i = 29; $i >= 0; $i--) { |
| 63 | 63 | push @day_keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400)); |
| 64 | 64 | } |
| 65 | 65 | foreach my $key (keys %pairs) { |
| 66 | 66 | my @series; |
| 67 | 67 | for my $k (@day_keys) { push @series, ($by{$key}{$k} || 0); } |
| 68 | 68 | $spark{$key} = \@series; |
| 69 | 69 | } |
| 70 | 70 | } |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | foreach my $row (@rows) { | |
| 74 | my $c = $row->{changes} // ''; | |
| 73 | foreach my $r (@rows) { | |
| 74 | my $c = $r->{changes} // ''; | |
| 75 | 75 | $c =~ s/\s+/ /g; |
| 76 | $row->{changes_preview} = length($c) > 100 ? substr($c, 0, 97) . '...' : $c; | |
| 77 | $row->{diff_url} = "/diff.cgi?kind=schema&id=$row->{id}"; | |
| 78 | $row->{ts_epoch} //= 0; | |
| 79 | my $key = ($row->{database_name} // '') . '|' . ($row->{table_name} // ''); | |
| 76 | $r->{changes_preview} = length($c) > 100 ? substr($c, 0, 97) . '...' : $c; | |
| 77 | $r->{diff_url} = "/diff.cgi?kind=schema&id=$r->{id}"; | |
| 78 | my $key = ($r->{database_name} // '') . '|' . ($r->{table_name} // ''); | |
| 80 | 79 | my $series = $spark{$key}; |
| 81 | $row->{sparkline_svg} = $series | |
| 80 | $r->{sparkline_svg} = $series | |
| 82 | 81 | ? MODS::Charts::sparkline(values => $series, width => 88, height => 20, color => '#f59e0b') |
| 83 | 82 | : ''; |
| 84 | 83 | } |
| 85 | 84 | my $total = scalar @rows; |
| 86 | 85 | |
| 87 | 86 | $db->db_disconnect($dbh); |
| 88 | ||
| 89 | my $picker_html = MODS::RangePicker::picker_html( | |
| 90 | action => '/schema_changes.cgi', | |
| 91 | range => $r, | |
| 92 | hidden => { q => $q }, | |
| 93 | ); | |
| 94 | 87 | |
| 95 | my $tvars = { | |
| 96 | rows => \@rows, | |
| 97 | has_rows => $total ? 1 : 0, | |
| 98 | total => $total, | |
| 99 | q => $q, | |
| 100 | picker_html => $picker_html, | |
| 101 | range_label => $r->{range_label}, | |
| 102 | is_day_view => $r->{is_day} ? 1 : 0, | |
| 103 | day_label => $r->{on}, | |
| 104 | }; | |
| 88 | my $tvars = { rows => \@rows, has_rows => $total ? 1 : 0, total => $total, days => $days, q => $q }; | |
| 105 | 89 | my @body = $tpl->template('schema_changes.html', $tvars); |
| 106 | 90 | MODS::PageWrapper->new->wrapper( |
| 107 | 91 | page_title => 'Schema changes', page_key => 'schema_changes', |
| 108 | 92 | body_html => join('', @body), userinfo => { display_name => 'Operator' }, |
| 109 | 93 | ); |
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.