Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/auto_ack.cgi
Diff
/var/www/vhosts/3dshawn.com/admin.3dshawn.com/auto_ack.cgi
added on local at 2026-07-10 14:13:09
Added
+0
lines
Removed
-0
lines
Context
446
unchanged
Blobs
from 8834775a8592
to 8834775a8592
to 8834775a8592
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | #!/usr/bin/perl |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # Meta-Admin -- /auto_ack.cgi |
| 4 | 4 | # |
| 5 | 5 | # Auto-acknowledge scheduler control panel. |
| 6 | 6 | # /auto_ack.cgi -> list rules + kill switch + queue + history |
| 7 | 7 | # /auto_ack.cgi?edit=NEW|ID -> rule form |
| 8 | 8 | # POST save=1 -> insert/update rule |
| 9 | 9 | # POST toggle=ID -> flip rule active |
| 10 | 10 | # POST kill_switch=on|off -> flip global_enabled setting |
| 11 | 11 | #====================================================================== |
| 12 | 12 | use strict; |
| 13 | 13 | use warnings; |
| 14 | 14 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; |
| 15 | 15 | use CGI (); |
| 16 | 16 | use MODS::Login; |
| 17 | 17 | use MODS::MetaAdmin::Config; |
| 18 | 18 | use MODS::MetaAdmin::Wrapper; |
| 19 | 19 | use MODS::MetaAdmin::AutoAck; |
| 20 | 20 | use MODS::MetaAdmin::CannedReplies; |
| 21 | 21 | use MODS::MetaAdmin::SupportInbox; |
| 22 | 22 | |
| 23 | 23 | my $auth = MODS::Login->new; |
| 24 | 24 | my $u = $auth->login_verify; |
| 25 | 25 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } |
| 26 | 26 | |
| 27 | 27 | my $cgi = CGI->new; |
| 28 | 28 | my $aa = MODS::MetaAdmin::AutoAck->new; |
| 29 | 29 | my $cr = MODS::MetaAdmin::CannedReplies->new; |
| 30 | 30 | |
| 31 | 31 | sub _esc { my $s=shift; $s//=''; $s=~s/&/&/g; $s=~s/</</g; $s=~s/>/>/g; $s=~s/"/"/g; return $s; } |
| 32 | 32 | |
| 33 | 33 | sub _rel_age { |
| 34 | 34 | my $ts = shift; |
| 35 | 35 | return '-' unless $ts && $ts =~ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})$/; |
| 36 | 36 | require Time::Local; |
| 37 | 37 | my $epoch = eval { Time::Local::timelocal($6,$5,$4,$3,$2-1,$1-1900) } || 0; |
| 38 | 38 | return '-' unless $epoch; |
| 39 | 39 | my $delta = time - $epoch; |
| 40 | 40 | my $sign = $delta < 0 ? 'in ' : ''; |
| 41 | 41 | my $abs = abs($delta); |
| 42 | 42 | my $suffix= $delta < 0 ? '' : ' ago'; |
| 43 | 43 | return $sign . 'just now' . $suffix if $abs < 60; |
| 44 | 44 | return $sign . int($abs/60) . ' m'.$suffix if $abs < 3600; |
| 45 | 45 | return $sign . int($abs/3600) . ' h'.$suffix if $abs < 86400; |
| 46 | 46 | return $sign . int($abs/86400).' d'.$suffix; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | my $method = $ENV{REQUEST_METHOD} || 'GET'; |
| 50 | 50 | |
| 51 | 51 | # --- POST: kill switch flip |
| 52 | 52 | if ($method eq 'POST' && defined $cgi->param('kill_switch')) { |
| 53 | 53 | my $val = $cgi->param('kill_switch'); |
| 54 | 54 | $aa->set_global_enabled($val eq 'on' ? 1 : 0); |
| 55 | 55 | print "Status: 302 Found\nLocation: /auto_ack.cgi?ks=".($val eq 'on'?'on':'off')."\n\n"; |
| 56 | 56 | exit; |
| 57 | 57 | } |
| 58 | 58 | # --- POST: save rule |
| 59 | 59 | if ($method eq 'POST' && $cgi->param('save')) { |
| 60 | 60 | my $id = int($cgi->param('id') || 0); |
| 61 | 61 | my $rid = $aa->save_rule( |
| 62 | 62 | id => $id, |
| 63 | 63 | name => scalar($cgi->param('name')), |
| 64 | 64 | description => scalar($cgi->param('description')), |
| 65 | 65 | template_id => scalar($cgi->param('template_id')), |
| 66 | 66 | site_scope => scalar($cgi->param('site_scope')) || 'all', |
| 67 | 67 | min_delay_seconds => scalar($cgi->param('min_delay_seconds')), |
| 68 | 68 | max_delay_seconds => scalar($cgi->param('max_delay_seconds')), |
| 69 | 69 | match_new_only => $cgi->param('match_new_only') ? 1 : 0, |
| 70 | 70 | match_no_admin_reply => $cgi->param('match_no_admin_reply') ? 1 : 0, |
| 71 | 71 | match_customer_other_threads_min => scalar($cgi->param('match_customer_other_threads_min')), |
| 72 | 72 | match_customer_other_threads_max => scalar($cgi->param('match_customer_other_threads_max')), |
| 73 | 73 | business_hours_only => $cgi->param('business_hours_only') ? 1 : 0, |
| 74 | 74 | business_hours_start => scalar($cgi->param('business_hours_start')), |
| 75 | 75 | business_hours_end => scalar($cgi->param('business_hours_end')), |
| 76 | 76 | priority => scalar($cgi->param('priority')), |
| 77 | 77 | is_active => $cgi->param('is_active') ? 1 : 0, |
| 78 | 78 | ); |
| 79 | 79 | print "Status: 302 Found\nLocation: /auto_ack.cgi?saved=$rid\n\n"; |
| 80 | 80 | exit; |
| 81 | 81 | } |
| 82 | 82 | # --- POST: toggle rule |
| 83 | 83 | if ($method eq 'POST' && $cgi->param('toggle')) { |
| 84 | 84 | my $id = int($cgi->param('toggle')); |
| 85 | 85 | $aa->toggle_rule($id); |
| 86 | 86 | print "Status: 302 Found\nLocation: /auto_ack.cgi?toggled=$id\n\n"; |
| 87 | 87 | exit; |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | # --- Edit form |
| 91 | 91 | my $edit = $cgi->param('edit') || ''; |
| 92 | 92 | if ($edit) { |
| 93 | 93 | my $r = ($edit eq 'NEW') ? {} : $aa->get_rule($edit); |
| 94 | 94 | $r ||= {}; |
| 95 | 95 | my %d = ( |
| 96 | 96 | id => $r->{id} // '', name => $r->{name} // '', |
| 97 | 97 | description => $r->{description} // '', |
| 98 | 98 | template_id => $r->{template_id} // '', |
| 99 | 99 | site_scope => $r->{site_scope} // 'all', |
| 100 | 100 | min_delay_seconds => $r->{min_delay_seconds} // 120, |
| 101 | 101 | max_delay_seconds => $r->{max_delay_seconds} // 480, |
| 102 | 102 | match_customer_other_threads_min => $r->{match_customer_other_threads_min} // 0, |
| 103 | 103 | match_customer_other_threads_max => $r->{match_customer_other_threads_max} // 999, |
| 104 | 104 | business_hours_start => $r->{business_hours_start} // 8, |
| 105 | 105 | business_hours_end => $r->{business_hours_end} // 23, |
| 106 | 106 | priority => $r->{priority} // 100, |
| 107 | 107 | ); |
| 108 | 108 | my $c_new = (defined $r->{match_new_only} && !$r->{match_new_only}) ? '' : 'checked'; |
| 109 | 109 | my $c_noadm = (defined $r->{match_no_admin_reply} && !$r->{match_no_admin_reply}) ? '' : 'checked'; |
| 110 | 110 | my $c_bh = ($r->{business_hours_only}) ? 'checked' : ''; |
| 111 | 111 | my $c_active = (defined $r->{is_active} && !$r->{is_active}) ? '' : 'checked'; |
| 112 | 112 | |
| 113 | 113 | my $templates = $cr->list; |
| 114 | 114 | my $tpl_opts = ''; |
| 115 | 115 | foreach my $t (@$templates) { |
| 116 | 116 | my $sel = ($t->{id} == ($d{template_id} || 0)) ? ' selected' : ''; |
| 117 | 117 | my $name = _esc($t->{name}); my $tid = _esc($t->{id}); |
| 118 | 118 | my $inactive_note = $t->{is_active} ? '' : ' (inactive)'; |
| 119 | 119 | $tpl_opts .= qq~<option value="$tid"$sel>#$tid: $name$inactive_note</option>~; |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | 122 | my $supported = MODS::MetaAdmin::SupportInbox->new->supported_slugs; |
| 123 | 123 | my $scope_hint = 'all, or comma-list of: ' . join(', ', @$supported); |
| 124 | 124 | |
| 125 | 125 | for my $k (keys %d) { $d{$k} = _esc($d{$k}) } |
| 126 | 126 | my $title = ($edit eq 'NEW') ? 'New auto-ack rule' : "Edit rule #$d{id}"; |
| 127 | 127 | |
| 128 | 128 | my $body = qq~ |
| 129 | 129 | <div class="page-head"><div class="left"> |
| 130 | 130 | <a href="/auto_ack.cgi" class="page-eyebrow" style="text-decoration:none"><span class="dot"></span> ← Back to auto-ack</a> |
| 131 | 131 | <h1 class="page-title">$title</h1> |
| 132 | 132 | <p class="page-subtitle">Rules select which threads get auto-acked. Every rule points at one canned template. Match conditions AND together -- ALL must be true for a rule to fire. Multiple rules can be active; they fire independently.</p> |
| 133 | 133 | </div></div> |
| 134 | 134 | <style> |
| 135 | 135 | .form-grid { display:grid; gap:14px; max-width:900px; } |
| 136 | 136 | .form-grid label.field { display:block; font-size:11px; letter-spacing:1.4px; text-transform:uppercase; color:var(--col-text-dim); font-weight:700; margin-bottom:6px; } |
| 137 | 137 | .form-grid input[type=text], .form-grid input[type=number], .form-grid textarea, .form-grid select { |
| 138 | 138 | width:100%; box-sizing:border-box; background:var(--col-surface); color:var(--col-text); |
| 139 | 139 | border:1px solid var(--col-border); border-radius:10px; padding:9px 12px; |
| 140 | 140 | font-family:inherit; font-size:13px; outline:none; |
| 141 | 141 | } |
| 142 | 142 | .form-grid select { cursor:pointer; } |
| 143 | 143 | .form-grid input:focus, .form-grid textarea:focus, .form-grid select:focus { border-color:var(--col-brand, var(--col-primary, #b53048)); } |
| 144 | 144 | .form-row-inline { display:flex; gap:14px; align-items:end; } |
| 145 | 145 | .form-row-inline > * { flex:1; min-width:0; } |
| 146 | 146 | .form-checkbox { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--col-text); } |
| 147 | 147 | .btn-primary { background:var(--col-brand, var(--col-primary, #b53048)); color:#fff; border:none; padding:10px 20px; border-radius:999px; font-weight:600; cursor:pointer; font-size:13px; } |
| 148 | 148 | .btn-secondary { background:transparent; color:var(--col-text-muted); border:1px solid var(--col-border); padding:10px 18px; border-radius:999px; font-size:13px; text-decoration:none; } |
| 149 | 149 | .section-title { font-size:11px; letter-spacing:1.4px; text-transform:uppercase; color:var(--col-text-muted); font-weight:700; margin:6px 0 4px; } |
| 150 | 150 | hr.form-sep { border:none; border-top:1px solid var(--col-border); margin:4px 0; } |
| 151 | 151 | </style> |
| 152 | 152 | <form method="post" action="/auto_ack.cgi" class="form-grid"> |
| 153 | 153 | <input type="hidden" name="save" value="1"> |
| 154 | 154 | <input type="hidden" name="id" value="$d{id}"> |
| 155 | 155 | |
| 156 | 156 | <div> |
| 157 | 157 | <label class="field">Rule name</label> |
| 158 | 158 | <input type="text" name="name" value="$d{name}" required maxlength="80" placeholder="e.g. First-touch ack on new threads"> |
| 159 | 159 | </div> |
| 160 | 160 | <div> |
| 161 | 161 | <label class="field">Description</label> |
| 162 | 162 | <input type="text" name="description" value="$d{description}" maxlength="255" placeholder="Optional notes for future you"> |
| 163 | 163 | </div> |
| 164 | 164 | <div> |
| 165 | 165 | <label class="field">Template to fire</label> |
| 166 | 166 | <select name="template_id" required>$tpl_opts</select> |
| 167 | 167 | </div> |
| 168 | 168 | <div> |
| 169 | 169 | <label class="field">Site scope</label> |
| 170 | 170 | <input type="text" name="site_scope" value="$d{site_scope}" placeholder="$scope_hint"> |
| 171 | 171 | </div> |
| 172 | 172 | |
| 173 | 173 | <div class="section-title">Delay</div> |
| 174 | 174 | <div class="form-row-inline"> |
| 175 | 175 | <div><label class="field">Min delay (seconds)</label><input type="number" name="min_delay_seconds" value="$d{min_delay_seconds}" min="10" max="7200"></div> |
| 176 | 176 | <div><label class="field">Max delay (seconds)</label><input type="number" name="max_delay_seconds" value="$d{max_delay_seconds}" min="10" max="7200"></div> |
| 177 | 177 | </div> |
| 178 | 178 | |
| 179 | 179 | <hr class="form-sep"> |
| 180 | 180 | <div class="section-title">Match conditions (all must be true)</div> |
| 181 | 181 | <div class="form-checkbox"><input type="checkbox" name="match_new_only" $c_new> Only fire on threads created in the last 72h</div> |
| 182 | 182 | <div class="form-checkbox"><input type="checkbox" name="match_no_admin_reply" $c_noadm> Only fire if no admin has replied yet (double-check at fire time)</div> |
| 183 | 183 | <div class="form-row-inline"> |
| 184 | 184 | <div><label class="field">Customer's OTHER open threads ≥</label><input type="number" name="match_customer_other_threads_min" value="$d{match_customer_other_threads_min}" min="0" max="999"></div> |
| 185 | 185 | <div><label class="field">Customer's OTHER open threads ≤</label><input type="number" name="match_customer_other_threads_max" value="$d{match_customer_other_threads_max}" min="0" max="999"></div> |
| 186 | 186 | </div> |
| 187 | 187 | <p style="color:var(--col-text-dim);font-size:11.5px;margin:0">Tip: set min=0 max=0 for "this is their only open thread". Set min=1 for a "second-touch" template that references their other open items.</p> |
| 188 | 188 | |
| 189 | 189 | <hr class="form-sep"> |
| 190 | 190 | <div class="section-title">Business hours (Mountain Time)</div> |
| 191 | 191 | <div class="form-checkbox"><input type="checkbox" name="business_hours_only" $c_bh> Only fire during these hours</div> |
| 192 | 192 | <div class="form-row-inline"> |
| 193 | 193 | <div><label class="field">Start hour (0-23)</label><input type="number" name="business_hours_start" value="$d{business_hours_start}" min="0" max="23"></div> |
| 194 | 194 | <div><label class="field">End hour (0-23)</label><input type="number" name="business_hours_end" value="$d{business_hours_end}" min="0" max="23"></div> |
| 195 | 195 | </div> |
| 196 | 196 | |
| 197 | 197 | <hr class="form-sep"> |
| 198 | 198 | <div class="form-row-inline"> |
| 199 | 199 | <div><label class="field">Priority</label><input type="number" name="priority" value="$d{priority}" min="0" max="9999"></div> |
| 200 | 200 | <div><label class="field">Active</label><div class="form-checkbox"><input type="checkbox" name="is_active" $c_active> Rule participates in the scheduler</div></div> |
| 201 | 201 | </div> |
| 202 | 202 | |
| 203 | 203 | <div style="display:flex;align-items:center;gap:12px;margin-top:8px"> |
| 204 | 204 | <button type="submit" class="btn-primary">Save rule</button> |
| 205 | 205 | <a href="/auto_ack.cgi" class="btn-secondary">Cancel</a> |
| 206 | 206 | </div> |
| 207 | 207 | </form> |
| 208 | 208 | ~; |
| 209 | 209 | |
| 210 | 210 | MODS::MetaAdmin::Wrapper->new->render( |
| 211 | 211 | title => $title, page_key => 'auto_ack', body => $body, userinfo => $u, |
| 212 | 212 | ); |
| 213 | 213 | exit; |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | 216 | #--------------------------------------------------------------------- |
| 217 | 217 | # List view |
| 218 | 218 | #--------------------------------------------------------------------- |
| 219 | 219 | my $enabled = $aa->is_globally_enabled; |
| 220 | 220 | my $rules = $aa->list_rules; |
| 221 | 221 | my $queue = $aa->list_queued(limit => 20); |
| 222 | 222 | my $history = $aa->list_history(limit => 20); |
| 223 | 223 | |
| 224 | 224 | my $body = q~ |
| 225 | 225 | <div class="page-head"><div class="left"> |
| 226 | 226 | <span class="page-eyebrow"><span class="dot"></span> Scheduler</span> |
| 227 | 227 | <h1 class="page-title">Auto-Acknowledge</h1> |
| 228 | 228 | <p class="page-subtitle">Rules watch for new support threads and auto-fire a canned acknowledgment after a randomized delay. Every reply goes out through each site's own send endpoint (same as manual). Templates are strictly hand-raises -- never answers -- so a customer knows we saw them while the real work happens on your schedule.</p> |
| 229 | 229 | </div></div> |
| 230 | 230 | ~; |
| 231 | 231 | |
| 232 | 232 | # Global kill switch tile + status |
| 233 | 233 | my ($ks_state, $ks_class, $ks_next, $ks_btn_label) = $enabled |
| 234 | 234 | ? ('ENABLED', 'kill-on', 'off', 'Turn off') |
| 235 | 235 | : ('DISABLED', 'kill-off', 'on', 'Turn on'); |
| 236 | 236 | my $ks_note = $enabled |
| 237 | 237 | ? 'Discovery + firing are active. Cron ticks every minute.' |
| 238 | 238 | : 'Nothing will fire. Rules are still editable, queue insertions are paused.'; |
| 239 | 239 | |
| 240 | 240 | my $saved = $cgi->param('saved') || ''; |
| 241 | 241 | my $toggled = $cgi->param('toggled') || ''; |
| 242 | 242 | my $ks = $cgi->param('ks') || ''; |
| 243 | 243 | if ($saved) { |
| 244 | 244 | $body .= qq~<div class="banner banner-ok" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(34,197,94,.10);border:1px solid rgba(34,197,94,.35);color:#a7f3d0;font-size:13px">Saved rule #$saved.</div>~; |
| 245 | 245 | } |
| 246 | 246 | if ($toggled) { |
| 247 | 247 | $body .= qq~<div class="banner" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(6,182,212,.10);border:1px solid rgba(6,182,212,.35);color:#7dd3fc;font-size:13px">Toggled rule #$toggled.</div>~; |
| 248 | 248 | } |
| 249 | 249 | if ($ks eq 'on') { |
| 250 | 250 | $body .= q~<div class="banner" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(6,182,212,.10);border:1px solid rgba(6,182,212,.35);color:#7dd3fc;font-size:13px">Auto-ack turned ON. First tick will pick up eligible threads within a minute.</div>~; |
| 251 | 251 | } |
| 252 | 252 | if ($ks eq 'off') { |
| 253 | 253 | $body .= q~<div class="banner" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(180,150,50,.10);border:1px solid rgba(180,150,50,.35);color:#facc15;font-size:13px">Auto-ack turned OFF. Nothing new will queue or fire.</div>~; |
| 254 | 254 | } |
| 255 | 255 | |
| 256 | 256 | $body .= qq~ |
| 257 | 257 | <style> |
| 258 | 258 | .kill-tile { position:relative; padding:28px 24px; border-radius:14px; overflow:hidden; } |
| 259 | 259 | .kill-tile.kill-off { background:linear-gradient(135deg, #0f0f0f, #1a1414); border:1px solid var(--col-border); } |
| 260 | 260 | .kill-tile.kill-on { background:linear-gradient(135deg, #451a1f, #1c0f11); border:1px solid rgba(181,48,72,.5); box-shadow:0 0 32px rgba(181,48,72,.15) inset; } |
| 261 | 261 | .kill-tile h2 { margin:0; font-size:24px; letter-spacing:.5px; color:var(--col-text); } |
| 262 | 262 | .kill-tile .state { font-size:11px; letter-spacing:1.4px; text-transform:uppercase; font-weight:700; } |
| 263 | 263 | .kill-tile.kill-on .state { color:#fca5a5; } |
| 264 | 264 | .kill-tile.kill-off .state { color:var(--col-text-dim); } |
| 265 | 265 | .kill-tile p { color:var(--col-text-2); margin:6px 0 14px; font-size:13px; } |
| 266 | 266 | .kill-btn { background:transparent; color:var(--col-text); border:1px solid var(--col-border); padding:8px 18px; border-radius:999px; font-weight:600; cursor:pointer; font-size:12.5px; } |
| 267 | 267 | .kill-tile.kill-off .kill-btn:hover { background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.5); color:#a7f3d0; } |
| 268 | 268 | .kill-tile.kill-on .kill-btn:hover { background:rgba(255,255,255,.05); } |
| 269 | 269 | </style> |
| 270 | 270 | <div class="dash-grid" style="grid-template-columns: 1.6fr 1fr; margin-bottom:16px"> |
| 271 | 271 | <div class="kill-tile $ks_class"> |
| 272 | 272 | <div class="state">$ks_state</div> |
| 273 | 273 | <h2>Global auto-ack</h2> |
| 274 | 274 | <p>$ks_note</p> |
| 275 | 275 | <form method="post" action="/auto_ack.cgi" style="display:inline"> |
| 276 | 276 | <input type="hidden" name="kill_switch" value="$ks_next"> |
| 277 | 277 | <button type="submit" class="kill-btn">$ks_btn_label</button> |
| 278 | 278 | </form> |
| 279 | 279 | </div> |
| 280 | 280 | <div class="module glow-cyan"> |
| 281 | 281 | <div class="module-head"><div class="left"> |
| 282 | 282 | <div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div> |
| 283 | 283 | <div class="module-title">Cron</div> |
| 284 | 284 | <span class="module-subtitle">Ticks every minute from <code>/etc/cron.d/meta_admin_auto_ack</code>. Log at <code>/var/www/vhosts/3dshawn.com/admin.3dshawn.com/auto_ack.log</code>.</span> |
| 285 | 285 | </div></div> |
| 286 | 286 | <div class="module-body kpi kpi-cyan"> |
| 287 | 287 | <div class="num">60s</div><div class="lbl">tick interval</div> |
| 288 | 288 | <div class="sub">safe by default -- discover + fire run only when global is ON</div> |
| 289 | 289 | </div> |
| 290 | 290 | </div> |
| 291 | 291 | </div> |
| 292 | 292 | ~; |
| 293 | 293 | |
| 294 | 294 | # Rules table |
| 295 | 295 | my $newbtn = '<a class="btn-new" href="/auto_ack.cgi?edit=NEW" style="background:var(--col-brand, var(--col-primary, #b53048));color:#fff;border:none;padding:8px 16px;border-radius:999px;font-weight:600;font-size:12px;text-decoration:none;display:inline-flex;align-items:center;gap:6px">+ New rule</a>'; |
| 296 | 296 | my $rules_html = ''; |
| 297 | 297 | if (!@$rules) { |
| 298 | 298 | $rules_html = q~<div style="padding:36px 14px;text-align:center;color:var(--col-text-dim);font-size:13px">No rules yet. Create one to teach the scheduler what to look for.</div>~; |
| 299 | 299 | } else { |
| 300 | 300 | $rules_html .= q~ |
| 301 | 301 | <style> |
| 302 | 302 | .ar-list { display:grid; row-gap:2px; font-size:13px; } |
| 303 | 303 | .ar-row { display:grid; grid-template-columns: 50px 1.6fr 1.4fr 1.2fr 100px 100px; gap:14px; align-items:center; padding:12px 14px; border-bottom:1px solid rgba(255,255,255,.03); } |
| 304 | 304 | .ar-row.inactive { opacity:.5; } |
| 305 | 305 | .ar-row:hover { background:rgba(255,255,255,.02); } |
| 306 | 306 | .ar-pri { color:var(--col-text-dim); font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; font-size:11px; } |
| 307 | 307 | .ar-name { font-weight:600; color:var(--col-text); } |
| 308 | 308 | .ar-desc { color:var(--col-text-dim); font-size:11.5px; margin-top:2px; } |
| 309 | 309 | .ar-tpl { color:var(--col-text-2); font-size:12.5px; } |
| 310 | 310 | .ar-scope{ color:var(--col-text-dim); font-size:11px; font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; } |
| 311 | 311 | .ar-delay{ color:var(--col-text-2); font-size:12px; font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; text-align:center; } |
| 312 | 312 | .ar-status{ text-align:center; font-size:10.5px; letter-spacing:.6px; text-transform:uppercase; padding:3px 8px; border-radius:999px; font-weight:700; } |
| 313 | 313 | .ar-status.on { background:rgba(34,197,94,.10); color:#a7f3d0; border:1px solid rgba(34,197,94,.35); } |
| 314 | 314 | .ar-status.off { background:rgba(255,255,255,.04); color:var(--col-text-dim); border:1px solid var(--col-border); } |
| 315 | 315 | .ar-actions a, .ar-actions button { background:transparent; border:1px solid var(--col-border); color:var(--col-text-muted); padding:4px 10px; border-radius:6px; font-size:11px; cursor:pointer; text-decoration:none; font-family:inherit; } |
| 316 | 316 | .ar-actions a:hover, .ar-actions button:hover { color:var(--col-text); border-color:var(--col-brand, var(--col-primary, #b53048)); } |
| 317 | 317 | </style>~; |
| 318 | 318 | foreach my $r (@$rules) { |
| 319 | 319 | my $cls = $r->{is_active} ? '' : ' inactive'; |
| 320 | 320 | my $tpl_row = $cr->get($r->{template_id}); |
| 321 | 321 | my $tpl_nm = _esc($tpl_row ? $tpl_row->{name} : '(missing template)'); |
| 322 | 322 | my ($sc, $sl) = $r->{is_active} ? ('on','ACTIVE') : ('off','OFF'); |
| 323 | 323 | my $id_h = _esc($r->{id}); |
| 324 | 324 | my $delay = _esc($r->{min_delay_seconds}) . '-' . _esc($r->{max_delay_seconds}) . 's'; |
| 325 | 325 | $rules_html .= qq~ |
| 326 | 326 | <div class="ar-row$cls"> |
| 327 | 327 | <div class="ar-pri">#~._esc($r->{priority}).qq~</div> |
| 328 | 328 | <div> |
| 329 | 329 | <div class="ar-name">~._esc($r->{name}).qq~</div> |
| 330 | 330 | <div class="ar-desc">~._esc($r->{description}||'').qq~</div> |
| 331 | 331 | </div> |
| 332 | 332 | <div class="ar-tpl">$tpl_nm <span class="ar-scope">/ #~._esc($r->{template_id}).qq~</span></div> |
| 333 | 333 | <div class="ar-scope">~._esc($r->{site_scope}).qq~</div> |
| 334 | 334 | <div class="ar-delay">$delay</div> |
| 335 | 335 | <div style="display:flex;gap:6px;justify-content:flex-end;align-items:center"> |
| 336 | 336 | <span class="ar-status $sc">$sl</span> |
| 337 | 337 | <div class="ar-actions" style="display:flex;gap:4px"> |
| 338 | 338 | <a href="/auto_ack.cgi?edit=$id_h">Edit</a> |
| 339 | 339 | <form method="post" action="/auto_ack.cgi" style="display:inline"> |
| 340 | 340 | <input type="hidden" name="toggle" value="$id_h"> |
| 341 | 341 | <button type="submit">Toggle</button> |
| 342 | 342 | </form> |
| 343 | 343 | </div> |
| 344 | 344 | </div> |
| 345 | 345 | </div>~; |
| 346 | 346 | } |
| 347 | 347 | } |
| 348 | 348 | $body .= qq~ |
| 349 | 349 | <div class="module glow-magenta" style="margin-bottom:16px"> |
| 350 | 350 | <div class="module-head"> |
| 351 | 351 | <div class="left"> |
| 352 | 352 | <div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div> |
| 353 | 353 | <div class="module-title">Rules</div> |
| 354 | 354 | <span class="module-subtitle">Each rule maps one canned template to a match condition set. Priority: lower fires first; rules are independent, all matching rules can fire.</span> |
| 355 | 355 | </div> |
| 356 | 356 | <div class="right" style="padding-right:14px">$newbtn</div> |
| 357 | 357 | </div> |
| 358 | 358 | <div class="module-body" style="padding:0"><div class="ar-list">$rules_html</div></div> |
| 359 | 359 | </div> |
| 360 | 360 | ~; |
| 361 | 361 | |
| 362 | 362 | # Queue view |
| 363 | 363 | my $q_html = ''; |
| 364 | 364 | if (!@$queue) { |
| 365 | 365 | $q_html = q~<div style="padding:24px 14px;text-align:center;color:var(--col-text-dim);font-size:13px">Nothing queued.</div>~; |
| 366 | 366 | } else { |
| 367 | 367 | $q_html .= q~<style> |
| 368 | 368 | .qq-row { display:grid; grid-template-columns: 90px 90px 1.8fr 1.4fr 1fr; gap:12px; align-items:center; padding:10px 14px; border-bottom:1px solid rgba(255,255,255,.03); font-size:12.5px; } |
| 369 | 369 | .qq-when { color:var(--col-text-dim); font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; font-size:11.5px; } |
| 370 | 370 | .qq-site { font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; color:var(--col-text-muted); font-size:11px; } |
| 371 | 371 | </style>~; |
| 372 | 372 | foreach my $q (@$queue) { |
| 373 | 373 | my $site = _esc($q->{site}); |
| 374 | 374 | my $tid = _esc($q->{thread_id}); |
| 375 | 375 | my $tpl = _esc($q->{template_name} || '#'.$q->{template_id}); |
| 376 | 376 | my $rule = _esc($q->{rule_name} || '#'.$q->{rule_id}); |
| 377 | 377 | my $when = _rel_age($q->{fire_at}); |
| 378 | 378 | my $href = "/support.cgi?thread=$site:$tid"; |
| 379 | 379 | $q_html .= qq~ |
| 380 | 380 | <div class="qq-row"> |
| 381 | 381 | <div class="qq-when">fire $when</div> |
| 382 | 382 | <div class="qq-site">$site</div> |
| 383 | 383 | <div><a href="$href" style="color:var(--col-text);text-decoration:none">thread #$tid</a></div> |
| 384 | 384 | <div>$tpl</div> |
| 385 | 385 | <div>via rule: $rule</div> |
| 386 | 386 | </div>~; |
| 387 | 387 | } |
| 388 | 388 | } |
| 389 | 389 | $body .= qq~ |
| 390 | 390 | <div class="module glow-cyan" style="margin-bottom:16px"> |
| 391 | 391 | <div class="module-head"><div class="left"> |
| 392 | 392 | <div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div> |
| 393 | 393 | <div class="module-title">Queued (waiting to fire)</div> |
| 394 | 394 | <span class="module-subtitle">Rows are re-verified at fire time -- if a human replied to that thread first, the row is auto-skipped and marked in history.</span> |
| 395 | 395 | </div></div> |
| 396 | 396 | <div class="module-body" style="padding:0">$q_html</div> |
| 397 | 397 | </div> |
| 398 | 398 | ~; |
| 399 | 399 | |
| 400 | 400 | # History view |
| 401 | 401 | my $h_html = ''; |
| 402 | 402 | if (!@$history) { |
| 403 | 403 | $h_html = q~<div style="padding:24px 14px;text-align:center;color:var(--col-text-dim);font-size:13px">No history yet.</div>~; |
| 404 | 404 | } else { |
| 405 | 405 | $h_html .= q~<style> |
| 406 | 406 | .hh-row { display:grid; grid-template-columns: 80px 80px 90px 1.5fr 1.4fr 2fr; gap:10px; align-items:center; padding:9px 14px; border-bottom:1px solid rgba(255,255,255,.03); font-size:12px; } |
| 407 | 407 | .hh-when { color:var(--col-text-dim); font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; font-size:11px; } |
| 408 | 408 | .hh-status { text-align:center; font-size:10px; letter-spacing:.5px; text-transform:uppercase; padding:2px 7px; border-radius:999px; font-weight:700; } |
| 409 | 409 | .hh-status.sent { background:rgba(34,197,94,.10); color:#a7f3d0; border:1px solid rgba(34,197,94,.35); } |
| 410 | 410 | .hh-status.skipped { background:rgba(180,150,50,.10); color:#facc15; border:1px solid rgba(180,150,50,.35); } |
| 411 | 411 | .hh-status.failed { background:rgba(239,68,68,.10); color:#fca5a5; border:1px solid rgba(239,68,68,.35); } |
| 412 | 412 | .hh-note { color:var(--col-text-dim); font-size:11px; } |
| 413 | 413 | </style>~; |
| 414 | 414 | foreach my $h (@$history) { |
| 415 | 415 | my $when = _rel_age($h->{sent_at} || $h->{discovered_at}); |
| 416 | 416 | my $site = _esc($h->{site}); |
| 417 | 417 | my $tid = _esc($h->{thread_id}); |
| 418 | 418 | my $tpl = _esc($h->{template_name} || '#'.$h->{template_id}); |
| 419 | 419 | my $stat = $h->{status}; |
| 420 | 420 | my $note = _esc($h->{skip_reason} || $h->{error_message} || ($stat eq 'sent' ? 'msg #' . ($h->{message_id} || '?') : '')); |
| 421 | 421 | my $href = "/support.cgi?thread=$site:$tid"; |
| 422 | 422 | $h_html .= qq~ |
| 423 | 423 | <div class="hh-row"> |
| 424 | 424 | <div class="hh-when">$when</div> |
| 425 | 425 | <div class="hh-status $stat">$stat</div> |
| 426 | 426 | <div class="hh-when">$site</div> |
| 427 | 427 | <div><a href="$href" style="color:var(--col-text);text-decoration:none">thread #$tid</a></div> |
| 428 | 428 | <div>$tpl</div> |
| 429 | 429 | <div class="hh-note">$note</div> |
| 430 | 430 | </div>~; |
| 431 | 431 | } |
| 432 | 432 | } |
| 433 | 433 | $body .= qq~ |
| 434 | 434 | <div class="module glow-lime"> |
| 435 | 435 | <div class="module-head"><div class="left"> |
| 436 | 436 | <div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></div> |
| 437 | 437 | <div class="module-title">Recent history</div> |
| 438 | 438 | <span class="module-subtitle">Sent / skipped / failed fires, newest first. Skip reasons: <code>admin_replied_first</code> = a human beat us to it; <code>thread_closed</code> = customer closed it; <code>outside_hours</code> = business-hours guard tripped.</span> |
| 439 | 439 | </div></div> |
| 440 | 440 | <div class="module-body" style="padding:0">$h_html</div> |
| 441 | 441 | </div> |
| 442 | 442 | ~; |
| 443 | 443 | |
| 444 | 444 | MODS::MetaAdmin::Wrapper->new->render( |
| 445 | 445 | title => 'Auto-Ack', page_key => 'auto_ack', body => $body, userinfo => $u, |
| 446 | 446 | ); |