Skip to main content

krabka_admin_ui/views/
mod.rs

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    /// Whether the operator's ACLs permit the page the route names.
73    #[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
92/// # Errors
93/// Returns an error when the request is invalid, authentication or session validation fails, or the broker admin operation reports a failure.
94pub fn render_route(route: Route) -> Element {
95    render_page_element(&RoutePage::for_unauthenticated_route(route))
96}
97
98/// # Errors
99/// Returns an error when the request is invalid, authentication or session validation fails, or the broker admin operation reports a failure.
100pub fn render_page_element(page: &RoutePage) -> Element {
101    render_page_element_for_operator(page, &OperatorView::anonymous())
102}
103
104/// # Errors
105/// Returns an error when the request is invalid, authentication or session validation fails, or the broker admin operation reports a failure.
106pub 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/// One input of a rendered mutation form.
114#[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
160/// Renders one mutation as a plain HTML form.
161///
162/// The server ships no JavaScript, so a control that mutates the cluster has to
163/// be a form that the browser itself submits. The hidden token proves that the
164/// signed-in operator asked for the mutation; the server rejects a body without
165/// it.
166fn 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
190/// The forms a page shows, already filtered by what the operator may do.
191fn 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
601/// The form that asks for another user's quotas.
602///
603/// It is a `GET`, so it needs no token: it changes nothing.
604fn 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
647/// One log-dir line.
648///
649/// A directory the broker could not read carries no partition data, so the row
650/// says what went wrong instead of showing the empty topic and the `-1`
651/// partition that stand for "no data".
652fn 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}