Skip to main content

max / makenotwork

wam: authenticate the ticket API with a shared bearer token Add optional WAM_TOKEN bearer auth to the wam HTTP API and present it from every automated caller (server WamClient, backup/WAL offsite alert scripts). Drop the interactive TUI surface, leaving the CLI and HTTP API.
Author: Max Johnson <me@maxj.phd> · 2026-07-23 17:07 UTC
Commit: 8fc4b75475ae13f264fd4b556c3197df02582c55
Parent: c9b4682
13 files changed, +223 insertions, -982 deletions
@@ -26,12 +26,16 @@ OFFSITE_USER="max"
26 26 OFFSITE_DIR="/opt/backups/mnw/${DB_NAME}"
27 27 OFFSITE_RETENTION_DAYS=30
28 28 WAM_URL="${WAM_URL:-http://127.0.0.1:7890}"
29 + WAM_TOKEN="${WAM_TOKEN:-}"
29 30
30 31 wam_alert() {
31 32 local title="$1"
32 33 local body="${2:-}"
34 + local auth=()
35 + [ -n "$WAM_TOKEN" ] && auth=(-H "Authorization: Bearer $WAM_TOKEN")
33 36 curl -sf -X POST "$WAM_URL/tickets" \
34 37 -H "Content-Type: application/json" \
38 + "${auth[@]}" \
35 39 -d "{\"title\": \"$title\", \"body\": \"$body\", \"priority\": \"high\", \"source\": \"backup-offsite\"}" \
36 40 >/dev/null 2>&1 || true
37 41 }
@@ -19,12 +19,16 @@ OFFSITE_DIR="/opt/backups/mnw/wal"
19 19 WAL_DIR="/opt/makenotwork/wal-archive"
20 20 OFFSITE_RETENTION_DAYS=7
21 21 WAM_URL="${WAM_URL:-http://127.0.0.1:7890}"
22 + WAM_TOKEN="${WAM_TOKEN:-}"
22 23
23 24 wam_alert() {
24 25 local title="$1"
25 26 local body="${2:-}"
27 + local auth=()
28 + [ -n "$WAM_TOKEN" ] && auth=(-H "Authorization: Bearer $WAM_TOKEN")
26 29 curl -sf -X POST "$WAM_URL/tickets" \
27 30 -H "Content-Type: application/json" \
31 + "${auth[@]}" \
28 32 -d "{\"title\": \"$title\", \"body\": \"$body\", \"priority\": \"high\", \"source\": \"wal-offsite\"}" \
29 33 >/dev/null 2>&1 || true
30 34 }
@@ -982,6 +982,7 @@ mod tests {
982 982 "INTERNAL_SHARED_SECRET",
983 983 "CLI_SERVICE_TOKEN",
984 984 "WAM_URL",
985 + "WAM_TOKEN",
985 986 "ACCESS_GATE",
986 987 "SSO_PROVIDER_URL",
987 988 "SSO_CLIENT_ID",
@@ -1,8 +1,10 @@
1 1 //! HTTP client for the WAM (Whack-a-Mole) ticket manager.
2 2 //!
3 - //! WAM runs on the tailnet — no auth required. The MNW server creates tickets
4 - //! for operational events that need human attention (stale refunds, dead
5 - //! webhooks, etc.).
3 + //! WAM runs on the tailnet. The MNW server creates tickets for operational
4 + //! events that need human attention (stale refunds, dead webhooks, etc.). When
5 + //! WAM is configured with a shared token, set `WAM_TOKEN` here to the same value
6 + //! and it is presented as a bearer token on every request; without it WAM must
7 + //! be running unauthenticated (tailnet-ACL only) or requests are rejected.
6 8
7 9 use serde::Serialize;
8 10
@@ -11,6 +13,7 @@ use serde::Serialize;
11 13 pub struct WamClient {
12 14 http: reqwest::Client,
13 15 base_url: String,
16 + token: Option<String>,
14 17 }
15 18
16 19 #[derive(Serialize)]
@@ -25,13 +28,20 @@ struct CreateTicketRequest<'a> {
25 28 }
26 29
27 30 impl WamClient {
28 - pub fn new(base_url: String) -> Self {
31 + /// Build a client for `base_url`. `token`, when `Some`, is sent as a bearer
32 + /// token on every request (matching WAM's shared-secret auth).
33 + pub fn new(base_url: String, token: Option<String>) -> Self {
29 34 let http = reqwest::Client::builder()
30 35 .timeout(std::time::Duration::from_secs(5))
31 36 .connect_timeout(std::time::Duration::from_secs(3))
32 37 .build()
33 38 .expect("WAM HTTP client");
34 - Self { http, base_url }
39 + let token = token.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty());
40 + Self {
41 + http,
42 + base_url,
43 + token,
44 + }
35 45 }
36 46
37 47 /// Return the ticket endpoint URL.
@@ -59,7 +69,12 @@ impl WamClient {
59 69 source_ref,
60 70 };
61 71
62 - match self.http.post(&url).json(&req).send().await {
72 + let mut request = self.http.post(&url).json(&req);
73 + if let Some(token) = &self.token {
74 + request = request.bearer_auth(token);
75 + }
76 +
77 + match request.send().await {
63 78 Ok(resp) if resp.status().is_success() => {
64 79 tracing::info!(title, source, "WAM ticket created");
65 80 }
@@ -82,13 +97,13 @@ mod tests {
82 97
83 98 #[test]
84 99 fn ticket_url_construction() {
85 - let client = WamClient::new("http://100.120.174.96:7890".to_string());
100 + let client = WamClient::new("http://100.120.174.96:7890".to_string(), None);
86 101 assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets");
87 102 }
88 103
89 104 #[test]
90 105 fn ticket_url_strips_trailing_slash() {
91 - let client = WamClient::new("http://localhost:7890/".to_string());
106 + let client = WamClient::new("http://localhost:7890/".to_string(), None);
92 107 assert_eq!(client.ticket_url(), "http://localhost:7890/tickets");
93 108 }
94 109
@@ -127,7 +142,7 @@ mod tests {
127 142 #[tokio::test]
128 143 async fn create_ticket_unreachable_does_not_panic() {
129 144 // WAM is fire-and-forget — unreachable server should not panic
130 - let client = WamClient::new("http://127.0.0.1:1".to_string());
145 + let client = WamClient::new("http://127.0.0.1:1".to_string(), None);
131 146 client
132 147 .create_ticket("test", None, "low", "test", None)
133 148 .await;
M wam/Cargo.lock +2 -379
@@ -18,21 +18,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18 18 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19 19
20 20 [[package]]
21 - name = "aho-corasick"
22 - version = "1.1.4"
23 - source = "registry+https://github.com/rust-lang/crates.io-index"
24 - checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
25 - dependencies = [
26 - "memchr",
27 - ]
28 -
29 - [[package]]
30 - name = "allocator-api2"
31 - version = "0.2.21"
32 - source = "registry+https://github.com/rust-lang/crates.io-index"
33 - checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
34 -
35 - [[package]]
36 21 name = "android_system_properties"
37 22 version = "0.1.5"
38 23 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -98,24 +83,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
98 83 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
99 84
100 85 [[package]]
101 - name = "approx"
102 - version = "0.5.1"
103 - source = "registry+https://github.com/rust-lang/crates.io-index"
104 - checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
105 - dependencies = [
106 - "num-traits",
107 - ]
108 -
109 - [[package]]
110 - name = "atomic"
111 - version = "0.6.1"
112 - source = "registry+https://github.com/rust-lang/crates.io-index"
113 - checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
114 - dependencies = [
115 - "bytemuck",
116 - ]
117 -
118 - [[package]]
119 86 name = "atomic-waker"
120 87 version = "1.1.2"
121 88 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -224,75 +191,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
224 191 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
225 192
226 193 [[package]]
227 - name = "bit-set"
228 - version = "0.5.3"
229 - source = "registry+https://github.com/rust-lang/crates.io-index"
230 - checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
231 - dependencies = [
232 - "bit-vec",
233 - ]
234 -
235 - [[package]]
236 - name = "bit-vec"
237 - version = "0.6.3"
238 - source = "registry+https://github.com/rust-lang/crates.io-index"
239 - checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
240 -
241 - [[package]]
242 - name = "bitflags"
243 - version = "1.3.2"
244 - source = "registry+https://github.com/rust-lang/crates.io-index"
245 - checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
246 -
247 - [[package]]
248 194 name = "bitflags"
249 195 version = "2.13.1"
250 196 source = "registry+https://github.com/rust-lang/crates.io-index"
251 197 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
252 198
253 199 [[package]]
254 - name = "block-buffer"
255 - version = "0.10.4"
256 - source = "registry+https://github.com/rust-lang/crates.io-index"
257 - checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
258 - dependencies = [
259 - "generic-array",
260 - ]
261 -
262 - [[package]]
263 200 name = "bumpalo"
264 201 version = "3.20.2"
265 202 source = "registry+https://github.com/rust-lang/crates.io-index"
266 203 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
267 204
268 205 [[package]]
269 - name = "by_address"
270 - version = "1.2.1"
271 - source = "registry+https://github.com/rust-lang/crates.io-index"
272 - checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
273 -
274 - [[package]]
275 - name = "bytemuck"
276 - version = "1.25.2"
277 - source = "registry+https://github.com/rust-lang/crates.io-index"
278 - checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
279 -
280 - [[package]]
281 206 name = "bytes"
282 207 version = "1.11.1"
283 208 source = "registry+https://github.com/rust-lang/crates.io-index"
284 209 checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
285 210
286 211 [[package]]
287 - name = "castaway"
288 - version = "0.2.4"
289 - source = "registry+https://github.com/rust-lang/crates.io-index"
290 - checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
291 - dependencies = [
292 - "rustversion",
293 - ]
294 -
295 - [[package]]
296 212 name = "cc"
297 213 version = "1.2.61"
298 214 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -361,7 +277,7 @@ dependencies = [
361 277 "heck",
362 278 "proc-macro2",
363 279 "quote",
364 - "syn 2.0.117",
280 + "syn",
365 281 ]
366 282
367 283 [[package]]
@@ -423,29 +339,6 @@ dependencies = [
423 339 ]
424 340
425 341 [[package]]
426 - name = "compact_str"
427 - version = "0.9.1"
428 - source = "registry+https://github.com/rust-lang/crates.io-index"
429 - checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
430 - dependencies = [
431 - "castaway",
432 - "cfg-if",
433 - "itoa",
434 - "rustversion",
435 - "ryu",
436 - "static_assertions",
437 - ]
438 -
439 - [[package]]
440 - name = "convert_case"
441 - version = "0.10.0"
442 - source = "registry+https://github.com/rust-lang/crates.io-index"
443 - checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
444 - dependencies = [
445 - "unicode-segmentation",
446 - ]
447 -
448 - [[package]]
449 342 name = "core-foundation"
450 343 version = "0.10.1"
451 344 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -462,146 +355,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
462 355 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
463 356
464 357 [[package]]
465 - name = "cpufeatures"
466 - version = "0.2.17"
467 - source = "registry+https://github.com/rust-lang/crates.io-index"
468 - checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
469 - dependencies = [
470 - "libc",
471 - ]
472 -
473 - [[package]]
474 - name = "critical-section"
475 - version = "1.2.0"
476 - source = "registry+https://github.com/rust-lang/crates.io-index"
477 - checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
478 -
479 - [[package]]
480 - name = "crossterm"
481 - version = "0.29.0"
482 - source = "registry+https://github.com/rust-lang/crates.io-index"
483 - checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
484 - dependencies = [
485 - "bitflags 2.13.1",
486 - "crossterm_winapi",
487 - "derive_more",
488 - "document-features",
489 - "mio",
490 - "parking_lot",
491 - "rustix",
492 - "signal-hook",
493 - "signal-hook-mio",
494 - "winapi",
495 - ]
496 -
497 - [[package]]
498 - name = "crossterm_winapi"
499 - version = "0.9.1"
500 - source = "registry+https://github.com/rust-lang/crates.io-index"
501 - checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
502 - dependencies = [
503 - "winapi",
504 - ]
505 -
506 - [[package]]
507 - name = "crypto-common"
508 - version = "0.1.7"
509 - source = "registry+https://github.com/rust-lang/crates.io-index"
510 - checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
511 - dependencies = [
512 - "generic-array",
513 - "typenum",
514 - ]
515 -
516 - [[package]]
517 - name = "csscolorparser"
518 - version = "0.6.2"
519 - source = "registry+https://github.com/rust-lang/crates.io-index"
520 - checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
521 - dependencies = [
522 - "lab",
523 - "phf",
524 - ]
525 -
526 - [[package]]
527 - name = "darling"
528 - version = "0.23.0"
529 - source = "registry+https://github.com/rust-lang/crates.io-index"
530 - checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
531 - dependencies = [
532 - "darling_core",
533 - "darling_macro",
534 - ]
535 -
536 - [[package]]
537 - name = "darling_core"
538 - version = "0.23.0"
539 - source = "registry+https://github.com/rust-lang/crates.io-index"
540 - checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
541 - dependencies = [
542 - "ident_case",
543 - "proc-macro2",
544 - "quote",
545 - "strsim",
546 - "syn 2.0.117",
547 - ]
548 -
549 - [[package]]
550 - name = "darling_macro"
551 - version = "0.23.0"
552 - source = "registry+https://github.com/rust-lang/crates.io-index"
553 - checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
554 - dependencies = [
555 - "darling_core",
556 - "quote",
557 - "syn 2.0.117",
558 - ]
559 -
560 - [[package]]
561 - name = "deltae"
562 - version = "0.3.2"
563 - source = "registry+https://github.com/rust-lang/crates.io-index"
564 - checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
565 -
566 - [[package]]
567 - name = "deranged"
568 - version = "0.5.8"
569 - source = "registry+https://github.com/rust-lang/crates.io-index"
570 - checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
571 -
572 - [[package]]
573 - name = "derive_more"
574 - version = "2.1.1"
575 - source = "registry+https://github.com/rust-lang/crates.io-index"
576 - checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
577 - dependencies = [
578 - "derive_more-impl",
579 - ]
580 -
581 - [[package]]
582 - name = "derive_more-impl"
583 - version = "2.1.1"
584 - source = "registry+https://github.com/rust-lang/crates.io-index"
585 - checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
586 - dependencies = [
587 - "convert_case",
588 - "proc-macro2",
589 - "quote",
590 - "rustc_version",
591 - "syn 2.0.117",
592 - ]
593 -
594 - [[package]]
595 - name = "digest"
596 - version = "0.10.7"
597 - source = "registry+https://github.com/rust-lang/crates.io-index"
598 - checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
599 - dependencies = [
600 - "block-buffer",
601 - "crypto-common",
602 - ]
603 -
604 - [[package]]
605 358 name = "directories"
606 359 version = "6.0.0"
607 360 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -630,16 +383,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
630 383 dependencies = [
631 384 "proc-macro2",
632 385 "quote",
633 - "syn 2.0.117",
634 - ]
635 -
636 - [[package]]
637 - name = "document-features"
638 - version = "0.2.12"
639 - source = "registry+https://github.com/rust-lang/crates.io-index"
640 - checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
641 - dependencies = [
642 - "litrs",
386 + "syn",
643 387 ]
644 388
645 389 [[package]]
@@ -649,37 +393,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
649 393 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
650 394
651 395 [[package]]
652 - name = "either"
653 - version = "1.15.0"
654 - source = "registry+https://github.com/rust-lang/crates.io-index"
655 - checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
656 -
657 - [[package]]
658 396 name = "equivalent"
659 397 version = "1.0.2"
660 398 source = "registry+https://github.com/rust-lang/crates.io-index"
661 399 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
662 400
663 401 [[package]]
664 - name = "errno"
665 - version = "0.3.14"
666 - source = "registry+https://github.com/rust-lang/crates.io-index"
667 - checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
668 - dependencies = [
669 - "libc",
670 - "windows-sys 0.61.2",
671 - ]
672 -
673 - [[package]]
674 - name = "euclid"
675 - version = "0.22.14"
676 - source = "registry+https://github.com/rust-lang/crates.io-index"
677 - checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
678 - dependencies = [
679 - "num-traits",
680 - ]
681 -
682 - [[package]]
683 402 name = "eyre"
684 403 version = "0.6.12"
685 404 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -702,57 +421,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
702 421 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
703 422
704 423 [[package]]
705 - name = "fancy-regex"
706 - version = "0.11.0"
707 - source = "registry+https://github.com/rust-lang/crates.io-index"
708 - checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
709 - dependencies = [
710 - "bit-set",
711 - "regex",
712 - ]
713 -
714 - [[package]]
715 - name = "fast-srgb8"
716 - version = "1.0.0"
717 - source = "registry+https://github.com/rust-lang/crates.io-index"
718 - checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
719 -
720 - [[package]]
721 - name = "filedescriptor"
722 - version = "0.8.3"
723 - source = "registry+https://github.com/rust-lang/crates.io-index"
724 - checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
725 - dependencies = [
726 - "libc",
727 - "thiserror 1.0.69",
728 - "winapi",
729 - ]
730 -
731 - [[package]]
732 424 name = "find-msvc-tools"
733 425 version = "0.1.9"
734 426 source = "registry+https://github.com/rust-lang/crates.io-index"
735 427 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
736 428
737 429 [[package]]
738 - name = "finl_unicode"
739 - version = "1.4.0"
740 - source = "registry+https://github.com/rust-lang/crates.io-index"
741 - checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
742 -
743 - [[package]]
744 - name = "fixedbitset"
745 - version = "0.4.2"
746 - source = "registry+https://github.com/rust-lang/crates.io-index"
747 - checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
748 -
749 - [[package]]
750 - name = "fnv"
751 - version = "1.0.7"
752 - source = "registry+https://github.com/rust-lang/crates.io-index"
753 - checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
754 -
755 - [[package]]
756 430 name = "foldhash"
757 431 version = "0.1.5"
758 432 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -813,16 +487,6 @@ dependencies = [
813 487 ]
814 488
815 489 [[package]]
816 - name = "generic-array"
817 - version = "0.14.7"
818 - source = "registry+https://github.com/rust-lang/crates.io-index"
819 - checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
820 - dependencies = [
821 - "typenum",
822 - "version_check",
823 - ]
824 -
825 - [[package]]
826 490 name = "getrandom"
827 491 version = "0.2.17"
828 492 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -883,8 +547,6 @@ version = "0.16.1"
883 547 source = "registry+https://github.com/rust-lang/crates.io-index"
884 548 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
885 549 dependencies = [
886 - "allocator-api2",
887 - "equivalent",
888 550 "foldhash 0.2.0",
889 551 ]
890 552
@@ -893,11 +555,6 @@ name = "hashbrown"
893 555 version = "0.17.0"
894 556 source = "registry+https://github.com/rust-lang/crates.io-index"
895 557 checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
896 - dependencies = [
897 - "allocator-api2",
898 - "equivalent",
899 - "foldhash 0.2.0",
900 - ]
901 558
902 559 [[package]]
903 560 name = "hashlink"
@@ -915,12 +572,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
915 572 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
916 573
917 574 [[package]]
918 - name = "hex"
919 - version = "0.4.3"
920 - source = "registry+https://github.com/rust-lang/crates.io-index"
921 - checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
922 -
923 - [[package]]
924 575 name = "http"
925 576 version = "1.4.0"
926 577 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1137,12 +788,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1137 788 checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
1138 789
1139 790 [[package]]
1140 - name = "ident_case"
1141 - version = "1.0.1"
1142 - source = "registry+https://github.com/rust-lang/crates.io-index"
1143 - checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1144 -
1145 - [[package]]
1146 791 name = "idna"
1147 792 version = "1.1.0"
1148 793 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1182,28 +827,6 @@ dependencies = [
1182 827 ]
1183 828
1184 829 [[package]]
1185 - name = "indoc"
1186 - version = "2.0.7"
1187 - source = "registry+https://github.com/rust-lang/crates.io-index"
1188 - checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1189 - dependencies = [
1190 - "rustversion",
1191 - ]
1192 -
1193 - [[package]]
1194 - name = "instability"
1195 - version = "0.3.12"
1196 - source = "registry+https://github.com/rust-lang/crates.io-index"
1197 - checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
1198 - dependencies = [
1199 - "darling",
1200 - "indoc",
1201 - "proc-macro2",
1202 - "quote",
1203 - "syn 2.0.117",
1204 - ]
1205 -
1206 - [[package]]
1207 830 name = "ipnet"
1208 831 version = "2.12.0"
Lines truncated
@@ -13,9 +13,7 @@ axum = "0.8"
13 13 chrono = { version = "0.4", features = ["serde"] }
14 14 clap = { version = "4", features = ["derive"] }
15 15 color-eyre = "0.6"
16 - crossterm = "0.29"
17 16 directories = "6"
18 - ratatui = "0.30"
19 17 rusqlite = { version = "0.39", features = ["bundled"] }
20 18 serde = { version = "1", features = ["derive"] }
21 19 serde_json = "1"
M wam/README.md +22 -7
@@ -1,8 +1,14 @@
1 1 # wam (Whack-a-Mole)
2 2
3 - A distributed ticket manager driven from the terminal. One binary exposes three
4 - surfaces over a single SQLite database: an interactive TUI, a CLI, and an HTTP
5 - API for programmatic ingest.
3 + A ticket store for ongoing, time-sensitive problems, driven from the terminal.
4 + One binary exposes two surfaces over a single SQLite database: a CLI and an HTTP
5 + API for programmatic ingest. The HTTP API is the point of the tool — automated
6 + sources (the MNW server, PoM, backup jobs) file problems into it.
7 +
8 + wam tracks *problems*; GoingsOn tracks *solutions* (todos). They stay separate:
9 + the plan is a lightweight bridge that promotes a wam ticket into a GoingsOn task
10 + to solve it, rather than merging the two. See the GoingsOn `wam` and `teams`
11 + tasks for the direction.
6 12
7 13 ## Urgency without a priority field
8 14
@@ -23,18 +29,27 @@ their calibration live in `src/types.rs`.
23 29 ## Build and run
24 30
25 31 ```
26 - cargo run -p wam # open the TUI (default)
32 + cargo run -p wam # list open tickets (default)
27 33 cargo run -p wam -- create -t "title" # create a ticket from the CLI
28 34 cargo run -p wam -- list # list tickets
35 + cargo run -p wam -- serve --token XXX # run the HTTP ingest API (bearer-auth)
29 36 ```
30 37
31 - Run without a subcommand for the TUI. Run with a subcommand for one-shot CLI use.
38 + Run without a subcommand for the open-ticket list. Run with a subcommand for
39 + one-shot CLI use.
40 +
41 + ## Auth
42 +
43 + The HTTP API runs on the tailnet. Pass `--token` (or set `WAM_TOKEN`) to require
44 + `Authorization: Bearer <token>` on every request; peer sync presents the same
45 + token. With no token the API stays open and logs a warning at startup, so the
46 + tailnet-ACL dependency is explicit. Producers (MNW server, PoM, the offsite-sync
47 + scripts) send the token when `WAM_TOKEN` is set in their environment.
32 48
33 49 ## Layout
34 50
35 - - `src/tui.rs` the ratatui interactive view.
36 51 - `src/cli.rs` the clap subcommands and export formats.
37 - - `src/api.rs` the axum HTTP endpoints.
52 + - `src/api.rs` the axum HTTP endpoints + bearer auth.
38 53 - `src/db.rs` SQLite storage (bundled rusqlite, WAL mode). Each instance has a
39 54 node id, the seam for future multi-user use.
40 55 - `src/types.rs` the ticket model and the painhours scoring.
M wam/src/api.rs +124 -12
@@ -1,6 +1,11 @@
1 1 //! HTTP API for programmatic ticket management and peer sync.
2 2 //!
3 - //! Designed to run on the tailnet -- tailnet membership is the auth boundary.
3 + //! The API runs on the tailnet. Tailnet membership is the outer boundary, but
4 + //! that is not sufficient on its own: anything that can reach the port can
5 + //! otherwise forge tickets. When a shared token is configured (`--token` or the
6 + //! `WAM_TOKEN` env var) every request must carry `Authorization: Bearer <token>`;
7 + //! peer sync presents the same token. With no token set the API stays open and
8 + //! logs a warning at startup, so the tailnet-ACL dependency is explicit.
4 9
5 10 use std::sync::Arc;
6 11
@@ -8,9 +13,10 @@ use tokio::sync::Mutex;
8 13
9 14 use axum::{
10 15 Json, Router,
11 - extract::{Path, Query, State},
16 + extract::{Path, Query, Request, State},
12 17 http::StatusCode,
13 - response::IntoResponse,
18 + middleware::{self, Next},
19 + response::{IntoResponse, Response},
14 20 routing::{get, patch, post},
15 21 };
16 22 use rusqlite::Connection;
@@ -19,25 +25,39 @@ use serde::Deserialize;
19 25 use crate::db::{self, ListFilter};
20 26 use crate::types::{Channel, NewTicket, Priority, Status, Ticket};
21 27
22 - /// Shared state: SQLite connection + node identity.
28 + /// Shared state: SQLite connection + node identity + optional shared token.
23 29 #[derive(Clone)]
24 30 pub(crate) struct AppState {
25 31 pub db: Arc<Mutex<Connection>>,
26 32 pub node_id: String,
33 + /// Shared bearer token. `None` leaves the API unauthenticated (tailnet-only).
34 + pub token: Option<Arc<str>>,
27 35 }
28 36
29 37 /// Start the HTTP server, optionally syncing with peers.
38 + ///
39 + /// `token` is the CLI-supplied shared secret; it falls back to the `WAM_TOKEN`
40 + /// env var. When resolved to `Some`, every request (including peer sync) must
41 + /// present it as a bearer token.
30 42 pub(crate) async fn serve(
31 43 conn: Connection,
32 44 port: u16,
33 45 peers: Vec<String>,
46 + token: Option<String>,
34 47 ) -> color_eyre::eyre::Result<()> {
35 48 let node_id = db::get_or_create_node_id(&conn)?;
36 49 eprintln!("node: {}", &node_id[..8]);
37 50
51 + let token = token
52 + .or_else(|| std::env::var("WAM_TOKEN").ok())
53 + .map(|t| t.trim().to_owned())
54 + .filter(|t| !t.is_empty())
55 + .map(Arc::<str>::from);
56 +
38 57 let app_state = AppState {
39 58 db: Arc::new(Mutex::new(conn)),
40 59 node_id,
60 + token: token.clone(),
41 61 };
42 62
43 63 // Spawn sync loop if peers are configured
@@ -57,11 +77,24 @@ pub(crate) async fn serve(
57 77 .route("/sync/pull", get(sync_pull))
58 78 .route("/sync/push", post(sync_push))
59 79 .route("/sync/node", get(sync_node_info))
80 + .route_layer(middleware::from_fn_with_state(
81 + app_state.clone(),
82 + require_auth,
83 + ))
60 84 .with_state(app_state);
61 85
62 86 let addr = format!("0.0.0.0:{port}");
63 87 let listener = tokio::net::TcpListener::bind(&addr).await?;
64 88 eprintln!("wam serving on {addr}");
89 + if token.is_some() {
90 + eprintln!("auth: bearer token required on every request");
91 + } else {
92 + eprintln!(
93 + "WARNING: no token set (--token / WAM_TOKEN) -- the API is unauthenticated. \
94 + Anything that can reach {addr} can forge or read tickets; access is bounded \
95 + only by the tailnet ACL."
96 + );
97 + }
65 98 if !peers.is_empty() {
66 99 eprintln!("syncing with {} peer(s)", peers.len());
67 100 }
@@ -69,6 +102,72 @@ pub(crate) async fn serve(
69 102 Ok(())
70 103 }
71 104
105 + /// Reject any request lacking a valid `Authorization: Bearer <token>` header
106 + /// when a shared token is configured. A no-op when the token is unset.
107 + async fn require_auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
108 + let Some(expected) = state.token.as_deref() else {
109 + return next.run(req).await;
110 + };
111 + let presented = req
112 + .headers()
113 + .get(axum::http::header::AUTHORIZATION)
114 + .and_then(|v| v.to_str().ok())
115 + .and_then(|v| v.strip_prefix("Bearer "));
116 + match presented {
117 + Some(tok) if ct_eq(tok.as_bytes(), expected.as_bytes()) => next.run(req).await,
118 + _ => (
119 + StatusCode::UNAUTHORIZED,
120 + Json(serde_json::json!({"error": "unauthorized"})),
121 + )
122 + .into_response(),
123 + }
124 + }
125 +
126 + /// Constant-time byte comparison, so a wrong token can't be recovered by timing
127 + /// how far it matched.
128 + fn ct_eq(a: &[u8], b: &[u8]) -> bool {
129 + if a.len() != b.len() {
130 + return false;
131 + }
132 + let mut diff = 0u8;
133 + for (x, y) in a.iter().zip(b) {
134 + diff |= x ^ y;
135 + }
136 + diff == 0
137 + }
138 +
139 + #[cfg(test)]
140 + mod tests {
141 + use super::*;
142 +
143 + #[test]
144 + fn ct_eq_matches_identical() {
145 + assert!(ct_eq(b"s3cr3t-token", b"s3cr3t-token"));
146 + assert!(ct_eq(b"", b""));
147 + }
148 +
149 + #[test]
150 + fn ct_eq_rejects_different_content() {
151 + assert!(!ct_eq(b"s3cr3t-token", b"s3cr3t-toker"));
152 + assert!(!ct_eq(b"abc", b"xyz"));
153 + }
154 +
155 + #[test]
156 + fn ct_eq_rejects_length_mismatch() {
157 + // A correct prefix must not pass — the guard is exact-match only.
158 + assert!(!ct_eq(b"s3cr3t", b"s3cr3t-token"));
159 + assert!(!ct_eq(b"s3cr3t-token", b"s3cr3t"));
160 + }
161 +
162 + #[test]
163 + fn with_token_attaches_only_when_set() {
164 + let client = reqwest::Client::new();
165 + // Smoke: both branches build a valid request without panicking.
166 + let _ = with_token(client.get("http://127.0.0.1/x"), Some("tok"));
167 + let _ = with_token(client.get("http://127.0.0.1/x"), None);
168 + }
169 + }
170 +
72 171 // -- Ticket handlers ----------------------------------------------------------
73 172
74 173 /// POST /tickets
@@ -251,6 +350,14 @@ async fn sync_node_info(State(state): State<AppState>) -> impl IntoResponse {
251 350 }))
252 351 }
253 352
353 + /// Attach the shared bearer token to an outbound peer request when one is set.
354 + fn with_token(req: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder {
355 + match token {
356 + Some(t) => req.bearer_auth(t),
357 + None => req,
358 + }
359 + }
360 +
254 361 // -- Background sync loop -----------------------------------------------------
255 362
256 363 /// Periodically pull from all peers and push local changes.
@@ -291,7 +398,11 @@ async fn sync_with_peer(
291 398 "{peer_url}/sync/pull?since={}",
292 399 urlencoding::encode(&cursor)
293 400 );
294 - let resp: serde_json::Value = client.get(&pull_url).send().await?.json().await?;
401 + let resp: serde_json::Value = with_token(client.get(&pull_url), state.token.as_deref())
402 + .send()
403 + .await?
404 + .json()
405 + .await?;
295 406
296 407 let tickets: Vec<Ticket> = serde_json::from_value(
297 408 resp.get("tickets")
@@ -325,13 +436,14 @@ async fn sync_with_peer(
325 436
326 437 if !our_tickets.is_empty() {
327 438 let push_url = format!("{peer_url}/sync/push");
328 - let resp: serde_json::Value = client
329 - .post(&push_url)
330 - .json(&our_tickets)
331 - .send()
332 - .await?
333 - .json()
334 - .await?;
439 + let resp: serde_json::Value = with_token(
440 + client.post(&push_url).json(&our_tickets),
441 + state.token.as_deref(),
442 + )
443 + .send()
444 + .await?
445 + .json()
446 + .await?;
335 447 let accepted = resp
336 448 .get("accepted")
337 449 .and_then(serde_json::Value::as_u64)
@@ -75,6 +75,11 @@ pub(crate) enum Command {
75 75 /// Peer WAM URLs to sync with (repeatable)
76 76 #[arg(long)]
77 77 peer: Vec<String>,
78 + /// Shared bearer token required on every request (and presented to
79 + /// peers). Falls back to the WAM_TOKEN env var. When unset the API is
80 + /// unauthenticated and relies on the tailnet ACL alone.
81 + #[arg(long)]
82 + token: Option<String>,
78 83 },
79 84 /// Show ticket aggregates: open by painhours band/source + avg resolution time
80 85 Stats,
@@ -266,24 +266,6 @@ pub(crate) fn update_status(conn: &Connection, id: &str, status: Status) -> Resu
266 266 Ok(())
267 267 }
268 268
269 - /// Update a ticket's title and body. Bumps `updated_at`.
270 - pub(crate) fn update_ticket(
271 - conn: &Connection,
272 - id: &str,
273 - title: &str,
274 - body: Option<&str>,
275 - ) -> Result<()> {
276 - let now = Utc::now().to_rfc3339();
277 - let rows = conn.execute(
278 - "UPDATE tickets SET title = ?1, body = ?2, updated_at = ?3 WHERE id = ?4",
279 - params![title, body, now, id],
280 - )?;
281 - if rows == 0 {
282 - return Err(eyre!("no ticket with id '{id}'"));
283 - }
284 - Ok(())
285 - }
286 -
287 269 /// Aggregate stats across all tickets.
288 270 #[derive(Debug, Default)]
289 271 pub(crate) struct Stats {
@@ -608,24 +590,6 @@ mod tests {
608 590 }
609 591
610 592 #[test]
611 - fn update_ticket_changes_fields() {
612 - let conn = open_memory().unwrap();
613 - let node = get_or_create_node_id(&conn).unwrap();
614 - let t = create_ticket(&conn, &test_new_ticket("orig"), &node).unwrap();
615 -
616 - update_ticket(&conn, &t.id, "renamed", Some("with body")).unwrap();
617 - let fetched = get_ticket(&conn, &t.id).unwrap();
618 - assert_eq!(fetched.title, "renamed");
619 - assert_eq!(fetched.body.as_deref(), Some("with body"));
620 - }
621 -
622 - #[test]
623 - fn update_ticket_missing_errors() {
624 - let conn = open_memory().unwrap();
625 - assert!(update_ticket(&conn, "no-such-id", "x", None).is_err());
626 - }
627 -
628 - #[test]
629 593 fn stats_aggregates() {
630 594 let conn = open_memory().unwrap();
631 595 let node = get_or_create_node_id(&conn).unwrap();
M wam/src/main.rs +37 -27
@@ -9,7 +9,6 @@
9 9 mod api;
10 10 mod cli;
11 11 mod db;
12 - mod tui;
13 12 mod types;
14 13
15 14 use std::io::Write;
@@ -27,7 +26,13 @@ fn main() -> Result<()> {
27 26 let node_id = db::get_or_create_node_id(&conn)?;
28 27
29 28 match cli.command {
30 - None => tui::run(conn, node_id)?,
29 + // No subcommand: print the open-ticket list. The interactive TUI was
30 + // removed (2026-07-23) as wam thins toward an automated-ingest store;
31 + // the CLI is the reader for now.
32 + None => {
33 + let tickets = db::list_tickets(&conn, &ListFilter::default())?;
34 + print_ticket_list(&tickets);
35 + }
31 36
32 37 Some(Command::Create {
33 38 title,
@@ -76,29 +81,7 @@ fn main() -> Result<()> {
76 81 ..Default::default()
77 82 },
78 83 )?;
79 -
80 - if tickets.is_empty() {
81 - println!("no tickets");
82 - return Ok(());
83 - }
84 -
85 - println!(
86 - "{:<10} {:<8} {:<5} {:<12} {:<30} Node",
87 - "ID", "Channel", "PH", "Status", "Title"
88 - );
89 - println!("{}", "-".repeat(80));
90 - for t in &tickets {
91 - println!(
92 - "{:<10} {:<8} {:<5} {:<12} {:<30} {}",
93 - t.short_id(),
94 - t.channel,
95 - t.painhours(),
96 - t.status,
97 - truncate(&t.title, 30),
98 - t.short_node(),
99 - );
100 - }
101 - println!("\n{} ticket(s)", tickets.len());
84 + print_ticket_list(&tickets);
102 85 }
103 86
104 87 Some(Command::Show { id }) => {
@@ -140,9 +123,9 @@ fn main() -> Result<()> {
140 123 println!("closed {} ({})", t.short_id(), t.title);
141 124 }
142 125
143 - Some(Command::Serve { port, peer }) => {
126 + Some(Command::Serve { port, peer, token }) => {
144 127 let rt = tokio::runtime::Runtime::new()?;
145 - rt.block_on(api::serve(conn, port, peer))?;
128 + rt.block_on(api::serve(conn, port, peer, token))?;
146 129 }
147 130
148 131 Some(Command::Stats) => print_stats(&conn)?,
@@ -201,6 +184,33 @@ fn main() -> Result<()> {
201 184 Ok(())
202 185 }
203 186
187 + /// Print a ticket list as an aligned table, or "no tickets" when empty. Shared
188 + /// by `wam list` and the no-subcommand default view.
189 + fn print_ticket_list(tickets: &[types::Ticket]) {
190 + if tickets.is_empty() {
191 + println!("no tickets");
192 + return;
193 + }
194 +
195 + println!(
196 + "{:<10} {:<8} {:<5} {:<12} {:<30} Node",
197 + "ID", "Channel", "PH", "Status", "Title"
198 + );
199 + println!("{}", "-".repeat(80));
200 + for t in tickets {
201 + println!(
202 + "{:<10} {:<8} {:<5} {:<12} {:<30} {}",
203 + t.short_id(),
204 + t.channel,
205 + t.painhours(),
206 + t.status,
207 + truncate(&t.title, 30),
208 + t.short_node(),
209 + );
210 + }
211 + println!("\n{} ticket(s)", tickets.len());
212 + }
213 +
204 214 fn print_stats(conn: &rusqlite::Connection) -> Result<()> {
205 215 let s = db::stats(conn)?;
206 216 println!("Total: {}", s.total);
@@ -1,732 +0,0 @@
1 - //! Ratatui TUI for interactive ticket management.
2 -
3 - use color_eyre::eyre::Result;
4 - use crossterm::event::{self, Event, KeyCode, KeyEventKind};
5 - use ratatui::{
6 - Frame,
7 - layout::{Constraint, Layout, Rect},
8 - style::{Modifier, Style},
9 - text::{Line, Span},
10 - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState},
11 - };
12 - use rusqlite::Connection;
13 -
14 - use crate::db::{self, ListFilter};
15 - use crate::types::{Channel, Priority, Status, Ticket};
16 -
17 - // -- View state ---------------------------------------------------------------
18 -
19 - enum View {
20 - List,
21 - Detail,
22 - Create,
23 - Edit,
24 - }
25 -
26 - #[derive(Clone, Copy, PartialEq, Eq)]
27 - enum EditFocus {
28 - Title,
29 - Body,
30 - }
31 -
32 - struct EditBuf {
33 - id: String,
34 - title: String,
35 - body: String,
36 - focus: EditFocus,
37 - }
38 -
39 - enum InputMode {
40 - Normal,
41 - Search,
42 - }
43 -
44 - // -- App ----------------------------------------------------------------------
45 -
46 - struct App {
47 - conn: Connection,
48 - node_id: String,
49 - tickets: Vec<Ticket>,
50 - table_state: TableState,
51 - view: View,
52 - input_mode: InputMode,
53 - status_filter: Option<Status>,
54 - priority_filter: Option<Priority>,
55 - channel_filter: Option<Channel>,
56 - source_filter: Option<String>,
57 - all_sources: Vec<String>,
58 - search_query: String,
59 - create_input: String,
60 - edit_buf: Option<EditBuf>,
61 - running: bool,
62 - }
63 -
64 - impl App {
65 - fn new(conn: Connection, node_id: String) -> Result<Self> {
66 - let mut app = Self {
67 - conn,
68 - node_id,
69 - tickets: Vec::new(),
70 - table_state: TableState::default(),
71 - view: View::List,
72 - input_mode: InputMode::Normal,
73 - status_filter: None,
74 - priority_filter: None,
75 - channel_filter: None,
76 - source_filter: None,
77 - all_sources: Vec::new(),
78 - search_query: String::new(),
79 - create_input: String::new(),
80 - edit_buf: None,
81 - running: true,
82 - };
83 - app.reload_sources()?;
84 - app.refresh()?;
85 - if !app.tickets.is_empty() {
86 - app.table_state.select(Some(0));
87 - }
88 - Ok(app)
89 - }
90 -
91 - fn refresh(&mut self) -> Result<()> {
92 - let search = if self.search_query.is_empty() {
93 - None
94 - } else {
95 - Some(self.search_query.as_str())
96 - };
97 - self.tickets = db::list_tickets(
98 - &self.conn,
99 - &ListFilter {
100 - status: self.status_filter,
101 - priority: self.priority_filter,
102 - channel: self.channel_filter,
103 - source: self.source_filter.as_deref(),
104 - search,
105 - },
106 - )?;
107 - // Keep selection in bounds
108 - if self.tickets.is_empty() {
109 - self.table_state.select(None);
110 - } else if let Some(i) = self.table_state.selected()
111 - && i >= self.tickets.len()
112 - {
113 - self.table_state.select(Some(self.tickets.len() - 1));
114 - }
115 - Ok(())
116 - }
117 -
118 - fn selected_ticket(&self) -> Option<&Ticket> {
119 - self.table_state
120 - .selected()
121 - .and_then(|i| self.tickets.get(i))
122 - }
123 -
124 - fn set_selected_status(&mut self, status: Status) -> Result<()> {
125 - if let Some(ticket) = self.selected_ticket() {
126 - let id = ticket.id.clone();
127 - db::update_status(&self.conn, &id, status)?;
128 - self.refresh()?;
129 - }
130 - Ok(())
131 - }
132 -
133 - fn cycle_status_filter(&mut self) -> Result<()> {
134 - self.status_filter = match self.status_filter {
135 - None => Some(Status::Open),
136 - Some(Status::Open) => Some(Status::InProgress),
137 - Some(Status::InProgress) => Some(Status::Resolved),
138 - Some(Status::Resolved) => Some(Status::Closed),
139 - Some(Status::Closed) => None,
140 - };
141 - self.refresh()
142 - }
143 -
144 - fn cycle_priority_filter(&mut self) -> Result<()> {
145 - self.priority_filter = match self.priority_filter {
146 - None => Some(Priority::Critical),
147 - Some(Priority::Critical) => Some(Priority::High),
148 - Some(Priority::High) => Some(Priority::Medium),
149 - Some(Priority::Medium) => Some(Priority::Low),
150 - Some(Priority::Low) => None,
151 - };
152 - self.refresh()
153 - }
154 -
155 - fn cycle_source_filter(&mut self) -> Result<()> {
156 - self.reload_sources()?;
157 - self.source_filter = match &self.source_filter {
158 - None if !self.all_sources.is_empty() => Some(self.all_sources[0].clone()),
159 - Some(current) => {
160 - let idx = self.all_sources.iter().position(|s| s == current);
161 - match idx {
162 - Some(i) if i + 1 < self.all_sources.len() => {
163 - Some(self.all_sources[i + 1].clone())
164 - }
165 - _ => None,
166 - }
167 - }
168 - None => None,
169 - };
170 - self.refresh()
171 - }
172 -
173 - fn reload_sources(&mut self) -> Result<()> {
174 - let all = db::list_tickets(&self.conn, &ListFilter::default())?;
175 - let mut sources: Vec<String> = all.iter().filter_map(|t| t.source.clone()).collect();
176 - sources.sort();
177 - sources.dedup();
178 - self.all_sources = sources;
179 - Ok(())
180 - }
181 -
182 - fn cycle_channel_filter(&mut self) -> Result<()> {
183 - self.channel_filter = match self.channel_filter {
184 - None => Some(Channel::System),
185 - Some(Channel::System) => Some(Channel::Request),
186 - Some(Channel::Request) => Some(Channel::Task),
187 - Some(Channel::Task) => None,
188 - };
189 - self.refresh()
190 - }
191 -
192 - fn enter_edit(&mut self) {
193 - if let Some(t) = self.selected_ticket() {
194 - self.edit_buf = Some(EditBuf {
195 - id: t.id.clone(),
196 - title: t.title.clone(),
197 - body: t.body.clone().unwrap_or_default(),
198 - focus: EditFocus::Title,
199 - });
200 - self.view = View::Edit;
201 - }
202 - }
203 -
204 - fn submit_edit(&mut self) -> Result<()> {
205 - if let Some(buf) = self.edit_buf.take() {
206 - let title = buf.title.trim();
207 - if !title.is_empty() {
208 - let body = if buf.body.is_empty() {
209 - None
210 - } else {
211 - Some(buf.body.as_str())
212 - };
213 - db::update_ticket(&self.conn, &buf.id, title, body)?;
214 - self.refresh()?;
215 - }
216 - }
217 - self.view = View::Detail;
218 - Ok(())
219 - }
220 -
221 - fn submit_create(&mut self) -> Result<()> {
222 - let title = self.create_input.trim().to_string();
223 - if !title.is_empty() {
224 - db::create_ticket(
225 - &self.conn,
226 - &crate::types::NewTicket {
227 - title,
228 - body: None,
229 - pain: crate::types::DEFAULT_FACTOR,
230 - scale: crate::types::DEFAULT_FACTOR,
231 - channel: Channel::Task,
232 - source: Some("manual".into()),
233 - source_ref: None,
234 - },
235 - &self.node_id,
236 - )?;
237 - self.refresh()?;
238 - }
239 - self.create_input.clear();
240 - self.view = View::List;
241 - Ok(())
242 - }
243 - }
244 -
245 - // -- Entry point --------------------------------------------------------------
246 -
247 - pub(crate) fn run(conn: Connection, node_id: String) -> Result<()> {
248 - let mut terminal = ratatui::init();
249 - let mut app = App::new(conn, node_id)?;
250 -
251 - while app.running {
252 - terminal.draw(|f| render(&mut app, f))?;
253 -
254 - if event::poll(std::time::Duration::from_millis(250))?
255 - && let Event::Key(key) = event::read()?
256 - {
257 - if key.kind != KeyEventKind::Press {
258 - continue;
259 - }
260 - handle_key(&mut app, key.code)?;
261 - }
262 - }
263 -
264 - ratatui::restore();
265 - Ok(())
266 - }
267 -
268 - // -- Input handling -----------------------------------------------------------
269 -
270 - fn handle_key(app: &mut App, key: KeyCode) -> Result<()> {
271 - // Search input mode captures all keys
272 - if matches!(app.input_mode, InputMode::Search) {
273 - match key {
274 - KeyCode::Esc => {
275 - app.search_query.clear();
276 - app.input_mode = InputMode::Normal;
277 - app.refresh()?;
278 - }
279 - KeyCode::Enter => {
280 - app.input_mode = InputMode::Normal;
281 - }
282 - KeyCode::Backspace => {
283 - app.search_query.pop();
284 - app.refresh()?;
285 - }
286 - KeyCode::Char(c) => {
287 - app.search_query.push(c);
288 - app.refresh()?;
289 - }
290 - _ => {}
291 - }
292 - return Ok(());
293 - }
294 -
295 - // Create input mode
296 - if matches!(app.view, View::Create) {
297 - match key {
298 - KeyCode::Esc => {
299 - app.create_input.clear();
300 - app.view = View::List;
301 - }
302 - KeyCode::Enter => app.submit_create()?,
303 - KeyCode::Backspace => {
304 - app.create_input.pop();
305 - }
306 - KeyCode::Char(c) => app.create_input.push(c),
307 - _ => {}
308 - }
309 - return Ok(());
310 - }
311 -
312 - // Edit input mode
313 - if matches!(app.view, View::Edit) {
314 - match key {
315 - KeyCode::Esc => {
316 - app.edit_buf = None;
317 - app.view = View::Detail;
318 - }
319 - KeyCode::Tab | KeyCode::BackTab => {
320 - if let Some(buf) = app.edit_buf.as_mut() {
321 - buf.focus = match buf.focus {
322 - EditFocus::Title => EditFocus::Body,
323 - EditFocus::Body => EditFocus::Title,
324 - };
325 - }
326 - }
327 - KeyCode::Enter => {
328 - if let Some(buf) = app.edit_buf.as_mut() {
329 - match buf.focus {
330 - EditFocus::Title => buf.focus = EditFocus::Body,
331 - EditFocus::Body => {
332 - app.submit_edit()?;
333 - }
334 - }
335 - }
336 - }
337 - KeyCode::Backspace => {
338 - if let Some(buf) = app.edit_buf.as_mut() {
339 - match buf.focus {
340 - EditFocus::Title => {
341 - buf.title.pop();
342 - }
343 - EditFocus::Body => {
344 - buf.body.pop();
345 - }
346 - }
347 - }
348 - }
349 - KeyCode::Char(c) => {
350 - if let Some(buf) = app.edit_buf.as_mut() {
351 - match buf.focus {
352 - EditFocus::Title => buf.title.push(c),
353 - EditFocus::Body => buf.body.push(c),
354 - }
355 - }
356 - }
357 - _ => {}
358 - }
359 - return Ok(());
360 - }
361 -
362 - match app.view {
363 - View::List => match key {
364 - KeyCode::Char('q') => app.running = false,
365 - KeyCode::Char('j') | KeyCode::Down => {
366 - let next = app
367 - .table_state
368 - .selected()
369 - .map_or(0, |i| (i + 1).min(app.tickets.len().saturating_sub(1)));
370 - app.table_state.select(Some(next));
371 - }
372 - KeyCode::Char('k') | KeyCode::Up => {
373 - let prev = app
374 - .table_state
375 - .selected()
376 - .map_or(0, |i| i.saturating_sub(1));
377 - app.table_state.select(Some(prev));
378 - }
379 - KeyCode::Enter if app.selected_ticket().is_some() => {
380 - app.view = View::Detail;
381 - }
382 - KeyCode::Char('o') => app.set_selected_status(Status::Open)?,
383 - KeyCode::Char('i') => app.set_selected_status(Status::InProgress)?,
384 - KeyCode::Char('r') => app.set_selected_status(Status::Resolved)?,
385 - KeyCode::Char('c') => app.set_selected_status(Status::Closed)?,
386 - KeyCode::Char('n') => {
387 - app.create_input.clear();
388 - app.view = View::Create;
389 - }
390 - KeyCode::Char('f') => app.cycle_status_filter()?,
391 - KeyCode::Char('p') => app.cycle_priority_filter()?,
392 - KeyCode::Char('s') => app.cycle_source_filter()?,
393 - KeyCode::Char('t') => app.cycle_channel_filter()?,
394 - KeyCode::Char('/') => {
395 - app.search_query.clear();
396 - app.input_mode = InputMode::Search;
397 - }
398 - _ => {}
399 - },
400 - View::Detail => match key {
401 - KeyCode::Esc | KeyCode::Char('q') => app.view = View::List,
402 - KeyCode::Char('o') => {
403 - app.set_selected_status(Status::Open)?;
404 - }
405 - KeyCode::Char('i') => {
406 - app.set_selected_status(Status::InProgress)?;
407 - }
408 - KeyCode::Char('r') => {
409 - app.set_selected_status(Status::Resolved)?;
410 - }
411 - KeyCode::Char('c') => {
412 - app.set_selected_status(Status::Closed)?;
413 - }
414 - KeyCode::Char('e') => app.enter_edit(),
415 - _ => {}
416 - },
417 - View::Create | View::Edit => {} // handled above
418 - }
419 -
420 - Ok(())
421 - }
422 -
423 - // -- Rendering ----------------------------------------------------------------
424 -
425 - fn render(app: &mut App, f: &mut Frame) {
426 - let [title_area, main_area, status_area] = Layout::vertical([
427 - Constraint::Length(1),
428 - Constraint::Fill(1),
429 - Constraint::Length(1),
430 - ])
431 - .areas(f.area());
432 -
433 - render_title_bar(app, f, title_area);
434 -
435 - match app.view {
436 - View::List => render_list(app, f, main_area),
437 - View::Detail => render_detail(app, f, main_area),
438 - View::Create => {
439 - render_list(app, f, main_area);
440 - render_create_popup(app, f, f.area());
441 - }
442 - View::Edit => {
443 - render_detail(app, f, main_area);
444 - render_edit_popup(app, f, f.area());
445 - }
446 - }
447 -
448 - render_status_bar(app, f, status_area);
449 - }
450 -
451 - fn render_title_bar(app: &App, f: &mut Frame, area: Rect) {
452 - let mut spans = vec![Span::styled(" WAM ", Style::new().bold().reversed())];
453 -
454 - // Show active filters
455 - let mut filters: Vec<String> = Vec::new();
456 - if let Some(status) = app.status_filter {
457 - filters.push(format!("status:{status}"));
458 - }
459 - if let Some(priority) = app.priority_filter {
460 - filters.push(format!("pri:{priority}"));
461 - }
462 - if let Some(channel) = app.channel_filter {
463 - filters.push(format!("ch:{channel}"));
464 - }
465 - if let Some(ref source) = app.source_filter {
466 - filters.push(format!("src:{source}"));
467 - }
468 - if !filters.is_empty() {
469 - spans.push(Span::raw(" "));
470 - spans.push(Span::styled(filters.join(" "), Style::new().bold()));
471 - }
472 -
473 - if matches!(app.input_mode, InputMode::Search) {
474 - spans.push(Span::raw(" /"));
475 - spans.push(Span::styled(
476 - &app.search_query,
477 - Style::new().bold().underlined(),
478 - ));
479 - } else if !app.search_query.is_empty() {
480 - spans.push(Span::raw(" search: "));
481 - spans.push(Span::styled(&app.search_query, Style::new().bold()));
482 - }
483 -
484 - let count = format!(" {} tickets ", app.tickets.len());
485 - spans.push(Span::raw(count));
486 -
487 - f.render_widget(Line::from(spans), area);
488 - }
489 -
490 - fn render_list(app: &mut App, f: &mut Frame, area: Rect) {
491 - let header = Row::new(["PH", "Title", "Ch", "Source", "Status", "Node", "Age"])
492 - .style(Style::new().bold().underlined());
493 -
494 - let rows: Vec<Row> = app
495 - .tickets
496 - .iter()
497 - .map(|t| {
498 - let pri_style = Style::new().fg(t.band().color());
499 - Row::new([
500 - Cell::from(format!("{:>3}", t.painhours())).style(pri_style),
Lines truncated
@@ -25,7 +25,6 @@ use std::fmt;
25 25 use std::str::FromStr;
26 26
27 27 use chrono::{DateTime, Utc};
28 - use ratatui::style::Color;
29 28 use serde::{Deserialize, Serialize};
30 29
31 30 // -- painhours tuning ---------------------------------------------------------
@@ -73,15 +72,6 @@ pub(crate) enum Priority {
73 72 }
74 73
75 74 impl Priority {
76 - pub(crate) fn color(self) -> Color {
77 - match self {
78 - Self::Low => Color::DarkGray,
79 - Self::Medium => Color::White,
80 - Self::High => Color::Yellow,
81 - Self::Critical => Color::Red,
82 - }
83 - }
84 -
85 75 /// Bucket a 0-100 painhours score into a color band.
86 76 pub(crate) fn from_painhours(score: u32) -> Self {
87 77 if score >= BAND_CRITICAL {