@@ -79,6 +79,13 @@ ssh "$TARGET_HOST" "command -v rsync >/dev/null 2>&1" || {
|
||
| 79 | 79 |
} |
| 80 | 80 |
|
| 81 | 81 |
perl -c scripts/host_manager.pl >/dev/null |
| 82 |
+BUILD_REVISION="$(git rev-parse --short=12 HEAD)" |
|
| 83 |
+BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" |
|
| 84 |
+BUILD_DIRTY=0 |
|
| 85 |
+if [[ -n "$(git status --porcelain)" ]]; then |
|
| 86 |
+ BUILD_DIRTY=1 |
|
| 87 |
+fi |
|
| 88 |
+BUILD_DEPLOYED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" |
|
| 82 | 89 |
|
| 83 | 90 |
rsync_args=(-az --delete) |
| 84 | 91 |
if [[ "$DRY_RUN" -eq 1 ]]; then |
@@ -100,6 +107,10 @@ if [[ "$DRY_RUN" -eq 1 ]]; then |
||
| 100 | 107 |
exit 0 |
| 101 | 108 |
fi |
| 102 | 109 |
|
| 110 |
+printf 'revision=%s\nbranch=%s\ndirty=%s\ndeployed_at=%s\n' \ |
|
| 111 |
+ "$BUILD_REVISION" "$BUILD_BRANCH" "$BUILD_DIRTY" "$BUILD_DEPLOYED_AT" | |
|
| 112 |
+ ssh "$TARGET_HOST" "cat > '$TARGET_DIR/BUILD'" |
|
| 113 |
+ |
|
| 103 | 114 |
ssh "$TARGET_HOST" "cd '$TARGET_DIR' && perl -c scripts/host_manager.pl >/dev/null" |
| 104 | 115 |
|
| 105 | 116 |
if [[ "$RESTART" -eq 1 ]]; then |
@@ -1125,13 +1125,84 @@ sub iso_now {
|
||
| 1125 | 1125 |
return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
|
| 1126 | 1126 |
} |
| 1127 | 1127 |
|
| 1128 |
+sub build_info {
|
|
| 1129 |
+ my %info = ( |
|
| 1130 |
+ revision => '', |
|
| 1131 |
+ branch => '', |
|
| 1132 |
+ built_at => '', |
|
| 1133 |
+ deployed_at => '', |
|
| 1134 |
+ dirty => '', |
|
| 1135 |
+ ); |
|
| 1136 |
+ |
|
| 1137 |
+ if ($ENV{HOST_MANAGER_BUILD}) {
|
|
| 1138 |
+ $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
|
|
| 1139 |
+ return \%info; |
|
| 1140 |
+ } |
|
| 1141 |
+ |
|
| 1142 |
+ my $build_file = "$project_dir/BUILD"; |
|
| 1143 |
+ if (-f $build_file) {
|
|
| 1144 |
+ for my $line (split /\n/, read_file($build_file)) {
|
|
| 1145 |
+ next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/; |
|
| 1146 |
+ $info{$1} = clean_scalar($2);
|
|
| 1147 |
+ } |
|
| 1148 |
+ return \%info if $info{revision} || $info{built_at};
|
|
| 1149 |
+ } |
|
| 1150 |
+ |
|
| 1151 |
+ my $revision = git_value('rev-parse --short=12 HEAD');
|
|
| 1152 |
+ my $branch = git_value('rev-parse --abbrev-ref HEAD');
|
|
| 1153 |
+ $info{revision} = $revision if $revision;
|
|
| 1154 |
+ $info{branch} = $branch if $branch && $branch ne 'HEAD';
|
|
| 1155 |
+ return \%info; |
|
| 1156 |
+} |
|
| 1157 |
+ |
|
| 1158 |
+sub git_value {
|
|
| 1159 |
+ my ($args) = @_; |
|
| 1160 |
+ return '' unless -d "$project_dir/.git"; |
|
| 1161 |
+ open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return ''; |
|
| 1162 |
+ my $value = <$fh> || ''; |
|
| 1163 |
+ close $fh; |
|
| 1164 |
+ chomp $value; |
|
| 1165 |
+ return clean_scalar($value); |
|
| 1166 |
+} |
|
| 1167 |
+ |
|
| 1168 |
+sub build_label {
|
|
| 1169 |
+ my $info = build_info(); |
|
| 1170 |
+ my $revision = $info->{revision} || 'unknown';
|
|
| 1171 |
+ my $branch = $info->{branch} || '';
|
|
| 1172 |
+ $branch = '' if $branch eq 'HEAD'; |
|
| 1173 |
+ my $label = $branch ? "$branch $revision" : $revision; |
|
| 1174 |
+ $label .= '+dirty' if ($info->{dirty} || '') eq '1';
|
|
| 1175 |
+ return $label; |
|
| 1176 |
+} |
|
| 1177 |
+ |
|
| 1178 |
+sub build_title {
|
|
| 1179 |
+ my $info = build_info(); |
|
| 1180 |
+ my $label = build_label(); |
|
| 1181 |
+ my $stamp = $info->{deployed_at} || $info->{built_at} || '';
|
|
| 1182 |
+ return $stamp ? "$label deployed $stamp" : $label; |
|
| 1183 |
+} |
|
| 1184 |
+ |
|
| 1185 |
+sub html_escape {
|
|
| 1186 |
+ my ($value) = @_; |
|
| 1187 |
+ $value = '' unless defined $value; |
|
| 1188 |
+ $value =~ s/&/&/g; |
|
| 1189 |
+ $value =~ s/</</g; |
|
| 1190 |
+ $value =~ s/>/>/g; |
|
| 1191 |
+ $value =~ s/"/"/g; |
|
| 1192 |
+ $value =~ s/'/'/g; |
|
| 1193 |
+ return $value; |
|
| 1194 |
+} |
|
| 1195 |
+ |
|
| 1128 | 1196 |
sub app_html {
|
| 1129 |
- return <<'HTML'; |
|
| 1197 |
+ my $build = html_escape(build_label()); |
|
| 1198 |
+ my $build_title = html_escape(build_title()); |
|
| 1199 |
+ my $html = <<'HTML'; |
|
| 1130 | 1200 |
<!doctype html> |
| 1131 | 1201 |
<html lang="ro"> |
| 1132 | 1202 |
<head> |
| 1133 | 1203 |
<meta charset="utf-8"> |
| 1134 | 1204 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 1205 |
+ <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__"> |
|
| 1135 | 1206 |
<title>Madagascar Local Authority</title> |
| 1136 | 1207 |
<style> |
| 1137 | 1208 |
:root {
|
@@ -1165,10 +1236,10 @@ sub app_html {
|
||
| 1165 | 1236 |
--login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5)); |
| 1166 | 1237 |
background: #fff; |
| 1167 | 1238 |
border-radius: 16px; |
| 1168 |
- padding: 54px 64px 48px; |
|
| 1239 |
+ padding: 54px 64px 34px; |
|
| 1169 | 1240 |
width: 100%; |
| 1170 | 1241 |
max-width: 680px; |
| 1171 |
- min-height: 440px; |
|
| 1242 |
+ min-height: 360px; |
|
| 1172 | 1243 |
display: grid; |
| 1173 | 1244 |
align-content: start; |
| 1174 | 1245 |
justify-items: center; |
@@ -1286,8 +1357,36 @@ sub app_html {
|
||
| 1286 | 1357 |
.span2 { grid-column: 1 / -1; }
|
| 1287 | 1358 |
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
| 1288 | 1359 |
.muted { color: var(--muted); }
|
| 1360 |
+ .build-badge {
|
|
| 1361 |
+ position: fixed; |
|
| 1362 |
+ right: 10px; |
|
| 1363 |
+ bottom: 8px; |
|
| 1364 |
+ z-index: 5; |
|
| 1365 |
+ color: rgba(255,255,255,.46); |
|
| 1366 |
+ background: rgba(19,24,42,.28); |
|
| 1367 |
+ border: 1px solid rgba(255,255,255,.08); |
|
| 1368 |
+ border-radius: 4px; |
|
| 1369 |
+ padding: 2px 5px; |
|
| 1370 |
+ font-size: 10px; |
|
| 1371 |
+ line-height: 1.2; |
|
| 1372 |
+ pointer-events: none; |
|
| 1373 |
+ user-select: none; |
|
| 1374 |
+ } |
|
| 1375 |
+ body.is-app .build-badge {
|
|
| 1376 |
+ color: rgba(100,112,132,.58); |
|
| 1377 |
+ background: rgba(255,255,255,.72); |
|
| 1378 |
+ border-color: rgba(216,222,232,.72); |
|
| 1379 |
+ } |
|
| 1289 | 1380 |
.problems { padding: 10px 14px; display: grid; gap: 8px; }
|
| 1290 | 1381 |
.problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
|
| 1382 |
+ .work-order-card { display: grid; gap: 8px; min-width: 0; }
|
|
| 1383 |
+ .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
| 1384 |
+ .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
|
|
| 1385 |
+ .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
|
|
| 1386 |
+ .work-order-actions { gap: 4px; }
|
|
| 1387 |
+ .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
|
|
| 1388 |
+ .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
|
|
| 1389 |
+ .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
|
|
| 1291 | 1390 |
@media (max-width: 760px) {
|
| 1292 | 1391 |
.grid { grid-template-columns: 1fr; }
|
| 1293 | 1392 |
table { min-width: 760px; }
|
@@ -1295,7 +1394,7 @@ sub app_html {
|
||
| 1295 | 1394 |
} |
| 1296 | 1395 |
</style> |
| 1297 | 1396 |
</head> |
| 1298 |
-<body> |
|
| 1397 |
+<body class="is-login"> |
|
| 1299 | 1398 |
|
| 1300 | 1399 |
<!-- ── Login screen ── --> |
| 1301 | 1400 |
<div id="login-screen"> |
@@ -1422,6 +1521,8 @@ sub app_html {
|
||
| 1422 | 1521 |
</main> |
| 1423 | 1522 |
</div> |
| 1424 | 1523 |
|
| 1524 |
+ <div class="build-badge" title="Running build __HOST_MANAGER_BUILD_TITLE__">build __HOST_MANAGER_BUILD__</div> |
|
| 1525 |
+ |
|
| 1425 | 1526 |
<script> |
| 1426 | 1527 |
let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
|
| 1427 | 1528 |
|
@@ -1436,6 +1537,8 @@ sub app_html {
|
||
| 1436 | 1537 |
} |
| 1437 | 1538 |
|
| 1438 | 1539 |
function showLogin(errorText) {
|
| 1540 |
+ document.body.classList.remove('is-app');
|
|
| 1541 |
+ document.body.classList.add('is-login');
|
|
| 1439 | 1542 |
$('app').style.display = 'none';
|
| 1440 | 1543 |
$('login-screen').style.display = 'flex';
|
| 1441 | 1544 |
$('login-error').textContent = errorText || '';
|
@@ -1443,6 +1546,8 @@ sub app_html {
|
||
| 1443 | 1546 |
} |
| 1444 | 1547 |
|
| 1445 | 1548 |
function showApp() {
|
| 1549 |
+ document.body.classList.remove('is-login');
|
|
| 1550 |
+ document.body.classList.add('is-app');
|
|
| 1446 | 1551 |
$('login-screen').style.display = 'none';
|
| 1447 | 1552 |
$('app').style.display = 'block';
|
| 1448 | 1553 |
} |
@@ -1515,7 +1620,7 @@ sub app_html {
|
||
| 1515 | 1620 |
const checklistComplete = checklist.length === 0 || doneItems === checklist.length; |
| 1516 | 1621 |
const checklistHtml = checklist.map(item => {
|
| 1517 | 1622 |
const checked = (item.status || 'pending') === 'done' ? 'checked' : ''; |
| 1518 |
- return `<label style="display:flex;align-items:flex-start;gap:8px"> |
|
| 1623 |
+ return `<label class="work-order-checkitem"> |
|
| 1519 | 1624 |
<input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
|
| 1520 | 1625 |
<span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
|
| 1521 | 1626 |
</label>`; |
@@ -1528,15 +1633,15 @@ sub app_html {
|
||
| 1528 | 1633 |
const button = (wo.status || 'pending') === 'pending' |
| 1529 | 1634 |
? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
|
| 1530 | 1635 |
: ''; |
| 1531 |
- return `<div class="problem" style="display:grid;gap:8px"> |
|
| 1532 |
- <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"> |
|
| 1636 |
+ return `<div class="problem work-order-card"> |
|
| 1637 |
+ <div class="work-order-head"> |
|
| 1533 | 1638 |
<div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span> <span class="pill">${doneItems}/${checklist.length} done</span></div>
|
| 1534 | 1639 |
${button}
|
| 1535 | 1640 |
</div> |
| 1536 |
- <div>${escapeHtml(wo.title || '')}</div>
|
|
| 1641 |
+ <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
|
|
| 1537 | 1642 |
<div class="muted">${escapeHtml(wo.reason || '')}</div>
|
| 1538 |
- <div style="display:grid;gap:6px">${checklistHtml}</div>
|
|
| 1539 |
- <div style="display:grid;gap:4px">${actions}</div>
|
|
| 1643 |
+ <div class="work-order-checklist">${checklistHtml}</div>
|
|
| 1644 |
+ <div class="work-order-actions">${actions}</div>
|
|
| 1540 | 1645 |
${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
|
| 1541 | 1646 |
</div>`; |
| 1542 | 1647 |
}).join('');
|
@@ -1759,4 +1864,7 @@ sub app_html {
|
||
| 1759 | 1864 |
</body> |
| 1760 | 1865 |
</html> |
| 1761 | 1866 |
HTML |
| 1867 |
+ $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g; |
|
| 1868 |
+ $html =~ s/__HOST_MANAGER_BUILD__/$build/g; |
|
| 1869 |
+ return $html; |
|
| 1762 | 1870 |
} |