Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0953488
Fix/user menu logout and sso login translations
mateodurante Aug 18, 2026
b1ae284
Feature/search inputs clear button
mateodurante Aug 18, 2026
a4f0b6e
Style/search clear button inside input
mateodurante Aug 20, 2026
87297b2
Style/keep search input focus border visible
mateodurante Aug 20, 2026
d26d04f
Revert "Style/keep search input focus border visible"
mateodurante Aug 20, 2026
8a1a77f
Style/focus ring around search input and button
mateodurante Aug 20, 2026
0e0d323
Revert "Style/focus ring around search input and button"
mateodurante Aug 20, 2026
b1dccba
Style/merge search input and button into one group with shared focus
mateodurante Aug 20, 2026
91e6c3c
Style/focus ring on search input only
mateodurante Aug 20, 2026
49d7ff3
Fix/avoid unnecessary search when clearing unsubmitted text
mateodurante Aug 20, 2026
c1b6be5
Feature/apply search on input blur without double submit
mateodurante Aug 20, 2026
c018759
Feature/filter toolbar on all search list views
mateodurante Aug 20, 2026
7356984
Style/keep list header controls on a single line
mateodurante Aug 20, 2026
11bc3c1
Refactor/unify list view headers with ListViewHeader component
mateodurante Aug 20, 2026
4a68fc9
Style/unify list views card wrapper and full width
mateodurante Aug 20, 2026
5484451
Fix/remove col-sm-auto card wrappers per template docs structure
mateodurante Aug 20, 2026
3506f7f
Style/apply official Row Col Card structure to all admin pages
mateodurante Aug 20, 2026
3b734b5
Feature/login connection status indicator with polling
mateodurante Aug 20, 2026
fde5917
Fix/add connection status translations
mateodurante Aug 20, 2026
e89a2c2
Style/place connection indicator next to auth lock icon
mateodurante Aug 20, 2026
a7becd7
Feature/shared backend health monitoring with leader election
mateodurante Aug 20, 2026
2f684ab
Revert "Feature/shared backend health monitoring with leader election"
mateodurante Aug 20, 2026
d321c8d
Refactor/backend health check with tanstack query and leader election
mateodurante Aug 20, 2026
06cd513
Style/red lightning inside login button and logo reload on reconnect
mateodurante Aug 20, 2026
aae28d7
Style/tooltip only when disconnected
mateodurante Aug 20, 2026
a53e8e0
Style/login validation via is-invalid inputs and button tooltip
mateodurante Aug 20, 2026
d4a9d66
Style/remove login button tooltip and persist invalid border in dark …
mateodurante Aug 21, 2026
deb8b78
Style/inline validation messages under login inputs and tighter spacing
mateodurante Aug 21, 2026
c40223c
copilot reviews
mateodurante Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"filter.cidr_domain": "cidr or domain",
"info.related": "Related information",
"login.do_not_have_an_account": "Don't have an account?",
"login.or": "or",
"login.sso": "Login with SSO",
"menu.about": "About",
"menu.analyzermappings": "Analyzer Mappings",
"menu.cases": "Cases",
Expand Down Expand Up @@ -131,6 +133,8 @@
"ngen.color": "Color",
"ngen.color.validate": "Enter a color",
"ngen.comments": "Comments",
"ngen.connection.connected": "Connected to server",
"ngen.connection.disconnected": "No connection to server",
"ngen.contact.create": "Create contact",
"ngen.contact.detail": "Contact detail",
"ngen.contact.placeholder": "Contact type has not been selected",
Expand Down Expand Up @@ -536,6 +540,7 @@
"search.by.name.description": "by name or description",
"search.by.name.user.email": "by name, username or email",
"search.bycode": "by code",
"search.clear": "Clear search",
"search.taxonomy_feed_affectedresource": "by taxonomy, feed or affected resource",
"selectOption": "Select an option",
"session.last": "Last login",
Expand Down
5 changes: 5 additions & 0 deletions frontend/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"filter.cidr_domain": "CIDR o dominio",
"info.related": "Información relacionada",
"login.do_not_have_an_account": "¿No tienes una cuenta?",
"login.or": "o",
"login.sso": "Ingresar con SSO",
"menu.about": "Acerca de",
"menu.analyzermappings": "Mapeo de Analizadores",
"menu.cases": "Casos",
Expand Down Expand Up @@ -131,6 +133,8 @@
"ngen.color": "Color",
"ngen.color.validate": "Introducir un color",
"ngen.comments": "Comentarios",
"ngen.connection.connected": "Conectado al servidor",
"ngen.connection.disconnected": "Sin conexión con el servidor",
"ngen.contact.create": "Crear contacto",
"ngen.contact.detail": "Detalle del contacto",
"ngen.contact.placeholder": "El tipo de contacto no ha sido seleccionado",
Expand Down Expand Up @@ -536,6 +540,7 @@
"search.by.name.description": "por nombre o descripción",
"search.by.name.user.email": "por nombre, nombre de usuario o correo electrónico",
"search.bycode": "por código",
"search.clear": "Borrar búsqueda",
"search.taxonomy_feed_affectedresource": "por taxonomía, feed o recurso afectado",
"selectOption": "Seleccione una opción",
"session.last": "Último inicio de sesión",
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/api/services/backendHealth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const CHANNEL_NAME = "ngen_health";
const LOCK_NAME = "ngen_health_leader";
const STATUS_KEY = "ngen_health_status";

