@@ -16,6 +16,8 @@ MVP-ul curent nu are dependențe CPAN externe. |
||
| 16 | 16 |
|
| 17 | 17 |
Git rămâne mecanismul de audit, istoric și rollback. Aplicația nu înlocuiește repo-ul și nu devine o bază de date separată. |
| 18 | 18 |
|
| 19 |
+Schimbările cu impact operațional care elimină nume sau schimbă semantica serviciilor locale se fac prin Work Order (WO), nu prin ștergere directă din UI. WO-ul rămâne în git și trebuie confirmat explicit înainte să modifice registrul. |
|
| 20 |
+ |
|
| 19 | 21 |
Endpoint-uri publice: |
| 20 | 22 |
|
| 21 | 23 |
- `/` — pagina de login/aplicație, fără date de host până la autentificare |
@@ -28,6 +30,7 @@ Healthcheck-ul `/healthz` este disponibil doar pe backend-ul local (`127.0.0.1:8 |
||
| 28 | 30 |
Endpoint-uri cu OTP: |
| 29 | 31 |
|
| 30 | 32 |
- `/api/hosts` |
| 33 |
+- `/api/work-orders` |
|
| 31 | 34 |
- `/api/ca/status` |
| 32 | 35 |
- `/api/ca/certificates` |
| 33 | 36 |
- `/download/hosts.yaml` |
@@ -36,6 +39,7 @@ Endpoint-uri cu OTP: |
||
| 36 | 39 |
- `/download/ca.crt` |
| 37 | 40 |
- `POST /api/hosts/upsert` |
| 38 | 41 |
- `POST /api/hosts/delete` |
| 42 |
+- `POST /api/work-orders/confirm` |
|
| 39 | 43 |
- `POST /api/render/local-hosts-tsv` |
| 40 | 44 |
|
| 41 | 45 |
## Pornire locală |
@@ -90,6 +94,32 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de |
||
| 90 | 94 |
./scripts/sync_local_hosts.sh --apply --verify |
| 91 | 95 |
``` |
| 92 | 96 |
|
| 97 |
+## Work Orders |
|
| 98 |
+ |
|
| 99 |
+`config/work-orders.yaml` păstrează operațiuni care trebuie confirmate înainte să atingă registrul. |
|
| 100 |
+ |
|
| 101 |
+În MVP, acțiunea suportată este: |
|
| 102 |
+ |
|
| 103 |
+```text |
|
| 104 |
+remove_name(host_id, name) |
|
| 105 |
+``` |
|
| 106 |
+ |
|
| 107 |
+Confirmarea unui WO: |
|
| 108 |
+ |
|
| 109 |
+- cere tastarea exactă a ID-ului WO în interfață |
|
| 110 |
+- elimină numele declarate din `config/hosts.yaml` |
|
| 111 |
+- marchează WO-ul ca `confirmed` |
|
| 112 |
+- regenerează `config/local-hosts.tsv` |
|
| 113 |
+- nu rulează automat sync-ul către resolvere |
|
| 114 |
+ |
|
| 115 |
+După confirmare, operatorul verifică schimbarea în git și rulează explicit: |
|
| 116 |
+ |
|
| 117 |
+```bash |
|
| 118 |
+./scripts/sync_local_hosts.sh --apply --verify |
|
| 119 |
+``` |
|
| 120 |
+ |
|
| 121 |
+Primul WO curent este pentru eliminarea numelor locale `pmx.*`/`pbs.*` create istoric pentru vhosturi nginx cu certificate Let's Encrypt. Odată cu CA-ul local, aceste nume nu mai trebuie să existe ca vhosturi separate pentru interfețele Proxmox/PBS. |
|
| 122 |
+ |
|
| 93 | 123 |
## Convenții de nume |
| 94 | 124 |
|
| 95 | 125 |
`madagascar.xdev.ro` este domeniul implicit al rețelei interne. În `config/hosts.yaml` se declară doar numele canonice/FQDN-urile necesare. |
@@ -122,6 +152,7 @@ Modelul recomandat: |
||
| 122 | 152 |
git repo |
| 123 | 153 |
config/hosts.yaml sursă versionată |
| 124 | 154 |
config/local-hosts.tsv manifest generat/versionat pentru DNS local |
| 155 |
+ config/work-orders.yaml operațiuni confirmabile/versionate |
|
| 125 | 156 |
|
| 126 | 157 |
jumper |
| 127 | 158 |
host-manager editează working tree cu OTP |
@@ -6,6 +6,7 @@ This project lives on jumper and is the local source for: |
||
| 6 | 6 |
|
| 7 | 7 |
- `config/hosts.yaml` - git-versioned host registry |
| 8 | 8 |
- `config/local-hosts.tsv` - DNS manifest exported for local resolvers |
| 9 |
+- `config/work-orders.yaml` - confirmable operational changes |
|
| 9 | 10 |
- `scripts/host_manager.pl` - Perl-only web app |
| 10 | 11 |
- `scripts/sync_local_hosts.sh` - local DNS sync to jumper and as01 |
| 11 | 12 |
- `scripts/ca_manager.sh` - local OpenSSL CA helper for host certificates |
@@ -24,6 +25,8 @@ The web UI is OTP-protected for all registry data, downloads, exports, and write |
||
| 24 | 25 |
|
| 25 | 26 |
The default internal domain is `madagascar.xdev.ro`. Short aliases are derived automatically from FQDNs, so `autonas01.madagascar.xdev.ro` also publishes `autonas01` without declaring it separately. |
| 26 | 27 |
|
| 28 |
+Name removals with operational impact go through a Work Order. Confirming a WO updates `hosts.yaml`, marks the WO as confirmed, and regenerates `local-hosts.tsv`; resolver sync remains an explicit operator step. |
|
| 29 |
+ |
|
| 27 | 30 |
The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with: |
| 28 | 31 |
|
| 29 | 32 |
```bash |
@@ -0,0 +1,23 @@ |
||
| 1 |
+version: 1 |
|
| 2 |
+work_orders: |
|
| 3 |
+ - id: "WO-20260606-001" |
|
| 4 |
+ status: "pending" |
|
| 5 |
+ title: "Remove legacy public-cert local vhost names" |
|
| 6 |
+ reason: "These names were introduced for Let's Encrypt-backed nginx vhosts for local Proxmox/PBS web interfaces. The local CA replaces public certificates for local-only resources." |
|
| 7 |
+ created_at: "2026-06-06T00:00:00Z" |
|
| 8 |
+ actions: |
|
| 9 |
+ - type: "remove_name" |
|
| 10 |
+ host_id: "baobab" |
|
| 11 |
+ name: "pmx.baobab.madagascar.xdev.ro" |
|
| 12 |
+ - type: "remove_name" |
|
| 13 |
+ host_id: "ebony" |
|
| 14 |
+ name: "pmx.ebony.madagascar.xdev.ro" |
|
| 15 |
+ - type: "remove_name" |
|
| 16 |
+ host_id: "tapia" |
|
| 17 |
+ name: "pmx.tapia.madagascar.xdev.ro" |
|
| 18 |
+ - type: "remove_name" |
|
| 19 |
+ host_id: "anjothibe" |
|
| 20 |
+ name: "pbs.anjothibe.madagascar.xdev.ro" |
|
| 21 |
+ - type: "remove_name" |
|
| 22 |
+ host_id: "andrafiabe" |
|
| 23 |
+ name: "pbs.andrafiabe.madagascar.xdev.ro" |
|
@@ -22,6 +22,7 @@ my %opt = ( |
||
| 22 | 22 |
port => $ENV{HOST_MANAGER_PORT} || 8088,
|
| 23 | 23 |
data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
|
| 24 | 24 |
local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
|
| 25 |
+ work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
|
|
| 25 | 26 |
); |
| 26 | 27 |
|
| 27 | 28 |
while (@ARGV) {
|
@@ -34,6 +35,8 @@ while (@ARGV) {
|
||
| 34 | 35 |
$opt{data} = shift @ARGV;
|
| 35 | 36 |
} elsif ($arg eq '--local-hosts-tsv') {
|
| 36 | 37 |
$opt{local_hosts_tsv} = shift @ARGV;
|
| 38 |
+ } elsif ($arg eq '--work-orders') {
|
|
| 39 |
+ $opt{work_orders} = shift @ARGV;
|
|
| 37 | 40 |
} elsif ($arg eq '--help' || $arg eq '-h') {
|
| 38 | 41 |
usage(); |
| 39 | 42 |
exit 0; |
@@ -77,8 +80,9 @@ Environment: |
||
| 77 | 80 |
HOST_MANAGER_SESSION_SECRET Optional session signing secret. |
| 78 | 81 |
HOST_MANAGER_DATA Defaults to config/hosts.yaml. |
| 79 | 82 |
HOST_MANAGER_LOCAL_HOSTS_TSV Defaults to config/local-hosts.tsv. |
| 83 |
+ HOST_MANAGER_WORK_ORDERS Defaults to config/work-orders.yaml. |
|
| 80 | 84 |
|
| 81 |
-Read-only endpoints do not require authentication. |
|
| 85 |
+The nginx vhost keeps registry, CA, work order and download endpoints behind OTP. |
|
| 82 | 86 |
EOF |
| 83 | 87 |
} |
| 84 | 88 |
|
@@ -136,6 +140,9 @@ sub handle_client {
|
||
| 136 | 140 |
my $registry = load_registry(); |
| 137 | 141 |
return send_json($client, 200, registry_payload($registry)); |
| 138 | 142 |
} |
| 143 |
+ if ($method eq 'GET' && $path eq '/api/work-orders') {
|
|
| 144 |
+ return send_json($client, 200, work_orders_payload(load_work_orders())); |
|
| 145 |
+ } |
|
| 139 | 146 |
if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
|
| 140 | 147 |
return send_file($client, $opt{data}, 'application/x-yaml; charset=utf-8', 'hosts.yaml');
|
| 141 | 148 |
} |
@@ -166,6 +173,10 @@ sub handle_client {
|
||
| 166 | 173 |
my $payload = request_payload(\%headers, $body); |
| 167 | 174 |
return delete_host($client, $payload->{id} || '');
|
| 168 | 175 |
} |
| 176 |
+ if ($path eq '/api/work-orders/confirm') {
|
|
| 177 |
+ my $payload = request_payload(\%headers, $body); |
|
| 178 |
+ return confirm_work_order($client, $payload); |
|
| 179 |
+ } |
|
| 169 | 180 |
if ($path eq '/api/render/local-hosts-tsv') {
|
| 170 | 181 |
my $registry = load_registry(); |
| 171 | 182 |
my $content = render_local_hosts_tsv($registry); |
@@ -189,6 +200,98 @@ sub save_registry {
|
||
| 189 | 200 |
write_file($opt{data}, render_hosts_yaml($registry));
|
| 190 | 201 |
} |
| 191 | 202 |
|
| 203 |
+sub load_work_orders {
|
|
| 204 |
+ return { version => 1, work_orders => [] } unless -f $opt{work_orders};
|
|
| 205 |
+ return parse_work_orders_yaml(read_file($opt{work_orders}));
|
|
| 206 |
+} |
|
| 207 |
+ |
|
| 208 |
+sub save_work_orders {
|
|
| 209 |
+ my ($orders) = @_; |
|
| 210 |
+ backup_file($opt{work_orders});
|
|
| 211 |
+ write_file($opt{work_orders}, render_work_orders_yaml($orders));
|
|
| 212 |
+} |
|
| 213 |
+ |
|
| 214 |
+sub work_orders_payload {
|
|
| 215 |
+ my ($orders) = @_; |
|
| 216 |
+ my $pending = 0; |
|
| 217 |
+ for my $wo (@{ $orders->{work_orders} || [] }) {
|
|
| 218 |
+ $pending++ if ($wo->{status} || 'pending') eq 'pending';
|
|
| 219 |
+ } |
|
| 220 |
+ return {
|
|
| 221 |
+ version => $orders->{version},
|
|
| 222 |
+ work_orders => $orders->{work_orders} || [],
|
|
| 223 |
+ counts => {
|
|
| 224 |
+ work_orders => scalar @{ $orders->{work_orders} || [] },
|
|
| 225 |
+ pending => $pending, |
|
| 226 |
+ }, |
|
| 227 |
+ }; |
|
| 228 |
+} |
|
| 229 |
+ |
|
| 230 |
+sub confirm_work_order {
|
|
| 231 |
+ my ($client, $payload) = @_; |
|
| 232 |
+ my $id = clean_scalar($payload->{id} || '');
|
|
| 233 |
+ return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
|
|
| 234 |
+ return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
|
|
| 235 |
+ |
|
| 236 |
+ my $orders = load_work_orders(); |
|
| 237 |
+ my $work_order; |
|
| 238 |
+ for my $wo (@{ $orders->{work_orders} || [] }) {
|
|
| 239 |
+ if (($wo->{id} || '') eq $id) {
|
|
| 240 |
+ $work_order = $wo; |
|
| 241 |
+ last; |
|
| 242 |
+ } |
|
| 243 |
+ } |
|
| 244 |
+ return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
|
|
| 245 |
+ return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
|
|
| 246 |
+ |
|
| 247 |
+ my $registry = load_registry(); |
|
| 248 |
+ my $results = apply_work_order($registry, $work_order); |
|
| 249 |
+ $work_order->{status} = 'confirmed';
|
|
| 250 |
+ $work_order->{confirmed_at} = iso_now();
|
|
| 251 |
+ $work_order->{result} = scalar(@$results) . ' action(s) applied';
|
|
| 252 |
+ |
|
| 253 |
+ save_registry($registry); |
|
| 254 |
+ save_work_orders($orders); |
|
| 255 |
+ backup_file($opt{local_hosts_tsv});
|
|
| 256 |
+ write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
|
|
| 257 |
+ |
|
| 258 |
+ return send_json($client, 200, {
|
|
| 259 |
+ ok => json_bool(1), |
|
| 260 |
+ work_order => $work_order, |
|
| 261 |
+ results => $results, |
|
| 262 |
+ local_hosts_tsv => $opt{local_hosts_tsv},
|
|
| 263 |
+ }); |
|
| 264 |
+} |
|
| 265 |
+ |
|
| 266 |
+sub apply_work_order {
|
|
| 267 |
+ my ($registry, $work_order) = @_; |
|
| 268 |
+ my @results; |
|
| 269 |
+ for my $action (@{ $work_order->{actions} || [] }) {
|
|
| 270 |
+ my $type = $action->{type} || '';
|
|
| 271 |
+ if ($type eq 'remove_name') {
|
|
| 272 |
+ my $host_id = $action->{host_id} || '';
|
|
| 273 |
+ my $name = $action->{name} || '';
|
|
| 274 |
+ my $removed = 0; |
|
| 275 |
+ for my $host (@{ $registry->{hosts} || [] }) {
|
|
| 276 |
+ next unless ($host->{id} || '') eq $host_id;
|
|
| 277 |
+ my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
|
|
| 278 |
+ $removed = @kept != @{ $host->{names} || [] };
|
|
| 279 |
+ $host->{names} = \@kept;
|
|
| 280 |
+ last; |
|
| 281 |
+ } |
|
| 282 |
+ push @results, {
|
|
| 283 |
+ type => $type, |
|
| 284 |
+ host_id => $host_id, |
|
| 285 |
+ name => $name, |
|
| 286 |
+ removed => json_bool($removed), |
|
| 287 |
+ }; |
|
| 288 |
+ } else {
|
|
| 289 |
+ die "Unsupported work order action: $type\n"; |
|
| 290 |
+ } |
|
| 291 |
+ } |
|
| 292 |
+ return \@results; |
|
| 293 |
+} |
|
| 294 |
+ |
|
| 192 | 295 |
sub registry_payload {
|
| 193 | 296 |
my ($registry) = @_; |
| 194 | 297 |
my $problems = analyze_hosts($registry->{hosts});
|
@@ -477,6 +580,67 @@ sub render_hosts_yaml {
|
||
| 477 | 580 |
return $out; |
| 478 | 581 |
} |
| 479 | 582 |
|
| 583 |
+sub parse_work_orders_yaml {
|
|
| 584 |
+ my ($text) = @_; |
|
| 585 |
+ my %orders = ( |
|
| 586 |
+ version => 1, |
|
| 587 |
+ work_orders => [], |
|
| 588 |
+ ); |
|
| 589 |
+ my ($section, $current, $in_actions, $current_action); |
|
| 590 |
+ for my $line (split /\n/, $text) {
|
|
| 591 |
+ next if $line =~ /^\s*$/ || $line =~ /^\s*#/; |
|
| 592 |
+ if ($line =~ /^version:\s*(\d+)/) {
|
|
| 593 |
+ $orders{version} = int($1);
|
|
| 594 |
+ } elsif ($line =~ /^work_orders:\s*$/) {
|
|
| 595 |
+ $section = 'work_orders'; |
|
| 596 |
+ } elsif (($section || '') eq 'work_orders' && $line =~ /^ - id:\s*(.+)$/) {
|
|
| 597 |
+ $current = {
|
|
| 598 |
+ id => yaml_unquote($1), |
|
| 599 |
+ status => 'pending', |
|
| 600 |
+ actions => [], |
|
| 601 |
+ }; |
|
| 602 |
+ push @{ $orders{work_orders} }, $current;
|
|
| 603 |
+ $in_actions = 0; |
|
| 604 |
+ $current_action = undef; |
|
| 605 |
+ } elsif ($current && $line =~ /^ actions:\s*$/) {
|
|
| 606 |
+ $in_actions = 1; |
|
| 607 |
+ $current->{actions} ||= [];
|
|
| 608 |
+ } elsif ($current && $in_actions && $line =~ /^ - type:\s*(.+)$/) {
|
|
| 609 |
+ $current_action = { type => yaml_unquote($1) };
|
|
| 610 |
+ push @{ $current->{actions} }, $current_action;
|
|
| 611 |
+ } elsif ($current_action && $line =~ /^ ([A-Za-z0-9_]+):\s*(.*)$/) {
|
|
| 612 |
+ $current_action->{$1} = yaml_unquote($2);
|
|
| 613 |
+ } elsif ($current && $line =~ /^ ([A-Za-z0-9_]+):\s*(.*)$/) {
|
|
| 614 |
+ $current->{$1} = yaml_unquote($2);
|
|
| 615 |
+ $in_actions = 0; |
|
| 616 |
+ $current_action = undef; |
|
| 617 |
+ } |
|
| 618 |
+ } |
|
| 619 |
+ return \%orders; |
|
| 620 |
+} |
|
| 621 |
+ |
|
| 622 |
+sub render_work_orders_yaml {
|
|
| 623 |
+ my ($orders) = @_; |
|
| 624 |
+ my $out = "version: " . int($orders->{version} || 1) . "\n";
|
|
| 625 |
+ $out .= "work_orders:\n"; |
|
| 626 |
+ for my $wo (@{ $orders->{work_orders} || [] }) {
|
|
| 627 |
+ $out .= " - id: " . yq($wo->{id}) . "\n";
|
|
| 628 |
+ for my $key (qw(status title reason created_at confirmed_at result)) {
|
|
| 629 |
+ next unless exists $wo->{$key} && length($wo->{$key} || '');
|
|
| 630 |
+ $out .= " $key: " . yq($wo->{$key}) . "\n";
|
|
| 631 |
+ } |
|
| 632 |
+ $out .= " actions:\n"; |
|
| 633 |
+ for my $action (@{ $wo->{actions} || [] }) {
|
|
| 634 |
+ $out .= " - type: " . yq($action->{type}) . "\n";
|
|
| 635 |
+ for my $key (qw(host_id name)) {
|
|
| 636 |
+ next unless exists $action->{$key} && length($action->{$key} || '');
|
|
| 637 |
+ $out .= " $key: " . yq($action->{$key}) . "\n";
|
|
| 638 |
+ } |
|
| 639 |
+ } |
|
| 640 |
+ } |
|
| 641 |
+ return $out; |
|
| 642 |
+} |
|
| 643 |
+ |
|
| 480 | 644 |
sub request_payload {
|
| 481 | 645 |
my ($headers, $body) = @_; |
| 482 | 646 |
my $type = $headers->{'content-type'} || '';
|
@@ -827,7 +991,7 @@ sub send_file {
|
||
| 827 | 991 |
|
| 828 | 992 |
sub send_response {
|
| 829 | 993 |
my ($client, $status, $body, $type, $extra_headers) = @_; |
| 830 |
- my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 500 => 'Internal Server Error', 503 => 'Service Unavailable'); |
|
| 994 |
+ my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 409 => 'Conflict', 500 => 'Internal Server Error', 503 => 'Service Unavailable'); |
|
| 831 | 995 |
$body = '' unless defined $body; |
| 832 | 996 |
print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
|
| 833 | 997 |
print $client "Content-Type: $type\r\n"; |
@@ -1070,6 +1234,14 @@ sub app_html {
|
||
| 1070 | 1234 |
<div class="problems" id="ca-status"></div> |
| 1071 | 1235 |
</section> |
| 1072 | 1236 |
|
| 1237 |
+ <section class="panel"> |
|
| 1238 |
+ <div class="panel-head"> |
|
| 1239 |
+ <h2>Work Orders</h2> |
|
| 1240 |
+ <div class="stats" id="wo-stats"></div> |
|
| 1241 |
+ </div> |
|
| 1242 |
+ <div class="problems" id="work-orders"></div> |
|
| 1243 |
+ </section> |
|
| 1244 |
+ |
|
| 1073 | 1245 |
<section class="panel"> |
| 1074 | 1246 |
<div class="panel-head"> |
| 1075 | 1247 |
<h2>Hosts</h2> |
@@ -1117,7 +1289,7 @@ sub app_html {
|
||
| 1117 | 1289 |
</div> |
| 1118 | 1290 |
|
| 1119 | 1291 |
<script> |
| 1120 |
- let state = { hosts: [], problems: [], authenticated: false };
|
|
| 1292 |
+ let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
|
|
| 1121 | 1293 |
|
| 1122 | 1294 |
const $ = (id) => document.getElementById(id); |
| 1123 | 1295 |
const msg = (text) => { $('message').textContent = text || ''; };
|
@@ -1153,6 +1325,7 @@ sub app_html {
|
||
| 1153 | 1325 |
state.problems = data.problems || []; |
| 1154 | 1326 |
render(data); |
| 1155 | 1327 |
await renderCa(); |
| 1328 |
+ await renderWorkOrders(); |
|
| 1156 | 1329 |
} |
| 1157 | 1330 |
|
| 1158 | 1331 |
function render(data) {
|
@@ -1190,6 +1363,60 @@ sub app_html {
|
||
| 1190 | 1363 |
} |
| 1191 | 1364 |
} |
| 1192 | 1365 |
|
| 1366 |
+ async function renderWorkOrders() {
|
|
| 1367 |
+ try {
|
|
| 1368 |
+ const data = await api('/api/work-orders');
|
|
| 1369 |
+ state.workOrders = data.work_orders || []; |
|
| 1370 |
+ $('wo-stats').innerHTML = [
|
|
| 1371 |
+ ['pending', data.counts.pending], |
|
| 1372 |
+ ['total', data.counts.work_orders], |
|
| 1373 |
+ ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
|
|
| 1374 |
+ |
|
| 1375 |
+ if (!state.workOrders.length) {
|
|
| 1376 |
+ $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
|
|
| 1377 |
+ return; |
|
| 1378 |
+ } |
|
| 1379 |
+ |
|
| 1380 |
+ $('work-orders').innerHTML = state.workOrders.map(wo => {
|
|
| 1381 |
+ const actions = (wo.actions || []).map(a => {
|
|
| 1382 |
+ const target = [a.host_id, a.name].filter(Boolean).join(' ');
|
|
| 1383 |
+ return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
|
|
| 1384 |
+ }).join('');
|
|
| 1385 |
+ const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok'; |
|
| 1386 |
+ const button = (wo.status || 'pending') === 'pending' |
|
| 1387 |
+ ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}">Confirm</button>`
|
|
| 1388 |
+ : ''; |
|
| 1389 |
+ return `<div class="problem" style="display:grid;gap:8px"> |
|
| 1390 |
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"> |
|
| 1391 |
+ <div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span></div>
|
|
| 1392 |
+ ${button}
|
|
| 1393 |
+ </div> |
|
| 1394 |
+ <div>${escapeHtml(wo.title || '')}</div>
|
|
| 1395 |
+ <div class="muted">${escapeHtml(wo.reason || '')}</div>
|
|
| 1396 |
+ <div style="display:grid;gap:4px">${actions}</div>
|
|
| 1397 |
+ ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
|
|
| 1398 |
+ </div>`; |
|
| 1399 |
+ }).join('');
|
|
| 1400 |
+ document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
|
|
| 1401 |
+ } catch (e) {
|
|
| 1402 |
+ $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
|
|
| 1403 |
+ } |
|
| 1404 |
+ } |
|
| 1405 |
+ |
|
| 1406 |
+ async function confirmWorkOrder(id) {
|
|
| 1407 |
+ const typed = prompt(`Type ${id} to confirm this work order`);
|
|
| 1408 |
+ if (typed !== id) return; |
|
| 1409 |
+ try {
|
|
| 1410 |
+ await api('/api/work-orders/confirm', {
|
|
| 1411 |
+ method: 'POST', |
|
| 1412 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 1413 |
+ body: JSON.stringify({ id, confirm: typed })
|
|
| 1414 |
+ }); |
|
| 1415 |
+ msg('work order confirmed; local-hosts.tsv written');
|
|
| 1416 |
+ await refresh(); |
|
| 1417 |
+ } catch (e) { msg(e.message); }
|
|
| 1418 |
+ } |
|
| 1419 |
+ |
|
| 1193 | 1420 |
function renderHosts() {
|
| 1194 | 1421 |
const filter = $('filter').value.toLowerCase();
|
| 1195 | 1422 |
$('hosts').innerHTML = state.hosts
|