1pub mod acls;
2pub mod groups;
3pub mod layout;
4pub mod log_dirs;
5pub mod login;
6pub mod overview;
7pub mod page;
8pub mod quotas;
9pub mod topics;
10pub mod users;
11
12use dioxus::prelude::*;
13use layout::sidebar_links_for;
14use page::LoginRouteState;
15pub use page::{
16 OperatorView, ReadRouteState, RoutePage, render_page, render_page_body_html,
17 render_page_body_html_for_operator, render_page_for_operator,
18};
19
20use crate::{dto::LogDirRow, permissions::Capabilities, session::CSRF_FIELD_NAME};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Route {
24 Overview,
25 Login,
26 Topics,
27 Groups,
28 Acls,
29 Users,
30 Quotas,
31 LogDirs,
32}
33
34impl Route {
35 #[must_use]
36 pub const fn path(self) -> &'static str {
37 match self {
38 Self::Overview => "/",
39 Self::Login => "/login",
40 Self::Topics => "/topics",
41 Self::Groups => "/groups",
42 Self::Acls => "/acls",
43 Self::Users => "/users",
44 Self::Quotas => "/quotas",
45 Self::LogDirs => "/log-dirs",
46 }
47 }
48
49 #[must_use]
50 pub const fn label(self) -> &'static str {
51 match self {
52 Self::Overview => "Overview",
53 Self::Login => "Login",
54 Self::Topics => "Topics",
55 Self::Groups => "Groups",
56 Self::Acls => "ACLs",
57 Self::Users => "Users",
58 Self::Quotas => "Quotas",
59 Self::LogDirs => "Log Dirs",
60 }
61 }
62
63 #[must_use]
64 pub const fn guard_for_authentication(self, is_authenticated: bool) -> Self {
65 if is_authenticated || matches!(self, Self::Login) {
66 return self;
67 }
68
69 Self::Login
70 }
71
72 #[must_use]
74 pub const fn is_permitted(self, capabilities: Capabilities) -> bool {
75 match self {
76 Self::Overview | Self::Login => true,
77 Self::Topics => capabilities.can_view_topics(),
78 Self::Groups => capabilities.can_view_groups(),
79 Self::Acls => capabilities.can_view_acls(),
80 Self::Users => capabilities.can_view_users(),
81 Self::Quotas => capabilities.can_view_quotas(),
82 Self::LogDirs => capabilities.can_view_log_dirs(),
83 }
84 }
85}
86
87#[must_use]
88pub fn render_route_html(route: Route) -> String {
89 render_page(&RoutePage::for_unauthenticated_route(route))
90}
91
92pub fn render_route(route: Route) -> Element {
95 render_page_element(&RoutePage::for_unauthenticated_route(route))
96}
97
98pub fn render_page_element(page: &RoutePage) -> Element {
101 render_page_element_for_operator(page, &OperatorView::anonymous())
102}
103
104pub fn render_page_element_for_operator(page: &RoutePage, operator: &OperatorView) -> Element {
107 match page.clone() {
108 RoutePage::Login { state } => render_login_element(state),
109 page => render_operations_shell_element(page, operator),
110 }
111}
112
113#[derive(Clone, PartialEq, Eq)]
115struct FormField {
116 name: &'static str,
117 label: &'static str,
118 kind: &'static str,
119 value: String,
120}
121
122impl FormField {
123 fn text(name: &'static str, label: &'static str, value: &str) -> Self {
124 Self {
125 name,
126 label,
127 kind: "text",
128 value: value.to_string(),
129 }
130 }
131
132 fn number(name: &'static str, label: &'static str, value: &str) -> Self {
133 Self {
134 name,
135 label,
136 kind: "number",
137 value: value.to_string(),
138 }
139 }
140
141 fn password(name: &'static str, label: &'static str) -> Self {
142 Self {
143 name,
144 label,
145 kind: "password",
146 value: String::new(),
147 }
148 }
149
150 fn fixed(name: &'static str, value: &str) -> Self {
151 Self {
152 name,
153 label: "",
154 kind: "hidden",
155 value: value.to_string(),
156 }
157 }
158}
159
160fn mutation_form(action: &str, submit_label: &str, token: &str, fields: Vec<FormField>) -> Element {
167 rsx! {
168 form { class: "mutation-form", method: "post", action: "{action}",
169 h3 { "{submit_label}" }
170 input { r#type: "hidden", name: "{CSRF_FIELD_NAME}", value: "{token}" }
171 for field in fields {
172 if field.kind == "hidden" {
173 input { r#type: "hidden", name: "{field.name}", value: "{field.value}" }
174 } else {
175 label {
176 "{field.label} "
177 input {
178 r#type: "{field.kind}",
179 name: "{field.name}",
180 value: "{field.value}",
181 }
182 }
183 }
184 }
185 button { r#type: "submit", "{submit_label}" }
186 }
187 }
188}
189
190fn mutation_forms(page: &RoutePage, operator: &OperatorView) -> Vec<Element> {
192 let Some(token) = operator.csrf_token() else {
193 return Vec::new();
194 };
195 let capabilities = operator.capabilities();
196
197 match page {
198 RoutePage::Topics { .. } => topics_forms(token, capabilities),
199 RoutePage::Acls { .. } => acls_forms(token, capabilities),
200 RoutePage::Users { .. } => users_forms(token, capabilities),
201 RoutePage::Quotas { .. } => quotas_forms(token, capabilities),
202 RoutePage::LogDirs { .. } => log_dirs_forms(token, capabilities),
203 RoutePage::Overview { .. } | RoutePage::Groups { .. } | RoutePage::Login { .. } => {
204 Vec::new()
205 }
206 }
207}
208
209fn topics_forms(token: &str, capabilities: Capabilities) -> Vec<Element> {
210 let mut forms = Vec::new();
211
212 if capabilities.can_create_topics() {
213 forms.push(mutation_form(
214 "/topics/create",
215 "Create Topic",
216 token,
217 vec![
218 FormField::text("name", "Name", ""),
219 FormField::number("partitions", "Partitions", "1"),
220 FormField::number("replicas", "Replicas", "1"),
221 FormField::text("configs", "Configs (name=value,name=value)", ""),
222 ],
223 ));
224 }
225
226 if capabilities.can_alter_topics() {
227 forms.push(mutation_form(
228 "/topics/partitions",
229 "Add Partitions",
230 token,
231 vec![
232 FormField::text("topic", "Topic", ""),
233 FormField::number("total_count", "Total partitions", "1"),
234 ],
235 ));
236 forms.push(mutation_form(
237 "/topics/configs",
238 "Alter Topic Configs",
239 token,
240 vec![
241 FormField::fixed("resource_type", "topic"),
242 FormField::text("resource_name", "Topic", ""),
243 FormField::text("configs", "Configs (name=value,name=value)", ""),
244 ],
245 ));
246 }
247
248 if capabilities.can_delete_topics() {
249 forms.push(mutation_form(
250 "/topics/delete",
251 "Delete Topic",
252 token,
253 vec![FormField::text("name", "Name", "")],
254 ));
255 }
256
257 forms
258}
259
260fn acl_fields() -> Vec<FormField> {
261 vec![
262 FormField::text("resource_type", "Resource type", "topic"),
263 FormField::text("resource_name", "Resource name", ""),
264 FormField::text(
265 "pattern_type",
266 "Pattern type (literal or prefixed)",
267 "literal",
268 ),
269 FormField::text("principal", "Principal", "User:"),
270 FormField::text("host", "Host", "*"),
271 FormField::text("operation", "Operation", "read"),
272 FormField::text("permission", "Permission", "allow"),
273 ]
274}
275
276fn acls_forms(token: &str, capabilities: Capabilities) -> Vec<Element> {
277 if !capabilities.can_alter_acls() {
278 return Vec::new();
279 }
280
281 vec![
282 mutation_form("/acls/create", "Create ACL", token, acl_fields()),
283 mutation_form("/acls/delete", "Delete ACL", token, acl_fields()),
284 ]
285}
286
287fn users_forms(token: &str, capabilities: Capabilities) -> Vec<Element> {
288 if !capabilities.can_alter_users() {
289 return Vec::new();
290 }
291
292 vec![
293 mutation_form(
294 "/users/scram/upsert",
295 "Upsert SCRAM-SHA-512",
296 token,
297 vec![
298 FormField::text("username", "Username", ""),
299 FormField::password("password", "Password"),
300 FormField::number("iterations", "Iterations", "4096"),
301 ],
302 ),
303 mutation_form(
304 "/users/scram/delete",
305 "Delete SCRAM User",
306 token,
307 vec![FormField::text("username", "Username", "")],
308 ),
309 ]
310}
311
312fn quotas_forms(token: &str, capabilities: Capabilities) -> Vec<Element> {
313 if !capabilities.can_alter_quotas() {
314 return Vec::new();
315 }
316
317 vec![
318 mutation_form(
319 "/quotas/upsert",
320 "Set Quota",
321 token,
322 vec![
323 FormField::text("entity", "User", ""),
324 FormField::text("quota_type", "Quota", "producer_byte_rate"),
325 FormField::text("value", "Value", ""),
326 ],
327 ),
328 mutation_form(
329 "/quotas/delete",
330 "Remove Quota",
331 token,
332 vec![
333 FormField::text("entity", "User", ""),
334 FormField::text("quota_type", "Quota", "producer_byte_rate"),
335 ],
336 ),
337 ]
338}
339
340fn log_dirs_forms(token: &str, capabilities: Capabilities) -> Vec<Element> {
341 if !capabilities.can_alter_log_dirs() {
342 return Vec::new();
343 }
344
345 vec![mutation_form(
346 "/log-dirs/move",
347 "Move Replica",
348 token,
349 vec![
350 FormField::text("topic", "Topic", ""),
351 FormField::number("partition", "Partition", "0"),
352 FormField::number("broker_id", "Broker id", "0"),
353 FormField::text("destination_log_dir", "Destination log dir", ""),
354 ],
355 )]
356}
357
358fn render_login_element(state: LoginRouteState) -> Element {
359 let message = match state {
360 LoginRouteState::Form => None,
361 LoginRouteState::AuthenticationFailed => Some("Authentication failed."),
362 LoginRouteState::BrokerUnavailable => {
363 Some("No broker answered. The cluster may be down; sign in again when it is back.")
364 }
365 };
366
367 rsx! {
368 div {
369 section { class: "login-shell",
370 h1 { "Sign in to Krabka Admin" }
371 p { "Authentication is required before broker operations are shown." }
372 if let Some(message) = message {
373 p { class: "login-message", "{message}" }
374 } else {
375 form { method: "post", action: "/login",
376 label {
377 "Username "
378 input { name: "username", autocomplete: "username" }
379 }
380 label {
381 "Password "
382 input { name: "password", r#type: "password", autocomplete: "current-password" }
383 }
384 button { r#type: "submit", "Sign in" }
385 }
386 }
387 }
388 }
389 }
390}
391
392fn render_operations_shell_element(page: RoutePage, operator: &OperatorView) -> Element {
393 let forms = mutation_forms(&page, operator);
394 let links = sidebar_links_for(operator.capabilities());
395 let sign_out_token = operator.csrf_token().map(str::to_string);
396 let content = match page {
397 RoutePage::Overview { message } => render_overview_element(message),
398 RoutePage::Topics { state } => render_topics_element(state),
399 RoutePage::Groups { state } => render_groups_element(state),
400 RoutePage::Acls { state } => render_acls_element(state),
401 RoutePage::Users { state } => render_users_element(state),
402 RoutePage::Quotas { state, entity } => render_quotas_element(state, entity),
403 RoutePage::LogDirs { state } => render_log_dirs_element(state),
404 RoutePage::Login { .. } => unreachable!("login pages render outside the operations shell"),
405 }?;
406
407 rsx! {
408 div {
409 section { class: "operations-shell",
410 aside {
411 h1 { "Krabka Operations" }
412 nav {
413 for link in links {
414 a { class: "nav-link", href: "{link.path}", "{link.label}" }
415 }
416 }
417 if let Some(token) = sign_out_token {
418 form { class: "sign-out-form", method: "post", action: "/logout",
419 input { r#type: "hidden", name: "{CSRF_FIELD_NAME}", value: "{token}" }
420 button { r#type: "submit", "Sign out" }
421 }
422 }
423 }
424 main {
425 {content}
426 for form in forms {
427 {form}
428 }
429 }
430 }
431 }
432 }
433}
434
435fn render_overview_element(message: Option<&'static str>) -> Element {
436 rsx! {
437 section {
438 h2 { "Cluster Overview" }
439 p { "Broker administration shell is ready." }
440 if let Some(message) = message {
441 p { "{message}" }
442 }
443 }
444 }
445}
446
447fn render_topics_element(state: ReadRouteState<crate::dto::TopicRow>) -> Element {
448 match state {
449 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
450 section { class: "admin-section topics-section",
451 h2 { "Topics" }
452 ul {
453 for row in rows {
454 li { "{row.name}" }
455 }
456 }
457 }
458 },
459 state => rsx! {
460 section { class: "admin-section topics-section",
461 h2 { "Topics" }
462 p { "{topics_message(&state)}" }
463 }
464 },
465 }
466}
467
468fn topics_message(state: &ReadRouteState<crate::dto::TopicRow>) -> &'static str {
469 match state {
470 ReadRouteState::Loading => "Loading topics…",
471 ReadRouteState::AuthenticationRequired => "Authentication required.",
472 ReadRouteState::NotPermitted => "Your ACLs do not permit topic access.",
473 ReadRouteState::LoadFailed => "Unable to load topics.",
474 ReadRouteState::Rows(_) => "No topics loaded yet.",
475 }
476}
477
478fn render_groups_element(state: ReadRouteState<crate::dto::GroupRow>) -> Element {
479 match state {
480 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
481 section { class: "admin-section groups-section",
482 h2 { "Consumer Groups" }
483 ul {
484 for row in rows {
485 li { "{row.group_id}" }
486 }
487 }
488 }
489 },
490 state => rsx! {
491 section { class: "admin-section groups-section",
492 h2 { "Consumer Groups" }
493 p { "{groups_message(&state)}" }
494 }
495 },
496 }
497}
498
499fn groups_message(state: &ReadRouteState<crate::dto::GroupRow>) -> &'static str {
500 match state {
501 ReadRouteState::Loading => "Loading consumer groups…",
502 ReadRouteState::AuthenticationRequired => "Authentication required.",
503 ReadRouteState::NotPermitted => "Your ACLs do not permit consumer group access.",
504 ReadRouteState::LoadFailed => "Unable to load consumer groups.",
505 ReadRouteState::Rows(_) => "No consumer groups loaded yet.",
506 }
507}
508
509fn render_acls_element(state: ReadRouteState<crate::dto::AclRow>) -> Element {
510 match state {
511 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
512 section { class: "admin-section acls-section",
513 h2 { "ACLs" }
514 ul {
515 for row in rows {
516 li {
517 "{row.resource} {row.pattern_type} {row.principal} host={row.host} {row.operation} {row.permission}"
518 }
519 }
520 }
521 }
522 },
523 state => rsx! {
524 section { class: "admin-section acls-section",
525 h2 { "ACLs" }
526 p { "{acls_message(&state)}" }
527 }
528 },
529 }
530}
531
532fn acls_message(state: &ReadRouteState<crate::dto::AclRow>) -> &'static str {
533 match state {
534 ReadRouteState::Loading => "Loading ACLs…",
535 ReadRouteState::AuthenticationRequired => "Authentication required.",
536 ReadRouteState::NotPermitted => "Your ACLs do not permit ACL access.",
537 ReadRouteState::LoadFailed => "Unable to load ACLs.",
538 ReadRouteState::Rows(_) => "No ACLs loaded yet.",
539 }
540}
541
542fn render_users_element(state: ReadRouteState<crate::dto::UserRow>) -> Element {
543 match state {
544 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
545 section { class: "admin-section users-section",
546 h2 { "SCRAM Users" }
547 ul {
548 for row in rows {
549 li { "{row.username} {row.principal}" }
550 }
551 }
552 }
553 },
554 state => rsx! {
555 section { class: "admin-section users-section",
556 h2 { "SCRAM Users" }
557 p { "{users_message(&state)}" }
558 }
559 },
560 }
561}
562
563fn users_message(state: &ReadRouteState<crate::dto::UserRow>) -> &'static str {
564 match state {
565 ReadRouteState::Loading => "Loading SCRAM users…",
566 ReadRouteState::AuthenticationRequired => "Authentication required.",
567 ReadRouteState::NotPermitted => "Your ACLs do not permit SCRAM user access.",
568 ReadRouteState::LoadFailed => "Unable to load SCRAM users.",
569 ReadRouteState::Rows(_) => "No SCRAM users loaded yet.",
570 }
571}
572
573fn render_quotas_element(
574 state: ReadRouteState<crate::dto::QuotaRow>,
575 entity: Option<String>,
576) -> Element {
577 let lookup_value = entity.unwrap_or_default();
578
579 match state {
580 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
581 section { class: "admin-section quotas-section",
582 h2 { "Quotas" }
583 {quota_lookup_form(&lookup_value)}
584 ul {
585 for row in rows {
586 li { "{row.entity} {row.quota_type} {row.value}" }
587 }
588 }
589 }
590 },
591 state => rsx! {
592 section { class: "admin-section quotas-section",
593 h2 { "Quotas" }
594 {quota_lookup_form(&lookup_value)}
595 p { "{quotas_message(&state)}" }
596 }
597 },
598 }
599}
600
601fn quota_lookup_form(entity: &str) -> Element {
605 rsx! {
606 form { class: "quota-lookup-form", method: "get", action: "/quotas",
607 label {
608 "User "
609 input { r#type: "text", name: "entity", value: "{entity}" }
610 }
611 button { r#type: "submit", "Show quotas" }
612 }
613 }
614}
615
616fn quotas_message(state: &ReadRouteState<crate::dto::QuotaRow>) -> &'static str {
617 match state {
618 ReadRouteState::Loading => "Loading quotas…",
619 ReadRouteState::AuthenticationRequired => "Authentication required.",
620 ReadRouteState::NotPermitted => "Your ACLs do not permit quota access.",
621 ReadRouteState::LoadFailed => "Unable to load quotas.",
622 ReadRouteState::Rows(_) => "No quotas loaded yet.",
623 }
624}
625
626fn render_log_dirs_element(state: ReadRouteState<LogDirRow>) -> Element {
627 match state {
628 ReadRouteState::Rows(rows) if !rows.is_empty() => rsx! {
629 section { class: "admin-section log-dirs-section",
630 h2 { "Log Dirs" }
631 ul {
632 for row in rows {
633 li { "{log_dir_row_text(&row)}" }
634 }
635 }
636 }
637 },
638 state => rsx! {
639 section { class: "admin-section log-dirs-section",
640 h2 { "Log Dirs" }
641 p { "{log_dirs_message(&state)}" }
642 }
643 },
644 }
645}
646
647fn log_dir_row_text(row: &LogDirRow) -> String {
653 let location = if row.partition < 0 {
654 format!("broker {} {}", row.broker_id, row.log_dir)
655 .trim_end()
656 .to_owned()
657 } else {
658 format!(
659 "broker {} {} {}/{}-{}",
660 row.broker_id, row.log_dir, row.topic, row.partition, row.partition_size
661 )
662 };
663
664 let Some(error) = &row.error else {
665 return location;
666 };
667
668 match &error.message {
669 Some(message) => format!(
670 "{location} error={} ({}): {message}",
671 error.name, error.code
672 ),
673 None => format!("{location} error={} ({})", error.name, error.code),
674 }
675}
676
677fn log_dirs_message(state: &ReadRouteState<LogDirRow>) -> &'static str {
678 match state {
679 ReadRouteState::Loading => "Loading log-dir data…",
680 ReadRouteState::AuthenticationRequired => "Authentication required.",
681 ReadRouteState::NotPermitted => "Your ACLs do not permit log-dir access.",
682 ReadRouteState::LoadFailed => "Unable to load log-dir data.",
683 ReadRouteState::Rows(_) => "No log-dir data loaded yet.",
684 }
685}