let channel = null;
try {
channel = new BroadcastChannel(CHANNEL_NAME);
} catch (e) {
channel = null;
}

export const readCachedStatus = () => localStorage.getItem(STATUS_KEY);

export const writeCachedStatus = (status) => {
localStorage.setItem(STATUS_KEY, status);
};

export const publishHealthStatus = (status) => {
try {
channel && channel.postMessage({ status });
} catch (e) {
// ignore
}
};

export const subscribeHealthStatus = (callback) => {
if (channel) {
channel.onmessage = (e) => {
if (e.data?.status) callback(e.data.status);
};
}
return () => {};
};

export const subscribeLeadership = (callback) => {
if (!navigator.locks || !navigator.locks.request) {
callback(true);
return () => {};
}

let cancelled = false;
let release = null;

navigator.locks.request(LOCK_NAME, async () => {
if (cancelled) return;
callback(true);
return new Promise((resolve) => {
release = resolve;
});
});

return () => {
cancelled = true;
release && release();
};
};
9 changes: 8 additions & 1 deletion frontend/src/api/services/loadEnv.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,12 @@ export const loadEnv = () => {

const normalizeApiUrl = (host, port, path) => {
let portValue = port || window.location.port;
return `${window.location.protocol}//${host || window.location.hostname}${portValue ? ":" + portValue : ""}${path || "/api/"}`;
let normalizedPath = path || "/api/";
if (!normalizedPath.startsWith("/")) {
normalizedPath = "/" + normalizedPath;
}
if (!normalizedPath.endsWith("/")) {
normalizedPath += "/";
}
return `${window.location.protocol}//${host || window.location.hostname}${portValue ? ":" + portValue : ""}${normalizedPath}`;
};
70 changes: 70 additions & 0 deletions frontend/src/assets/scss/partials/_custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,73 @@
margin: 2px 0;
}
}

.search-input-group {
position: relative;
display: flex;
align-items: stretch;
width: 100%;

.search-field {
position: relative;
flex: 1 1 auto;
width: 1%;
min-width: 0;

.form-control {
height: 100%;
padding-right: 2.25rem;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

.search-clear-btn {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
z-index: 5;
padding: 0;
border: none;
background: transparent;
color: var(--bs-secondary-color);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;

&:hover {
color: var(--bs-body-color);
}

&:focus-visible {
outline: 2px solid var(--bs-primary, #04a9f5);
outline-offset: 2px;
}
}
}

> .search-btn {
margin: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
}
}

[data-bs-theme="dark"] .form-control.is-invalid,
[data-bs-theme="dark"] .form-control.is-invalid:focus {
border-color: var(--bs-form-invalid-border-color);
}

.login-feedback {
min-height: 1.75em;
margin-top: 0.25rem;
font-size: 0.875em;
color: var(--bs-form-invalid-color);
text-align: left;
padding: 0 5px;
}

.auth-wrapper .mb-3 {
margin-bottom: 0.5rem !important;
}
2 changes: 1 addition & 1 deletion frontend/src/components/Button/FilterToolbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const FilterToolbar = ({ open, setOpen, onReload, onClearFilters }) => {

return (
<div className="d-flex gap-1">
<ButtonFilter open={open} setOpen={setOpen} />
{setOpen && <ButtonFilter open={open} setOpen={setOpen} />}
{onClearFilters && (
<Button size="lm" variant="outline-secondary" onClick={onClearFilters}>
{t("button.clear_filters")}
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/ListViewHeader/ListViewHeader.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react";
import { Col, Row } from "react-bootstrap";
import Search from "../Search/Search";
import FilterToolbar from "../Button/FilterToolbar";

const ListViewHeader = ({
open,
setOpen,
onReload,
onClearFilters,
searchType,
wordToSearch,
setWordToSearch,
setLoading,
setCurrentPage,
children
}) => {
return (
<Row>
<Col sm="auto">
<FilterToolbar open={open} setOpen={setOpen} onReload={onReload} onClearFilters={onClearFilters} />
</Col>
<Col>
<Search
type={searchType}
setWordToSearch={setWordToSearch}
wordToSearch={wordToSearch}
setLoading={setLoading}
setCurrentPage={setCurrentPage}
/>
</Col>
<Col sm="auto" className="d-flex gap-1">
{children}
</Col>
</Row>
);
};

export default ListViewHeader;
61 changes: 57 additions & 4 deletions frontend/src/components/Search/Search.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";

const Search = ({ type, setWordToSearch, wordToSearch, setLoading, setCurrentPage }) => {
const [search, setSearch] = useState("");
const skipBlurApplyRef = useRef(false);
const { t } = useTranslation();

useEffect(() => {
Expand Down Expand Up @@ -36,12 +37,64 @@ const Search = ({ type, setWordToSearch, wordToSearch, setLoading, setCurrentPag
const handleSubmit = (e) => {
e.preventDefault(); // Evita que se envíe el formulario (recarga la página)
action();
skipBlurApplyRef.current = false;
};

const clearSearch = () => {
skipBlurApplyRef.current = false;
setSearch("");
if (wordToSearch) {
setWordToSearch("");
if (setCurrentPage) {
setCurrentPage(1);
}
setLoading(true);
}
};

const handleButtonPointerDown = () => {
skipBlurApplyRef.current = true;
};

const handleInputBlur = () => {
if (skipBlurApplyRef.current) {
skipBlurApplyRef.current = false;
return;
}
const applied = wordToSearch.startsWith("search=")
? wordToSearch.replace(/^search=/, "").replace(/&$/, "")
: "";
if (search === applied) {
return;
}
action();
};

return (
<form onSubmit={handleSubmit} className="input-group">
<input value={search} onChange={searcher} type="text" id="m-search" className="form-control" placeholder={text} />
<button type="submit" className="search-btn btn btn-primary">
<form onSubmit={handleSubmit} className="search-input-group">
<div className="search-field">
<input
value={search}
onChange={searcher}
onBlur={handleInputBlur}
type="text"
id="m-search"
className="form-control"
placeholder={text}
/>
{search && (
<button
type="button"
className="search-clear-btn"
onClick={clearSearch}
onPointerDown={handleButtonPointerDown}
aria-label={t("search.clear")}
>
<i className="feather icon-x" />
</button>
)}
</div>
<button type="submit" className="search-btn btn btn-primary" onPointerDown={handleButtonPointerDown}>
<i className="feather icon-search " />
</button>
</form>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/config/constant.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const CONFIG = {
};

export const COMPONENT_URL = {
health: "health/",
tlp: "administration/tlp/",
feed: "administration/feed/",
priority: "administration/priority/",
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/hooks/useBackendHealth.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { COMPONENT_URL } from "config/constant";
import {
publishHealthStatus,
readCachedStatus,
subscribeHealthStatus,
subscribeLeadership,
writeCachedStatus
} from "api/services/backendHealth";

const fetchHealth = async () => {
const apiServer = localStorage.getItem("API_SERVER");
if (!apiServer) {
throw new Error("API_SERVER is not set");
}
const res = await fetch(apiServer + COMPONENT_URL.health, { cache: "no-store" });
Comment thread
mateodurante marked this conversation as resolved.
if (!res.ok) {
throw new Error("Backend health check failed");
}
return res.json();
};

const useBackendHealth = () => {
const queryClient = useQueryClient();
const [isLeader, setIsLeader] = useState(false);

useEffect(() => subscribeLeadership(setIsLeader), []);

useEffect(
() =>
subscribeHealthStatus((status) => {
writeCachedStatus(status);
queryClient.setQueryData(["backend-health"], { status });
}),
[queryClient]
);

const query = useQuery({
queryKey: ["backend-health"],
queryFn: fetchHealth,
refetchInterval: isLeader ? 3000 : false,
refetchIntervalInBackground: true,
retry: false,
staleTime: Infinity,
initialData: () => {
const cached = readCachedStatus();
return cached ? { status: cached } : undefined;
}
});

useEffect(() => {
if (!isLeader) return;
const status = query.isError ? "down" : query.data?.status;
if (status) {
writeCachedStatus(status);
publishHealthStatus(status);
}
}, [isLeader, query.data, query.isError]);

return !query.isError && query.data?.status === "ok";
};

export default useBackendHealth;
Loading
Loading