@cryptotaxi247 / CoPilot / commits / dc85a138

Customers (#108)

* customers branch * added customers api * fixed customer deletion routes * fixed get_velociraptor_agents_healthcheck * check customer code exists in alertcreationsettings table * restructure alert creation * removed duplicate utils * added customers page * updated customers api * return data collected for customer full even if meta doesnt exist * updated customers page * log purger await * log purger timestamp await * remove celery * updated customers card * updated notifications * improved notifications * added dockerfile.deb * added customer form * updated customer form * updated customer item * improved items modals style * updated agent card * added customers page ruote params * updated customer card component * updated customer card component * updated customer card component * updated customer card component * added customer healthcheck component * updated customer healthcheck component * updated customer healthcheck components * precommit fixes --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Dec 19, 2023 at 05:32 UTC dc85a138888422a1d095a2e52a71123ce7fc6d95
59 files changed +2256 -799
Dockerfile.deb new
+47
@@ -0,0 +1,47 @@
1 +# build with `docker build -t python-backend -f Dockerfile.deb .`
2 +# run with `docker run -p 5000:5000 -d python-backend`
3 +# Start with the base Debian 11 image
4 +FROM debian:11
5 +
6 +# Set environment variables
7 +ENV PYTHONDONTWRITEBYTECODE 1
8 +ENV PYTHONUNBUFFERED 1
9 +
10 +# Update the package lists
11 +RUN apt-get update
12 +
13 +# Install necessary dependencies
14 +RUN apt-get install -y \
15 + apt-transport-https \
16 + ca-certificates \
17 + curl \
18 + gnupg \
19 + lsb-release
20 +
21 +# Add the deadsnakes PPA
22 +RUN echo "deb http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal main" | tee /etc/apt/sources.list.d/focal.list
23 +RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F23C5A6CF475977595C89F51BA6932366A755776
24 +
25 +# Update the package lists
26 +RUN apt-get update
27 +
28 +# Install Python 3.11, pip and venv
29 +RUN apt-get install -y python3.11 python3-pip python3.11-venv
30 +
31 +# Create a Python virtual environment and activate it
32 +RUN python3.11 -m venv /opt/venv
33 +ENV PATH="/opt/venv/bin:$PATH"
34 +
35 +# Install setuptools
36 +RUN /opt/venv/bin/pip install setuptools
37 +
38 +# Install your application's dependencies
39 +WORKDIR /app
40 +COPY backend/requirements.in ./
41 +RUN /opt/venv/bin/pip install --no-cache-dir -r requirements.in
42 +
43 +# Copy your application into the Docker image
44 +COPY backend/ ./
45 +
46 +# Run your application
47 +CMD ["uvicorn", "copilot:app", "--host", "0.0.0.0", "--port", "5000", "--log-level", "debug"]
backend/app/agents/routes/agents.py
-2
@@ -1,5 +1,3 @@
1 -import requests
2 -from celery.result import AsyncResult
1 from fastapi import APIRouter
2 from fastapi import BackgroundTasks
3 from fastapi import Depends
backend/app/connectors/schema.py
+1
@@ -36,6 +36,7 @@ class ConnectorResponse(BaseModel):
36
37 class Config:
38 orm_mode = True
39 + from_attributes = True
40
41
42 class ConnectorsListResponse(BaseModel):
backend/app/customer_provisioning/routes/provision.py
+3 -4
@@ -19,9 +19,6 @@ from app.customer_provisioning.services.provision import provision_wazuh_custome
19 from app.db.db_session import get_session
20 from app.db.universal_models import Customers
21
22 -# App specific imports
23 -
24 -
22 customer_provisioning_router = APIRouter()
23
24
@@ -90,5 +87,7 @@ async def get_subscriptions_route():
87 logger.info("Getting list of subscriptions")
88 available_subscriptions = get_available_subscriptions()
89 return GetSubscriptionsResponse(
93 - available_subscriptions=available_subscriptions, success=True, message="Subscriptions retrieved successfully",
90 + available_subscriptions=available_subscriptions,
91 + success=True,
92 + message="Subscriptions retrieved successfully",
93 )
backend/app/customers/routes/customers.py
+13 -4
@@ -154,9 +154,11 @@ async def delete_customer(customer_code: str, session: AsyncSession = Depends(ge
154 customer_data = CustomerRequestBody.from_orm(existing_customer)
155
156 # Delete the customer
157 - session.delete(existing_customer)
157 + await session.delete(existing_customer)
158 await session.flush() # Optional: Flush the changes to the database
159 await session.commit() # Commit the transaction
160 + # Close the session
161 + await session.close()
162
163 return CustomerResponse(
164 customer=customer_data,
@@ -271,8 +273,11 @@ async def delete_customer_meta(customer_code: str, session: AsyncSession = Depen
273 # Store customer meta data for response before deleting
274 customer_meta_data = CustomerMetaRequestBody.from_orm(existing_customer_meta)
275
274 - session.delete(existing_customer_meta)
276 + await session.delete(existing_customer_meta)
277 + await session.flush() # Optional: Flush the changes to the database
278 await session.commit() # Ensure to await commit
279 + # Close the session
280 + await session.close()
281
282 return CustomerMetaResponse(
283 customer_meta=customer_meta_data,
@@ -298,7 +303,11 @@ async def get_customer_full(customer_code: str, session: AsyncSession = Depends(
303 customer_meta_result = await session.execute(select(CustomersMeta).filter(CustomersMeta.customer_code == customer_code))
304 customer_meta = customer_meta_result.scalars().first()
305 if not customer_meta:
301 - raise HTTPException(status_code=404, detail=f"Customer meta with customer_code {customer_code} not found")
306 + return CustomerFullResponse(
307 + customer=CustomerRequestBody.from_orm(customer),
308 + success=True,
309 + message="Customer fetched successfully but customer meta not found",
310 + )
311
312 return CustomerFullResponse(
313 customer=CustomerRequestBody.from_orm(customer),
@@ -390,4 +399,4 @@ async def get_velociraptor_agents_healthcheck(
399 agents = agents_result.scalars().all()
400 agents = [AgentModel.parse_obj(agent.__dict__) for agent in agents]
401 time_criteria = TimeCriteriaModel(minutes=minutes, hours=hours, days=days)
393 - return velociraptor_agents_healthcheck(agents, time_criteria)
402 + return await velociraptor_agents_healthcheck(agents, time_criteria)
backend/app/healthchecks/agents/services/agents.py
+1
@@ -95,6 +95,7 @@ async def wazuh_agent_healthcheck(agent: AgentModel, time_criteria: TimeCriteria
95 async def velociraptor_agents_healthcheck(agents: list, time_criteria: TimeCriteriaModel) -> AgentHealthCheckResponse:
96 healthy_velociraptor_agents = []
97 unhealthy_velociraptor_agents = []
98 +
99 for agent in agents:
100 # If agent_id is `000` skip it because this is the Wazuh manager
101 if agent.agent_id == "000":
backend/app/integrations/alert_creation/general/routes/alert.py
+18
@@ -28,6 +28,20 @@ async def is_rule_id_valid(create_alert_request: CreateAlertRequest, session: As
28 return True
29
30
31 +async def is_customer_code_valid(create_alert_request: CreateAlertRequest, session: AsyncSession) -> bool:
32 + logger.info(f"Checking if customer_code: {create_alert_request.agent_labels_customer} is valid.")
33 +
34 + result = await session.execute(
35 + select(AlertCreationSettings).where(AlertCreationSettings.customer_code == create_alert_request.agent_labels_customer),
36 + )
37 + settings = result.scalars().first()
38 +
39 + if settings:
40 + return True
41 +
42 + return False
43 +
44 +
45 @general_alerts_router.post(
46 "/general",
47 response_model=CreateAlertResponse,
@@ -39,6 +53,10 @@ async def create_general_alert(
53 ):
54 logger.info(f"create_alert_request: {create_alert_request.dict()}")
55
56 + if await is_customer_code_valid(create_alert_request, session) is False:
57 + logger.info(f"Invalid customer_code: {create_alert_request.agent_labels_customer}")
58 + raise HTTPException(status_code=200, detail="Invalid customer_code.")
59 +
60 if await is_rule_id_valid(create_alert_request, session) is False:
61 logger.info(f"Invalid rule_id: {create_alert_request.rule_id}")
62 raise HTTPException(status_code=200, detail="Invalid rule_id.")
backend/app/integrations/alert_creation/general/services/alert.py
+10 -9
@@ -16,10 +16,10 @@ from app.integrations.alert_creation.general.schema.alert import IrisAlertPayloa
16 from app.integrations.alert_creation.general.schema.alert import IrisAsset
17 from app.integrations.alert_creation.general.schema.alert import IrisIoc
18 from app.integrations.alert_creation.general.schema.alert import ValidIocFields
19 -from app.integrations.alert_creation.utils.schema import ShufflePayload
20 -from app.integrations.alert_creation.utils.universal import get_asset_type_id
21 -from app.integrations.alert_creation.utils.universal import send_to_shuffle
22 -from app.integrations.alert_creation.utils.universal import validate_ioc_type
19 +from app.integrations.utils.alerts import get_asset_type_id
20 +from app.integrations.utils.alerts import send_to_shuffle
21 +from app.integrations.utils.alerts import validate_ioc_type
22 +from app.integrations.utils.schema import ShufflePayload
23 from app.utils import get_customer_alert_settings
24
25
@@ -90,6 +90,7 @@ async def build_asset_payload(agent_data: AgentsResponse, alert_details) -> Iris
90
91 async def build_alert_context_payload(
92 alert_details: CreateAlertRequest,
93 + agent_data: AgentsResponse,
94 session: AsyncSession,
95 ) -> IrisAlertContext:
96 return IrisAlertContext(
@@ -106,7 +107,7 @@ async def build_alert_context_payload(
107 rule_id=alert_details.rule_id,
108 asset_name=alert_details.agent_name,
109 asset_ip=alert_details.agent_ip,
109 - asset_type=alert_details.asset_type_id,
110 + asset_type=await get_asset_type_id(agent_data.agents[0].os),
111 process_id=getattr(alert_details, "process_id", "No process id found"),
112 rule_mitre_id=getattr(alert_details, "rule_mitre_id", "No rule mitre id found"),
113 rule_mitre_tactic=getattr(
@@ -129,7 +130,7 @@ async def build_alert_payload(
130 session: AsyncSession,
131 ) -> IrisAlertPayload:
132 asset_payload = await build_asset_payload(agent_data, alert_details)
132 - context_payload = await build_alert_context_payload(alert_details, session=session)
133 + context_payload = await build_alert_context_payload(alert_details=alert_details, agent_data=agent_data, session=session)
134 timefield = (await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)).timefield
135 # Get the timefield value from the alert_details
136 if hasattr(alert_details, timefield):
@@ -141,7 +142,7 @@ async def build_alert_payload(
142 alert_title=alert_details.rule_description,
143 alert_source_link=await construct_alert_source_link(alert_details, session=session),
144 alert_description=alert_details.rule_description,
144 - alert_source="SOCFORTRESS RULE",
145 + alert_source="CoPilot",
146 assets=[asset_payload],
147 alert_status_id=3,
148 alert_severity_id=5,
@@ -157,7 +158,7 @@ async def build_alert_payload(
158 logger.info("Alert does not have IoC")
159 return IrisAlertPayload(
160 alert_title=alert_details.rule_description,
160 - alert_source_link=construct_alert_source_link(alert_details),
161 + alert_source_link=await construct_alert_source_link(alert_details, session=session),
162 alert_description=alert_details.rule_description,
163 alert_source="SOCFORTRESS RULE",
164 assets=[asset_payload],
@@ -204,7 +205,7 @@ async def create_alert(alert: CreateAlertRequest, session: AsyncSession) -> Crea
205 )
206 alert_id = result["data"]["alert_id"]
207 logger.info(f"Successfully created alert {alert_id} in IRIS.")
207 - send_to_shuffle(
208 + await send_to_shuffle(
209 ShufflePayload(
210 alert_id=alert_id,
211 customer=(await get_customer_alert_settings(customer_code=alert.agent_labels_customer, session=session)).customer_name,
backend/app/integrations/alert_escalation/schema/general_alert.py
+5
@@ -36,6 +36,7 @@ class GenericSourceModel(BaseModel):
36 rule_description: str = Field(..., description="The description of the rule.")
37 timestamp: str = Field(..., description="The timestamp of the alert.")
38 timestamp_utc: Optional[str] = Field(..., description="The UTC timestamp of the alert.")
39 + process_id: Optional[str] = Field(None, description="The process id of the alert.")
40
41 class Config:
42 extra = Extra.allow
@@ -52,6 +53,10 @@ class GenericAlertModel(BaseModel):
53 )
54 ioc_value: Optional[str] = Field(None, description="The IoC value of the alert which is needed for when we add the IoC to IRIS.")
55 ioc_type: Optional[str] = Field(None, description="The IoC type of the alert which is needed for when we add the IoC to IRIS.")
56 + time_field: Optional[str] = Field(
57 + "timestamp",
58 + description="The timefield of the alert to be used when creating the IRIS alert.",
59 + )
60
61 class Config:
62 extra = Extra.allow
backend/app/integrations/alert_escalation/services/general_alert.py
+145 -50
@@ -4,11 +4,16 @@ from typing import Set
4 from fastapi import HTTPException
5 from loguru import logger
6 from sqlalchemy.ext.asyncio import AsyncSession
7 +from sqlalchemy.future import select
8
9 +# from app.integrations.alert_escalation.utils.universal import get_agent_data
10 +from app.agents.routes.agents import get_agent
11 +from app.agents.schema.agents import AgentsResponse
12 from app.connectors.dfir_iris.utils.universal import fetch_and_validate_data
13 from app.connectors.dfir_iris.utils.universal import initialize_client_and_alert
14 from app.connectors.utils import get_connector_info_from_db
15 from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
16 +from app.integrations.alert_creation.models.alert_settings import AlertCreationSettings
17 from app.integrations.alert_escalation.schema.general_alert import CreateAlertRequest
18 from app.integrations.alert_escalation.schema.general_alert import CreateAlertResponse
19 from app.integrations.alert_escalation.schema.general_alert import GenericAlertModel
@@ -18,9 +23,23 @@ from app.integrations.alert_escalation.schema.general_alert import IrisAlertPayl
23 from app.integrations.alert_escalation.schema.general_alert import IrisAsset
24 from app.integrations.alert_escalation.schema.general_alert import IrisIoc
25 from app.integrations.alert_escalation.schema.general_alert import ValidIocFields
21 -from app.integrations.alert_escalation.utils.universal import get_agent_data
22 -from app.integrations.alert_escalation.utils.universal import get_asset_type_id
23 -from app.integrations.alert_escalation.utils.universal import validate_ioc_type
26 +from app.integrations.utils.alerts import get_asset_type_id
27 +from app.integrations.utils.alerts import validate_ioc_type
28 +from app.utils import get_customer_alert_settings
29 +
30 +
31 +async def is_customer_code_valid(customer_code: str, session: AsyncSession) -> bool:
32 + logger.info(f"Checking if customer_code: {customer_code} is valid.")
33 +
34 + result = await session.execute(
35 + select(AlertCreationSettings).where(AlertCreationSettings.customer_code == customer_code),
36 + )
37 + settings = result.scalars().first()
38 +
39 + if settings:
40 + return True
41 +
42 + return False
43
44
45 def valid_ioc_fields() -> Set[str]:
@@ -34,6 +53,37 @@ def valid_ioc_fields() -> Set[str]:
53 return {field.value for field in ValidIocFields}
54
55
56 +async def construct_alert_source_link(alert_details: GenericAlertModel, session: AsyncSession) -> str:
57 + """
58 + Construct the alert source link for the alert details.
59 + Parameters
60 + ----------
61 + alert_details: CreateAlertRequest
62 + The alert details.
63 + Returns
64 + -------
65 + str
66 + The alert source link.
67 + """
68 + # Check if the alert has a process id and that it is not "No process ID found"
69 + if hasattr(alert_details, "process_id") and alert_details._source.process_id != "No process ID found":
70 + query_string = f"%22query%22:%22process_id:%5C%22{alert_details._source.process_id}%5C%22%20AND%20"
71 + else:
72 + query_string = f"%22query%22:%22_id:%5C%22{alert_details._id}%5C%22%20AND%20"
73 +
74 + grafana_url = (
75 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
76 + ).grafana_url
77 +
78 + return (
79 + f"{grafana_url}/explore?left=%5B%22now-6h%22,%22now%22,%22WAZUH%22,%7B%22refId%22:%22A%22,"
80 + f"{query_string}"
81 + f"agent_name:%5C%22{alert_details._source.agent_name}%5C%22%22,"
82 + "%22alias%22:%22%22,%22metrics%22:%5B%7B%22id%22:%221%22,%22type%22:%22logs%22,%22settings%22:%7B%22limit%22:%22500%22%7D%7D%5D,"
83 + "%22bucketAggs%22:%5B%5D,%22timeField%22:%22timestamp%22%7D%5D"
84 + )
85 +
86 +
87 async def get_single_alert_details(alert_details: CreateAlertRequest) -> GenericAlertModel:
88 logger.info(f"Fetching alert details for alert {alert_details.alert_id} in index {alert_details.index_name}")
89 es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
@@ -46,33 +96,48 @@ async def get_single_alert_details(alert_details: CreateAlertRequest) -> Generic
96 raise HTTPException(status_code=400, detail=f"Failed to collect alert details: {e}")
97
98
49 -def build_ioc_payload(alert_details: GenericAlertModel) -> Optional[IrisIoc]:
99 +async def build_ioc_payload(alert_details: GenericAlertModel) -> Optional[IrisIoc]:
100 for field in valid_ioc_fields():
101 if hasattr(alert_details._source, field):
102 ioc_value = getattr(alert_details._source, field)
53 - ioc_type = validate_ioc_type(ioc_value=ioc_value)
103 + ioc_type = await validate_ioc_type(ioc_value=ioc_value)
104 return IrisIoc(ioc_value=ioc_value, ioc_description="IoC found in alert", ioc_tlp_id=1, ioc_type_id=ioc_type)
105 return None
106
107
58 -def build_asset_payload(agent_data, alert_details) -> IrisAsset:
59 - return IrisAsset(
60 - asset_name=agent_data.hostname,
61 - asset_ip=agent_data.ip_address,
62 - asset_description=agent_data.os,
63 - asset_type_id=alert_details.asset_type_id,
64 - )
108 +async def build_asset_payload(agent_data: AgentsResponse, alert_details) -> IrisAsset:
109 + if agent_data.success:
110 + return IrisAsset(
111 + asset_name=agent_data.agents[0].hostname,
112 + asset_ip=agent_data.agents[0].ip_address,
113 + asset_description=agent_data.agents[0].os,
114 + asset_type_id=await get_asset_type_id(agent_data.agents[0].os),
115 + )
116 + return IrisAsset()
117
118
67 -def build_alert_context_payload(alert_details: GenericAlertModel, agent_data) -> IrisAlertContext:
119 +async def build_alert_context_payload(
120 + alert_details: GenericAlertModel,
121 + agent_data: AgentsResponse,
122 + session: AsyncSession,
123 +) -> IrisAlertContext:
124 return IrisAlertContext(
125 + customer_iris_id=(
126 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
127 + ).iris_customer_id,
128 + customer_name=(
129 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
130 + ).customer_name,
131 + customer_cases_index=(
132 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
133 + ).iris_index,
134 alert_id=alert_details._id,
135 alert_name=alert_details._source.rule_description,
136 alert_level=alert_details._source.rule_level,
137 rule_id=alert_details._source.rule_id,
73 - asset_name=agent_data.hostname,
74 - asset_ip=agent_data.ip_address,
75 - asset_type=alert_details.asset_type_id,
138 + asset_name=agent_data.agents[0].hostname,
139 + asset_ip=agent_data.agents[0].ip_address,
140 + asset_type=await get_asset_type_id(agent_data.agents[0].os),
141 process_id=getattr(alert_details._source, "process_id", "No process id found"),
142 rule_mitre_id=getattr(alert_details._source, "rule_mitre_id", "No rule mitre id found"),
143 rule_mitre_tactic=getattr(alert_details._source, "rule_mitre_tactic", "No rule mitre tactic found"),
@@ -80,36 +145,58 @@ def build_alert_context_payload(alert_details: GenericAlertModel, agent_data) ->
145 )
146
147
83 -def build_alert_payload(alert_details: GenericAlertModel, agent_data, ioc_payload: Optional[IrisIoc]) -> IrisAlertPayload:
84 - asset_payload = build_asset_payload(agent_data, alert_details)
85 - context_payload = build_alert_context_payload(alert_details, agent_data)
86 - if ioc_payload:
87 - logger.info(f"Alert has IoC: {ioc_payload}")
88 - return IrisAlertPayload(
89 - alert_title=alert_details._source.rule_description,
90 - alert_description=alert_details._source.rule_description,
91 - alert_source="CoPilot",
92 - assets=[asset_payload],
93 - alert_status_id=3,
94 - alert_severity_id=5,
95 - alert_customer_id=1,
96 - alert_source_content=alert_details._source,
97 - alert_context=context_payload,
98 - alert_iocs=[ioc_payload],
99 - )
100 - else:
101 - logger.info("Alert does not have IoC")
102 - return IrisAlertPayload(
103 - alert_title=alert_details._source.rule_description,
104 - alert_description=alert_details._source.rule_description,
105 - alert_source="CoPilot",
106 - assets=[asset_payload],
107 - alert_status_id=3,
108 - alert_severity_id=5,
109 - alert_customer_id=1,
110 - alert_source_content=alert_details._source,
111 - alert_context=context_payload,
112 - )
148 +async def build_alert_payload(
149 + alert_details: GenericAlertModel,
150 + agent_data,
151 + ioc_payload: Optional[IrisIoc],
152 + session: AsyncSession,
153 +) -> IrisAlertPayload:
154 + asset_payload = await build_asset_payload(agent_data, alert_details)
155 + context_payload = await build_alert_context_payload(alert_details=alert_details, agent_data=agent_data, session=session)
156 + timefield = (await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)).timefield
157 + # Get the timefield value from the alert_details
158 + if hasattr(alert_details, timefield):
159 + alert_details.time_field = getattr(alert_details, timefield)
160 + logger.info(f"Alert has context: {context_payload}")
161 + try:
162 + if ioc_payload:
163 + logger.info(f"Alert has IoC: {ioc_payload}")
164 + return IrisAlertPayload(
165 + alert_title=alert_details._source.rule_description,
166 + alert_source_link=await construct_alert_source_link(alert_details, session=session),
167 + alert_description=alert_details._source.rule_description,
168 + alert_source="CoPilot",
169 + assets=[asset_payload],
170 + alert_status_id=3,
171 + alert_severity_id=5,
172 + alert_customer_id=(
173 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
174 + ).iris_customer_id,
175 + alert_source_content=alert_details._source,
176 + alert_context=context_payload,
177 + alert_iocs=[ioc_payload],
178 + alert_source_event_time=alert_details.time_field,
179 + )
180 + else:
181 + logger.info("Alert does not have IoC")
182 + return IrisAlertPayload(
183 + alert_title=alert_details._source.rule_description,
184 + alert_source_link=await construct_alert_source_link(alert_details, session=session),
185 + alert_description=alert_details._source.rule_description,
186 + alert_source="CoPilot",
187 + assets=[asset_payload],
188 + alert_status_id=3,
189 + alert_severity_id=5,
190 + alert_customer_id=(
191 + await get_customer_alert_settings(customer_code=alert_details._source.agent_labels_customer, session=session)
192 + ).iris_customer_id,
193 + alert_source_content=alert_details._source,
194 + alert_context=context_payload,
195 + alert_source_event_time=alert_details.time_field,
196 + )
197 + except Exception as e:
198 + logger.error(f"Failed to build alert payload: {e}")
199 + raise HTTPException(status_code=500, detail=f"Failed to build alert payload: {e}")
200
201
202 async def construct_soc_alert_url(root_url: str, soc_alert_id: int) -> str:
@@ -161,10 +248,18 @@ async def add_alert_to_document(es_client, alert: CreateAlertRequest, soc_alert_
248 async def create_alert(alert: CreateAlertRequest, session: AsyncSession) -> CreateAlertResponse:
249 logger.info(f"Creating alert {alert.alert_id} in IRIS")
250 alert_details = await get_single_alert_details(alert_details=alert)
164 - agent_data = await get_agent_data(session, agent_id=alert_details._source.agent_id)
165 - alert_details.asset_type_id = get_asset_type_id(os=agent_data.os)
166 - ioc_payload = build_ioc_payload(alert_details)
167 - iris_alert_payload = build_alert_payload(alert_details, agent_data, ioc_payload)
251 + logger.info(f"Alert details: {alert_details}")
252 + if await is_customer_code_valid(customer_code=alert_details._source.agent_labels_customer, session=session) is False:
253 + logger.info(f"Invalid customer_code: {alert_details._source.agent_labels_customer}")
254 + raise HTTPException(status_code=200, detail="Invalid customer_code, or the customer is not configured for alert creation.")
255 + agent_data = await get_agent(agent_id=alert_details._source.agent_id, db=session)
256 + ioc_payload = await build_ioc_payload(alert_details=alert_details)
257 + iris_alert_payload = await build_alert_payload(
258 + alert_details=alert_details,
259 + agent_data=agent_data,
260 + ioc_payload=ioc_payload,
261 + session=session,
262 + )
263 client, alert_client = await initialize_client_and_alert("DFIR-IRIS")
264 result = await fetch_and_validate_data(client, alert_client.add_alert, iris_alert_payload.to_dict())
265 es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
backend/app/integrations/alert_escalation/utils/universal.py deleted
-413
@@ -1,413 +0,0 @@
1 -import ipaddress
2 -import re
3 -from abc import ABC
4 -from typing import Any
5 -from typing import Dict
6 -from typing import Optional
7 -from typing import Union
8 -
9 -import regex
10 -from elasticsearch7 import Elasticsearch
11 -from fastapi import HTTPException
12 -from loguru import logger
13 -from sqlalchemy.ext.asyncio import AsyncSession
14 -from sqlalchemy.future import select
15 -
16 -from app.connectors.utils import get_connector_info_from_db
17 -from app.db.all_models import Agents
18 -from app.db.db_session import session
19 -from app.healthchecks.agents.schema.agents import AgentModel
20 -
21 -
22 -#################### ! DFIR IRIS ASSET VALIDATOR ! ####################
23 -class AssetValidator(ABC):
24 - """
25 - Base class for asset validators.
26 -
27 - Attributes:
28 - os (str): The OS to be validated.
29 - """
30 -
31 - ASSET_TYPE_ID: int = 1
32 -
33 - def __init__(self, os: str) -> None:
34 - """
35 - Initialize a Validator.
36 -
37 - Args:
38 - os (str): The OS to be validated.
39 - """
40 - self.os = os.lower()
41 -
42 - def validate(self) -> Dict[str, Union[bool, str, int]]:
43 - """
44 - Validate the OS.
45 -
46 - If the OS matches the type of this validator,
47 - the method returns a dictionary indicating success, the matching message, and the asset type id.
48 -
49 - Returns:
50 - Dict[str, Union[bool, str, int]]: The validation result.
51 - """
52 - raise NotImplementedError
53 -
54 -
55 -class WindowsAssetValidator(AssetValidator):
56 - """
57 - Class to check if an OS is Windows.
58 - """
59 -
60 - ASSET_TYPE_ID = 9
61 -
62 - def validate(self) -> Dict[str, Union[bool, str, int]]:
63 - if "windows" in self.os:
64 - return {
65 - "success": True,
66 - "message": f"{self.os} is a valid Windows OS.",
67 - "asset_type_id": self.ASSET_TYPE_ID,
68 - }
69 - else:
70 - return {
71 - "success": False,
72 - "message": f"{self.os} is not a Windows OS.",
73 - "asset_type_id": self.ASSET_TYPE_ID,
74 - }
75 -
76 -
77 -class LinuxAssetValidator(AssetValidator):
78 - """
79 - Class to check if an OS is Linux.
80 - """
81 -
82 - ASSET_TYPE_ID = 4
83 -
84 - def validate(self) -> Dict[str, Union[bool, str, int]]:
85 - if "linux" in self.os:
86 - return {
87 - "success": True,
88 - "message": f"{self.os} is a valid Linux OS.",
89 - "asset_type_id": self.ASSET_TYPE_ID,
90 - }
91 - else:
92 - return {
93 - "success": False,
94 - "message": f"{self.os} is not a Linux OS.",
95 - "asset_type_id": self.ASSET_TYPE_ID,
96 - }
97 -
98 -
99 -class FirewallAssetValidator(AssetValidator):
100 - """
101 - Class to check if an OS is Firewall.
102 - """
103 -
104 - ASSET_TYPE_ID = 2
105 -
106 - def validate(self) -> Dict[str, Union[bool, str, int]]:
107 - if "firewall" in self.os:
108 - return {
109 - "success": True,
110 - "message": f"{self.os} is a valid Firewall OS.",
111 - "asset_type_id": self.ASSET_TYPE_ID,
112 - }
113 - else:
114 - return {
115 - "success": False,
116 - "message": f"{self.os} is not a Firewall OS.",
117 - "asset_type_id": self.ASSET_TYPE_ID,
118 - }
119 -
120 -
121 -class UbuntuAssetValidator(AssetValidator):
122 - """
123 - Class to check if an OS is Ubuntu.
124 - """
125 -
126 - ASSET_TYPE_ID = 4
127 -
128 - def validate(self) -> Dict[str, Union[bool, str, int]]:
129 - if "ubuntu" in self.os:
130 - return {
131 - "success": True,
132 - "message": f"{self.os} is a valid Ubuntu OS.",
133 - "asset_type_id": self.ASSET_TYPE_ID,
134 - }
135 - else:
136 - return {
137 - "success": False,
138 - "message": f"{self.os} is not an Ubuntu OS.",
139 - "asset_type_id": self.ASSET_TYPE_ID,
140 - }
141 -
142 -
143 -class AssetTypeResolver:
144 - """
145 - Class to iterate over asset validators and return the successful validator's asset type id.
146 - """
147 -
148 - def __init__(self, os: str):
149 - """
150 - Initialize AssetTypeResolver.
151 -
152 - Args:
153 - os (str): The OS to be validated.
154 - """
155 - self.os = os
156 - self.validators = [
157 - WindowsAssetValidator,
158 - LinuxAssetValidator,
159 - FirewallAssetValidator,
160 - UbuntuAssetValidator,
161 - ]
162 -
163 - def get_asset_type_id(self) -> int:
164 - """
165 - Iterate over validators and return the successful validator's asset type id.
166 -
167 - Returns:
168 - int: The asset type id.
169 - """
170 - for Validator in self.validators:
171 - validator = Validator(self.os)
172 - result = validator.validate()
173 - if result["success"] is True:
174 - return result["asset_type_id"]
175 -
176 - # Return default asset type id (1) if no validators succeed
177 - return 1
178 -
179 -
180 -#################### ! DFIR IRIS ASSET VALIDATOR END ! ####################
181 -
182 -
183 -#################### ! DFIR IRIS IOC VALIDATOR ! ##########################
184 -
185 -
186 -class IoCValidator(ABC):
187 - """
188 - Base class for validators.
189 -
190 - Attributes:
191 - value (str): The value to be validated.
192 - """
193 -
194 - PATTERN: Optional[str] = None # type: ignore
195 - IOC_TYPE: Optional[int] = None # type: ignore
196 -
197 - def __init__(self, value: str) -> None:
198 - """
199 - Initialize a Validator.
200 -
201 - Args:
202 - value (str): The value to be validated.
203 - """
204 - self.value = value
205 -
206 - def validate(self) -> Dict[str, Union[bool, str, int]]:
207 - """
208 - Validate the value.
209 -
210 - If the value matches the pattern,
211 - the method returns a dictionary indicating success, the matching message, and the IOC type.
212 -
213 - Returns:
214 - Dict[str, Union[bool, str, int]]: The validation result.
215 - """
216 - logger.info(f"Validating {self.value} against {self.PATTERN}.")
217 - if self.PATTERN and regex.match(self.PATTERN, self.value, re.IGNORECASE):
218 - return {
219 - "success": True,
220 - "message": f"{self.value} matches the pattern.",
221 - "ioc_type": self.IOC_TYPE,
222 - }
223 - else:
224 - return {
225 - "success": False,
226 - "message": f"{self.value} does not match the pattern.",
227 - "ioc_type": self.IOC_TYPE,
228 - }
229 -
230 -
231 -class IPv4AddressValidator(IoCValidator):
232 - """
233 - Class to check if a string is a valid IPv4 address.
234 - """
235 -
236 - IOC_TYPE = 76
237 -
238 - def validate(self) -> Dict[str, Union[bool, str, int]]:
239 - """
240 - Validate if the given value is a valid IPv4 address.
241 -
242 - Returns:
243 - dict: A dictionary containing success status, message, and the associated IoC type.
244 - """
245 - try:
246 - # if the value is like this `162.159.133.233|443` strip the port
247 - if "|" in self.value:
248 - self.value = self.value.split("|")[0]
249 - logger.info(f"Validating {self.value} as an IPv4 address.")
250 - ipaddress.IPv4Address(self.value)
251 - return {
252 - "success": True,
253 - "message": f"{self.value} is a valid IPv4 address.",
254 - "ioc_type": self.IOC_TYPE,
255 - }
256 - except ValueError:
257 - return {
258 - "success": False,
259 - "message": f"{self.value} is not a valid IPv4 address.",
260 - "ioc_type": self.IOC_TYPE,
261 - }
262 -
263 -
264 -class HashValidator(IoCValidator):
265 - """
266 - Class to check if a string is a valid SHA256 hash.
267 - """
268 -
269 - PATTERN = r"^[a-fA-F\d]{64}$"
270 - IOC_TYPE = 113
271 -
272 -
273 -class DomainValidator(IoCValidator):
274 - """
275 - Class to check if a string is a valid domain name.
276 - """
277 -
278 - PATTERN = r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$"
279 - IOC_TYPE = 20
280 -
281 -
282 -#################### ! DFIR IRIS IOC VALIDATOR END ! ##########################
283 -
284 -
285 -def verify_wazuh_indexer_credentials(attributes: Dict[str, Any]) -> Dict[str, Any]:
286 - """
287 - Verifies the connection to Wazuh Indexer service.
288 -
289 - Returns:
290 - dict: A dictionary containing 'connectionSuccessful' status and 'authToken' if the connection is successful.
291 - """
292 - logger.info(f"Verifying the wazuh-indexer connection to {attributes['connector_url']}")
293 -
294 - try:
295 - es = Elasticsearch(
296 - [attributes["connector_url"]],
297 - http_auth=(attributes["connector_username"], attributes["connector_password"]),
298 - verify_certs=False,
299 - timeout=15,
300 - max_retries=10,
301 - retry_on_timeout=False,
302 - )
303 - es.cluster.health()
304 - logger.debug("Wazuh Indexer connection successful")
305 - return {"connectionSuccessful": True, "message": "Wazuh Indexer connection successful"}
306 - except Exception as e:
307 - logger.error(f"Connection to {attributes['connector_url']} failed with error: {e}")
308 - return {"connectionSuccessful": False, "message": f"Connection to {attributes['connector_url']} failed with error: {e}"}
309 -
310 -
311 -def verify_wazuh_indexer_connection(connector_name: str) -> str:
312 - """
313 - Returns the authentication token for the Wazuh Indexer service.
314 -
315 - Returns:
316 - str: Authentication token for the Wazuh Indexer service.
317 - """
318 - attributes = get_connector_info_from_db(connector_name)
319 - if attributes is None:
320 - logger.error("No Wazuh Indexer connector found in the database")
321 - return None
322 - return verify_wazuh_indexer_credentials(attributes)
323 -
324 -
325 -def create_wazuh_indexer_client(connector_name: str) -> Elasticsearch:
326 - """
327 - Returns an Elasticsearch client for the Wazuh Indexer service.
328 -
329 - Returns:
330 - Elasticsearch: Elasticsearch client for the Wazuh Indexer service.
331 - """
332 - attributes = get_connector_info_from_db(connector_name)
333 - if attributes is None:
334 - logger.error("No Wazuh Indexer connector found in the database")
335 - return None
336 - return Elasticsearch(
337 - [attributes["connector_url"]],
338 - http_auth=(attributes["connector_username"], attributes["connector_password"]),
339 - verify_certs=False,
340 - timeout=15,
341 - max_retries=10,
342 - retry_on_timeout=False,
343 - )
344 -
345 -
346 -async def get_agent_data(session: AsyncSession, agent_id: str) -> AgentModel:
347 - """
348 - Get agent data based on the agent id from the agents table.
349 -
350 - Args:
351 - session (AsyncSession): The SQLAlchemy session.
352 - agent_id (str): Agent id.
353 -
354 - Returns:
355 - AgentModel: Agent data.
356 - """
357 - agent_query = select(Agents).filter(Agents.agent_id == agent_id)
358 - result = await session.execute(agent_query)
359 - agent_details = result.scalars().first()
360 -
361 - if agent_details is not None:
362 - # Assuming AgentModel can be created from the Agents ORM model
363 - return AgentModel.from_orm(agent_details)
364 - else:
365 - raise HTTPException(status_code=404, detail=f"Agent with id {agent_id} not found in agents table")
366 -
367 -
368 -def get_asset_type_id(os: str) -> int:
369 - """
370 - Use AssetTypeResolver to determine the asset type ID to set within DFIR-IRIS.
371 -
372 - Parameters
373 - ----------
374 - os : str
375 - The operating system (OS) string used to resolve the asset type ID.
376 -
377 - Returns
378 - -------
379 - int
380 - The ID corresponding to the asset type.
381 - """
382 - asset_resolver = AssetTypeResolver(os)
383 - return asset_resolver.get_asset_type_id()
384 -
385 -
386 -def validate_ioc_type(ioc_value: str) -> str:
387 - """
388 - Validate IoC type using validators.
389 -
390 - Parameters
391 - ----------
392 - ioc_value : str
393 - The value to validate the IoC type.
394 -
395 - Returns
396 - -------
397 - str
398 - The type of the IoC. Returns None if validation fails.
399 - """
400 - validators = [IPv4AddressValidator, HashValidator, DomainValidator]
401 - ioc_type = None
402 -
403 - for Validator in validators:
404 - validator = Validator(ioc_value)
405 - result = validator.validate()
406 -
407 - if result["success"]:
408 - ioc_type = result["ioc_type"]
409 - break
410 -
411 - if ioc_type is None:
412 - logger.error("Failed to validate IoC value.")
413 - return ioc_type
backend/app/integrations/utils/alerts.py renamed
+2 -2
@@ -15,8 +15,8 @@ import requests
15 from fastapi import HTTPException
16 from loguru import logger
17
18 -from app.integrations.alert_creation.utils.schema import ShufflePayload
19 -from app.integrations.alert_creation.utils.schema import WazuhAgentResponse
18 +from app.integrations.utils.schema import ShufflePayload
19 +from app.integrations.utils.schema import WazuhAgentResponse
20 from app.utils import get_customer_alert_settings
21
22
backend/app/integrations/utils/schema.py renamed
backend/app/utils.py
+2 -2
@@ -407,7 +407,7 @@ async def purge_logs(session: AsyncSession = Depends(get_session)) -> LogsRespon
407
408 if logs:
409 for log in logs:
410 - session.delete(log)
410 + await session.delete(log)
411 await session.commit()
412 return LogsResponse(logs=[], success=True, message="Logs purged successfully")
413 else:
@@ -443,7 +443,7 @@ async def purge_logs_by_time_range(time_range: TimeRangeModel, session: AsyncSes
443 logs = [log for log in logs if log.timestamp >= datetime.now() - timedelta(days=int(time_range.time_range[:-1]))]
444 if logs != []:
445 for log in logs:
446 - session.delete(log)
446 + await session.delete(log)
447 await session.commit()
448 return LogsResponse(logs=[], success=True, message="Logs purged successfully")
449 else:
src/api/customers.ts new
+78
@@ -0,0 +1,78 @@
1 +import { type FlaskBaseResponse } from "@/types/flask.d"
2 +import { HttpClient } from "./httpClient"
3 +import type { Customer, CustomerAgentHealth, CustomerMeta } from "@/types/customers.d"
4 +import type { Agent } from "@/types/agents.d"
5 +
6 +export interface CustomerAgentsHealthcheckQuery {
7 + minutes?: number
8 + hours?: number
9 + days?: number
10 +}
11 +
12 +export default {
13 + getCustomers(code?: string) {
14 + return HttpClient.get<FlaskBaseResponse & { customers?: Customer[]; customer?: Customer }>(
15 + `/customers${code ? "/" + code : ""}`
16 + )
17 + },
18 + createCustomer(customer: Customer) {
19 + return HttpClient.post<FlaskBaseResponse & { customer: Customer }>(`/customers`, customer)
20 + },
21 + updateCustomer(customer: Customer, code?: string) {
22 + return HttpClient.put<FlaskBaseResponse & { customer: Customer }>(
23 + `/customers/${code || customer.customer_code}`,
24 + customer
25 + )
26 + },
27 + deleteCustomer(code: string) {
28 + return HttpClient.delete<FlaskBaseResponse & { customer: Customer }>(`/customers/${code}`)
29 + },
30 + getCustomerMeta(code: string) {
31 + return HttpClient.get<FlaskBaseResponse & { customer_meta: CustomerMeta }>(`/customers/${code}/meta`)
32 + },
33 + updateCustomerMeta(meta: CustomerMeta, code: string) {
34 + return HttpClient.put<FlaskBaseResponse & { customer_meta: CustomerMeta }>(`/customers/${code}/meta`, meta)
35 + },
36 + createCustomerMeta(meta: CustomerMeta, code: string) {
37 + return HttpClient.post<FlaskBaseResponse & { customer_meta: CustomerMeta }>(`/customers/${code}/meta`, meta)
38 + },
39 + deleteCustomerMeta(code: string) {
40 + return HttpClient.delete<FlaskBaseResponse & { customer_meta: CustomerMeta }>(`/customers/${code}/meta`)
41 + },
42 + getCustomerFull(code: string) {
43 + return HttpClient.get<FlaskBaseResponse & { customer: Customer; customer_meta?: CustomerMeta }>(
44 + `/customers/${code}/full`
45 + )
46 + },
47 + getCustomerAgents(code: string) {
48 + return HttpClient.get<FlaskBaseResponse & { agents: Agent[] }>(`/customers/${code}/agents`)
49 + },
50 + getCustomerAgentsHealthcheckWazuh(code: string, query?: CustomerAgentsHealthcheckQuery) {
51 + return HttpClient.get<
52 + FlaskBaseResponse & {
53 + healthy_wazuh_agents: CustomerAgentHealth[]
54 + unhealthy_wazuh_agents: CustomerAgentHealth[]
55 + }
56 + >(`/customers/${code}/agents/healthcheck/wazuh`, {
57 + params: {
58 + minutes: query?.minutes || 0,
59 + hours: query?.hours || 0,
60 + days: query?.days || 0
61 + }
62 + })
63 + },
64 + getCustomerAgentsHealthcheckVelociraptor(code: string, query?: CustomerAgentsHealthcheckQuery) {
65 + return HttpClient.get<
66 + FlaskBaseResponse & {
67 + healthy_velociraptor_agents: CustomerAgentHealth[]
68 + unhealthy_velociraptor_agents: CustomerAgentHealth[]
69 + }
70 + >(`/customers/${code}/agents/healthcheck/velociraptor`, {
71 + params: {
72 + minutes: query?.minutes || 0,
73 + hours: query?.hours || 0,
74 + days: query?.days || 0
75 + }
76 + })
77 + }
78 +}
src/api/index.ts
+3 -1
@@ -9,6 +9,7 @@ import soc from "./soc"
9 import healthchecks from "./healthchecks"
10 import threatIntel from "./threatIntel"
11 import askSocfortress from "./askSocfortress"
12 +import customers from "./customers"
13
14 export default {
15 agents,
@@ -21,5 +22,6 @@ export default {
22 soc,
23 healthchecks,
24 threatIntel,
24 - askSocfortress
25 + askSocfortress,
26 + customers
27 }
src/assets/scss/helpers.scss
+60
@@ -23,6 +23,61 @@
23 }
24 }
25
26 +.item-appear {
27 + &.item-appear-bottom {
28 + animation: item-fade-bottom 0.3s forwards;
29 + opacity: 0;
30 + }
31 + &.item-appear-up {
32 + animation: item-fade-up 0.3s forwards;
33 + opacity: 0;
34 + }
35 +
36 + &.item-appear-005 {
37 + @for $i from 0 through 40 {
38 + &:nth-child(#{$i}) {
39 + animation-delay: $i * 0.05s;
40 + }
41 + }
42 + }
43 + &.item-appear-010 {
44 + @for $i from 0 through 40 {
45 + &:nth-child(#{$i}) {
46 + animation-delay: $i * 0.1s;
47 + }
48 + }
49 + }
50 +}
51 +
52 +@keyframes item-fade-bottom {
53 + from {
54 + opacity: 0;
55 + transform: translateY(10px);
56 + }
57 + to {
58 + opacity: 1;
59 + }
60 +}
61 +
62 +@keyframes item-fade-up {
63 + from {
64 + opacity: 0;
65 + transform: translateY(-10px);
66 + }
67 + to {
68 + opacity: 1;
69 + }
70 +}
71 +
72 +.grid-auto-flow-200 {
73 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
74 + grid-auto-flow: row dense;
75 +}
76 +.grid-auto-flow-250 {
77 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
78 + grid-auto-flow: row dense;
79 +}
80 +
81 .bg-color {
82 background-color: var(--bg-color);
83 }
@@ -38,6 +93,11 @@
93 .text-primary-color {
94 color: var(--primary-color);
95 }
96 +.hover\:text-primary-color {
97 + &:hover {
98 + color: var(--primary-color);
99 + }
100 +}
101
102 .text-secondary-color {
103 color: var(--fg-secondary-color);
src/components/agents/AgentCard.vue
+11 -2
@@ -1,5 +1,9 @@
1 <template>
2 - <n-card class="agent-card py-3 px-4" :class="{ critical: agent.critical_asset }" content-style="padding:0">
2 + <n-card
3 + class="agent-card py-3 px-4"
4 + :class="{ critical: agent.critical_asset, 'bg-secondary': bgSecondary }"
5 + content-style="padding:0"
6 + >
7 <n-spin :show="loading">
8 <div class="wrapper">
9 <div class="agent-header">
@@ -84,8 +88,9 @@ const emit = defineEmits<{
88 const props = defineProps<{
89 agent: Agent
90 showActions?: boolean
91 + bgSecondary?: boolean
92 }>()
88 -const { agent, showActions } = toRefs(props)
93 +const { agent, showActions, bgSecondary } = toRefs(props)
94
95 const dFormats = useSettingsStore().dateFormat
96 const loading = ref(false)
@@ -147,6 +152,10 @@ function toggleCritical(agentId: string, criticalStatus: boolean) {
152 transition: all 0.3s;
153 border: var(--border-small-050);
154
155 + &.bg-secondary {
156 + background-color: var(--bg-secondary-color);
157 + }
158 +
159 .wrapper {
160 display: flex;
161 @apply gap-6;
src/components/agents/OverviewSection.vue
+20 -1
@@ -3,7 +3,18 @@
3 <div class="property-group">
4 <KVCard v-for="item of propsSanitized" :key="item.key">
5 <template #key>{{ item.key }}</template>
6 - <template #value>{{ item.val ?? "-" }}</template>
6 + <template #value>
7 + <template v-if="item.key === 'customer_code'">
8 + <code class="cursor-pointer text-primary-color" @click="gotoCustomer(item.val)" v-if="item.val">
9 + {{ item.val }}
10 + <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
11 + </code>
12 + <span v-else>-</span>
13 + </template>
14 + <template v-else>
15 + {{ item.val ?? "-" }}
16 + </template>
17 + </template>
18 </KVCard>
19 </div>
20 </div>
@@ -15,12 +26,16 @@ import dayjs from "@/utils/dayjs"
26 import { type Agent } from "@/types/agents.d"
27 import { useSettingsStore } from "@/stores/settings"
28 import KVCard from "@/components/common/KVCard.vue"
29 +import Icon from "@/components/common/Icon.vue"
30 +import { useRouter } from "vue-router"
31
32 const props = defineProps<{
33 agent: Agent
34 }>()
35 const { agent } = toRefs(props)
36
37 +const LinkIcon = "carbon:launch"
38 +const router = useRouter()
39 const dFormats = useSettingsStore().dateFormat
40
41 const propsSanitized = computed(() => {
@@ -44,6 +59,10 @@ const formatDate = (date: string) => {
59
60 return datejs.format(dFormats.datetime)
61 }
62 +
63 +function gotoCustomer(code: string | number) {
64 + router.push(`/customers?code=${code}`).catch(() => {})
65 +}
66 </script>
67
68 <style lang="scss" scoped>
src/components/agents/utils.ts
+1 -1
@@ -93,7 +93,7 @@ export function handleDeleteAgent({
93 title: "Confirm",
94 content: () =>
95 h("div", {
96 - innerHTML: `Are you sure you want to delete the agent:<br/><strong>${agent.hostname}</strong> ?`
96 + innerHTML: `Are you sure you want to delete the Agent:<br/><strong>${agent.hostname}</strong> ?`
97 }),
98 positiveText: "Yes I'm sure",
99 negativeText: "Cancel",
src/components/alerts/Alert.vue
+20 -9
@@ -70,7 +70,13 @@
70 </div>
71 <div class="box">
72 agent_labels_customer:
73 - <code>{{ alert._source.agent_labels_customer }}</code>
73 + <code
74 + class="cursor-pointer text-primary-color"
75 + @click="gotoCustomer(alert._source.agent_labels_customer)"
76 + >
77 + {{ alert._source.agent_labels_customer }}
78 + <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
79 + </code>
80 </div>
81 </div>
82 </n-popover>
@@ -125,9 +131,9 @@
131 :bordered="false"
132 segmented
133 >
128 - <n-tabs type="line" animated justify-content="space-evenly">
134 + <n-tabs type="line" animated :tabs-padding="24">
135 <n-tab-pane name="Agent" tab="Agent" display-directive="show">
130 - <div class="grid gap-2 alert-context-grid p-7 pt-4" v-if="agentProperties">
136 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4" v-if="agentProperties">
137 <KVCard v-for="(value, key) of agentProperties" :key="key">
138 <template #key>{{ key }}</template>
139 <template #value>
@@ -137,6 +143,12 @@
143 <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
144 </code>
145 </template>
146 + <template v-else-if="key === 'agent_labels_customer'">
147 + <code class="cursor-pointer text-primary-color" @click="gotoCustomer(value + '')">
148 + {{ value }}
149 + <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
150 + </code>
151 + </template>
152 <template v-else>
153 {{ value || "-" }}
154 </template>
@@ -208,6 +220,8 @@
220 </template>
221
222 <script setup lang="ts">
223 +// TODO: add global popover with map for coords property (agent_ip_geolocation) ??
224 +
225 import { NPopover, NModal, NTabs, NTabPane, NInput } from "naive-ui"
226 import { useSettingsStore } from "@/stores/settings"
227 import dayjs from "@/utils/dayjs"
@@ -257,6 +271,9 @@ function formatDate(timestamp: string): string {
271 function gotoAgentPage(agentId: string) {
272 router.push(`/agent/${agentId}`).catch(() => {})
273 }
274 +function gotoCustomer(code: string | number) {
275 + router.push(`/customers?code=${code}`).catch(() => {})
276 +}
277 </script>
278
279 <style lang="scss" scoped>
@@ -334,9 +351,3 @@ function gotoAgentPage(agentId: string) {
351 }
352 }
353 </style>
337 -<style lang="scss">
338 -.alert-context-grid {
339 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
340 - grid-auto-flow: row dense;
341 -}
342 -</style>
src/components/alerts/AlertsList.vue
+1 -22
@@ -49,7 +49,7 @@
49 v-for="alertsSummary of alertsSummaryList"
50 :key="alertsSummary.index_name"
51 :alertsSummary="alertsSummary"
52 - class="mb-2"
52 + class="item-appear item-appear-bottom item-appear-005 mb-2"
53 />
54 </template>
55 <template v-else>
@@ -379,27 +379,6 @@ onBeforeUnmount(() => {
379 .list {
380 container-type: inline-size;
381 min-height: 200px;
382 -
383 - .alert-summary {
384 - animation: alert-summary-fade 0.3s forwards;
385 - opacity: 0;
386 -
387 - @for $i from 0 through 20 {
388 - &:nth-child(#{$i}) {
389 - animation-delay: $i * 0.05s;
390 - }
391 - }
392 -
393 - @keyframes alert-summary-fade {
394 - from {
395 - opacity: 0;
396 - transform: translateY(10px);
397 - }
398 - to {
399 - opacity: 1;
400 - }
401 - }
402 - }
382 }
383 }
384 </style>
src/components/alerts/AlertsStats.vue
+1 -1
@@ -1,6 +1,6 @@
1 <template>
2 <div class="alerts-stats">
3 - <n-tabs default-value="countByHost" animated justify-content="space-evenly" type="line">
3 + <n-tabs default-value="countByHost" animated type="line" :tabs-padding="24">
4 <n-tab-pane name="countByHost" tab="By Host">
5 <n-spin :show="loadingCountByHost">
6 <template #description>Alerts are being fetched, this may take up to 1 minute.</template>
src/components/apps/Kanban/TaskCard.vue
-18
@@ -48,26 +48,8 @@ const labelsColors = {
48 margin-top: 3px;
49 background-color: var(--bg-color);
50 transition: all 0.2s;
51 - opacity: 0;
52 - animation: task-fade 0.3s forwards;
51 border: 1px solid var(--border-color);
52
55 - @for $i from 0 through 15 {
56 - &:nth-child(#{$i}) {
57 - animation-delay: $i * 0.1s;
58 - }
59 - }
60 -
61 - @keyframes task-fade {
62 - from {
63 - opacity: 0;
64 - transform: translateY(-10px);
65 - }
66 - to {
67 - opacity: 1;
68 - }
69 - }
70 -
53 .pan-area {
54 margin-top: 2px;
55 }
src/components/apps/Mailbox/Email.vue
-18
@@ -112,17 +112,9 @@ function toggleStar(email: Email) {
112 line-height: 1.2;
113 white-space: nowrap;
114 cursor: pointer;
115 - opacity: 0;
115 transition: all 0.1s ease-in;
117 - animation: email-fade 0.3s forwards;
116 container-type: inline-size;
117
120 - @for $i from 0 through 40 {
121 - &:nth-child(#{$i}) {
122 - animation-delay: $i * 0.05s;
123 - }
124 - }
125 -
118 .title {
119 overflow: hidden;
120 width: 0;
@@ -168,16 +160,6 @@ function toggleStar(email: Email) {
160 }
161 }
162
171 - @keyframes email-fade {
172 - from {
173 - opacity: 0;
174 - transform: translateY(10px);
175 - }
176 - to {
177 - opacity: 1;
178 - }
179 - }
180 -
163 @container (max-width: 760px) {
164 .title {
165 display: flex;
src/components/artifacts/ArtifactsCollect.vue
+6 -22
@@ -72,7 +72,12 @@
72 <n-spin :show="loading">
73 <div class="list grid gap-3 my-7">
74 <template v-if="collectList.length">
75 - <CollectItem v-for="collect of collectList" :key="collect.___id" :collect="collect" />
75 + <CollectItem
76 + v-for="collect of collectList"
77 + :key="collect.___id"
78 + :collect="collect"
79 + class="item-appear item-appear-bottom item-appear-005"
80 + />
81 </template>
82 <template v-else>
83 <n-empty description="No items found" class="justify-center h-48" v-if="!loading" />
@@ -260,27 +265,6 @@ onBeforeMount(() => {
265 min-height: 200px;
266 grid-template-columns: repeat(auto-fit, minmax(390px, 1fr));
267 grid-auto-flow: row dense;
263 -
264 - .collect-item {
265 - animation: artifacts-collect-fade 0.3s forwards;
266 - opacity: 0;
267 -
268 - @for $i from 0 through 30 {
269 - &:nth-child(#{$i}) {
270 - animation-delay: $i * 0.05s;
271 - }
272 - }
273 -
274 - @keyframes artifacts-collect-fade {
275 - from {
276 - opacity: 0;
277 - transform: translateY(10px);
278 - }
279 - to {
280 - opacity: 1;
281 - }
282 - }
283 - }
268 }
269
270 @media (max-width: 490px) {
src/components/artifacts/ArtifactsCommand.vue
+6 -22
@@ -97,7 +97,12 @@
97 <n-spin :show="loading">
98 <div class="list flex flex-col gap-3 my-7">
99 <template v-if="commandList.length">
100 - <CommandItem v-for="command of commandList" :key="command.Stdout" :command="command" />
100 + <CommandItem
101 + v-for="command of commandList"
102 + :key="command.Stdout"
103 + :command="command"
104 + class="item-appear item-appear-bottom item-appear-005"
105 + />
106 </template>
107 <template v-else>
108 <n-empty description="No items found" class="justify-center h-48" v-if="!loading" />
@@ -290,27 +295,6 @@ onBeforeMount(() => {
295 .list {
296 container-type: inline-size;
297 min-height: 200px;
293 -
294 - .command-item {
295 - animation: artifacts-command-fade 0.3s forwards;
296 - opacity: 0;
297 -
298 - @for $i from 0 through 10 {
299 - &:nth-child(#{$i}) {
300 - animation-delay: $i * 0.05s;
301 - }
302 - }
303 -
304 - @keyframes artifacts-command-fade {
305 - from {
306 - opacity: 0;
307 - transform: translateY(10px);
308 - }
309 - to {
310 - opacity: 1;
311 - }
312 - }
313 - }
298 }
299 }
300 </style>
src/components/artifacts/ArtifactsList.vue
+1 -22
@@ -110,7 +110,7 @@
110 v-for="artifact of itemsPaginated"
111 :key="artifact.name"
112 :artifact="artifact"
113 - class="mb-2"
113 + class="item-appear item-appear-bottom item-appear-005 mb-2"
114 />
115 </template>
116 <template v-else>
@@ -301,27 +301,6 @@ onBeforeMount(() => {
301 .list {
302 container-type: inline-size;
303 min-height: 200px;
304 -
305 - .artifact-item {
306 - animation: artifacts-item-fade 0.3s forwards;
307 - opacity: 0;
308 -
309 - @for $i from 0 through 30 {
310 - &:nth-child(#{$i}) {
311 - animation-delay: $i * 0.05s;
312 - }
313 - }
314 -
315 - @keyframes artifacts-item-fade {
316 - from {
317 - opacity: 0;
318 - transform: translateY(10px);
319 - }
320 - to {
321 - opacity: 1;
322 - }
323 - }
324 - }
304 }
305 }
306 </style>
src/components/artifacts/ArtifactsQuarantine.vue
+1 -21
@@ -67,6 +67,7 @@
67 v-for="quarantine of quarantineList"
68 :key="quarantine.Result + quarantine.Time"
69 :quarantine="quarantine"
70 + class="item-appear item-appear-bottom item-appear-005"
71 />
72 </template>
73 <template v-else>
@@ -248,27 +249,6 @@ onBeforeMount(() => {
249 .list {
250 container-type: inline-size;
251 min-height: 100px;
251 -
252 - .quarantine-item {
253 - animation: artifacts-quarantine-fade 0.3s forwards;
254 - opacity: 0;
255 -
256 - @for $i from 0 through 30 {
257 - &:nth-child(#{$i}) {
258 - animation-delay: $i * 0.05s;
259 - }
260 - }
261 -
262 - @keyframes artifacts-quarantine-fade {
263 - from {
264 - opacity: 0;
265 - transform: translateY(10px);
266 - }
267 - to {
268 - opacity: 1;
269 - }
270 - }
271 - }
252 }
253 }
254 </style>
src/components/common/Notifications/List.vue
+2 -2
@@ -29,11 +29,12 @@
29 </div>
30 </div>
31 <slot name="last"></slot>
32 + <n-empty v-if="!list.length" description="There is no notification" class="justify-center h-48" />
33 </n-scrollbar>
34 </template>
35
36 <script lang="ts" setup>
36 -import { NScrollbar, NTooltip } from "naive-ui"
37 +import { NScrollbar, NTooltip, NEmpty } from "naive-ui"
38 import Icon from "@/components/common/Icon.vue"
39 import { useNotifications } from "@/composables/useNotifications"
40 import { computed } from "vue"
@@ -109,7 +110,6 @@ function formatDatetime(date: Date | string) {
110 }
111
112 .content {
112 - max-width: 250px;
113 padding-right: 20px;
114 font-size: 14px;
115
src/components/common/Notifications/Toolbar.vue
+6
@@ -25,3 +25,9 @@ function deleteAll() {
25 useNotifications().deleteAll()
26 }
27 </script>
28 +
29 +<style>
30 +.notifications-toolbar {
31 + width: 100%;
32 +}
33 +</style>
src/components/customers/CustomerAgents.vue new
+75
@@ -0,0 +1,75 @@
1 +<template>
2 + <n-spin :show="loading">
3 + <div class="customer-agents flex flex-col gap-2 p-6 pt-4">
4 + <AgentCard
5 + v-for="agent in list"
6 + :key="agent.agent_id"
7 + :agent="agent"
8 + bg-secondary
9 + show-actions
10 + @delete="getAgents()"
11 + @click="gotoAgentPage(agent)"
12 + class="item-appear item-appear-bottom item-appear-005"
13 + />
14 + <n-empty v-if="!list.length" description="No Agents found" class="justify-center h-48" />
15 + </div>
16 + </n-spin>
17 +</template>
18 +
19 +<script setup lang="ts">
20 +import { onBeforeMount, ref, toRefs } from "vue"
21 +import AgentCard from "@/components/agents/AgentCard.vue"
22 +import Api from "@/api"
23 +import { useMessage, NSpin, NEmpty } from "naive-ui"
24 +import type { Customer } from "@/types/customers.d"
25 +import { useRouter } from "vue-router"
26 +import type { Agent } from "@/types/agents.d"
27 +import { isAgentOnline } from "@/components/agents/utils"
28 +
29 +const props = defineProps<{
30 + customer: Customer
31 +}>()
32 +const { customer } = toRefs(props)
33 +
34 +const loading = ref(false)
35 +const router = useRouter()
36 +const message = useMessage()
37 +const list = ref<Agent[] | []>([])
38 +
39 +function gotoAgentPage(agent: Agent) {
40 + router.push(`/agent/${agent.agent_id}`).catch(() => {})
41 +}
42 +
43 +function getAgents() {
44 + loading.value = true
45 +
46 + Api.customers
47 + .getCustomerAgents(customer.value.customer_code)
48 + .then(res => {
49 + if (res.data.success) {
50 + list.value = (res.data.agents || []).map(o => {
51 + o.online = isAgentOnline(o.wazuh_last_seen)
52 + return o
53 + })
54 + } else {
55 + message.error(res.data?.message || "An error occurred. Please try again later.")
56 + }
57 + })
58 + .catch(err => {
59 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
60 + })
61 + .finally(() => {
62 + loading.value = false
63 + })
64 +}
65 +
66 +onBeforeMount(() => {
67 + getAgents()
68 +})
69 +</script>
70 +
71 +<style lang="scss" scoped>
72 +.customer-agents {
73 + min-height: 100px;
74 +}
75 +</style>
src/components/customers/CustomerForm.vue new
+261
@@ -0,0 +1,261 @@
1 +<template>
2 + <n-spin :show="loading" class="customer-form">
3 + <n-form :label-width="80" :model="form" :rules="rules" ref="formRef">
4 + <div class="flex flex-col gap-4">
5 + <div class="flex flex-wrap gap-4">
6 + <div v-for="(val, key) of form" :key="key" class="grow">
7 + <n-form-item :label="fieldsMeta[key].label" :path="key" class="grow">
8 + <n-input
9 + v-model:value.trim="form[key]"
10 + :placeholder="fieldsMeta[key].placeholder"
11 + clearable
12 + :readonly="key === 'customer_code' && lockCode"
13 + :disabled="key === 'customer_code' && lockCode"
14 + />
15 + </n-form-item>
16 + </div>
17 + </div>
18 + <div class="flex justify-between gap-4">
19 + <div class="flex gap-4">
20 + <slot name="additionalActions"></slot>
21 + </div>
22 + <div class="flex gap-4">
23 + <n-button @click="reset()" :disabled="loading">Reset</n-button>
24 + <n-button type="primary" :disabled="!isValid" @click="validate()" :loading="loading">
25 + Submit
26 + </n-button>
27 + </div>
28 + </div>
29 + </div>
30 + </n-form>
31 + </n-spin>
32 +</template>
33 +
34 +<script setup lang="ts">
35 +import { computed, onBeforeMount, onMounted, ref, toRefs, watch } from "vue"
36 +import Api from "@/api"
37 +import {
38 + useMessage,
39 + NForm,
40 + NFormItem,
41 + NInput,
42 + NButton,
43 + NSpin,
44 + type FormValidationError,
45 + type FormInst,
46 + type FormRules
47 +} from "naive-ui"
48 +import type { Customer } from "@/types/customers.d"
49 +import _trim from "lodash/trim"
50 +import _get from "lodash/get"
51 +
52 +const emit = defineEmits<{
53 + (e: "update:loading", value: boolean): void
54 + (e: "submitted", value: Customer): void
55 + (
56 + e: "mounted",
57 + value: {
58 + reset: () => void
59 + }
60 + ): void
61 +}>()
62 +
63 +const props = defineProps<{
64 + customer?: Customer
65 + resetOnSubmit?: boolean
66 + /** lock customer_code on reset and editing (readonly) */
67 + lockCode?: boolean
68 +}>()
69 +const { customer, resetOnSubmit, lockCode } = toRefs(props)
70 +
71 +const loading = ref(false)
72 +const message = useMessage()
73 +const form = ref<Customer>(getClearForm())
74 +const formRef = ref<FormInst | null>(null)
75 +
76 +const rules: FormRules = {
77 + customer_code: {
78 + required: true,
79 + message: "Please input code",
80 + trigger: ["input", "blur"]
81 + },
82 + customer_name: {
83 + required: true,
84 + message: "Please input name",
85 + trigger: ["input", "blur"]
86 + },
87 + contact_last_name: {
88 + required: true,
89 + message: "Please input last name",
90 + trigger: ["input", "blur"]
91 + },
92 + contact_first_name: {
93 + required: true,
94 + message: "Please input first name",
95 + trigger: ["input", "blur"]
96 + }
97 +}
98 +
99 +const fieldsMeta = {
100 + customer_code: {
101 + label: "Code",
102 + placeholder: "Unique code for the customer"
103 + },
104 + customer_name: {
105 + label: "Name",
106 + placeholder: "Name of the customer"
107 + },
108 + contact_last_name: {
109 + label: "Last name",
110 + placeholder: "Last name of the contact"
111 + },
112 + contact_first_name: {
113 + label: "First name",
114 + placeholder: "First name of the contact"
115 + },
116 + parent_customer_code: {
117 + label: "Parent Customer Code",
118 + placeholder: "Code for the parent customer"
119 + },
120 + phone: {
121 + label: "Phone number",
122 + placeholder: "Phone number"
123 + },
124 + address_line1: {
125 + label: "First line address",
126 + placeholder: "First line of the address"
127 + },
128 + address_line2: {
129 + label: "Second line address",
130 + placeholder: "Second line of the address"
131 + },
132 + city: {
133 + label: "City",
134 + placeholder: "City"
135 + },
136 + state: {
137 + label: "State",
138 + placeholder: "State"
139 + },
140 + postal_code: {
141 + label: "Postal Code",
142 + placeholder: "Postal Code"
143 + },
144 + country: {
145 + label: "Country",
146 + placeholder: "Country"
147 + },
148 + customer_type: {
149 + label: "Type",
150 + placeholder: "Type of the customer"
151 + },
152 + logo_file: {
153 + label: "Logo",
154 + placeholder: "Logo file for the customer"
155 + }
156 +}
157 +
158 +const isValid = computed(() => {
159 + let valid = true
160 +
161 + for (const key in rules) {
162 + const rule = rules[key] as FormRules
163 +
164 + if (rule.required && !_trim(_get(form.value, key))) {
165 + valid = false
166 + }
167 + }
168 +
169 + return valid
170 +})
171 +
172 +function validate() {
173 + if (!formRef.value) return
174 +
175 + formRef.value.validate((errors?: Array<FormValidationError>) => {
176 + if (!errors) {
177 + submit()
178 + } else {
179 + message.warning("You must fill in the required fields correctly.")
180 + return false
181 + }
182 + })
183 +}
184 +
185 +function getClearForm(customer?: Partial<Customer>) {
186 + return {
187 + customer_code: customer?.customer_code || "",
188 + customer_name: customer?.customer_name || "",
189 + contact_last_name: customer?.contact_last_name || "",
190 + contact_first_name: customer?.contact_first_name || "",
191 + parent_customer_code: customer?.parent_customer_code || "",
192 + phone: customer?.phone || "",
193 + address_line1: customer?.address_line1 || "",
194 + address_line2: customer?.address_line2 || "",
195 + city: customer?.city || "",
196 + state: customer?.state || "",
197 + postal_code: customer?.postal_code || "",
198 + country: customer?.country || "",
199 + customer_type: customer?.customer_type || "",
200 + logo_file: customer?.logo_file || ""
201 + }
202 +}
203 +
204 +function reset() {
205 + let fields = undefined
206 + if (lockCode.value) {
207 + fields = { customer_code: customer.value?.customer_code || "" }
208 + }
209 + form.value = getClearForm(fields)
210 +}
211 +
212 +function submit() {
213 + loading.value = true
214 +
215 + const method = customer.value?.customer_code ? "updateCustomer" : "createCustomer"
216 +
217 + Api.customers[method](form.value, customer.value?.customer_code)
218 + .then(res => {
219 + if (res.data.success) {
220 + emit("submitted", res.data.customer)
221 + if (resetOnSubmit.value) {
222 + reset()
223 + }
224 + } else {
225 + message.warning(res.data?.message || "An error occurred. Please try again later.")
226 + }
227 + })
228 + .catch(err => {
229 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
230 + })
231 + .finally(() => {
232 + loading.value = false
233 + })
234 +}
235 +
236 +function setForm() {
237 + form.value = getClearForm(customer.value)
238 +}
239 +
240 +watch(loading, val => {
241 + emit("update:loading", val)
242 +})
243 +
244 +watch(customer, val => {
245 + if (val) {
246 + setForm()
247 + }
248 +})
249 +
250 +onBeforeMount(() => {
251 + if (customer.value) {
252 + setForm()
253 + }
254 +})
255 +
256 +onMounted(() => {
257 + emit("mounted", {
258 + reset
259 + })
260 +})
261 +</script>
src/components/customers/CustomerHealthcheckItem.vue new
+220
@@ -0,0 +1,220 @@
1 +<template>
2 + <div class="customer-healthcheck-item" :class="[{ 'bg-secondary': bgSecondary }, type]">
3 + <div class="px-4 py-3 flex flex-col gap-2">
4 + <div class="header-box flex justify-between items-center">
5 + <div class="id flex items-center gap-2 cursor-pointer" @click="showDetails = true">
6 + <span>#{{ healthData.id }} - {{ healthData.label }}</span>
7 + <Icon :name="InfoIcon" :size="16"></Icon>
8 + </div>
9 + <div class="time" v-if="cardDate">
10 + {{ cardDate }}
11 + </div>
12 + </div>
13 +
14 + <div class="main-box">
15 + <div class="content flex flex-col gap-1 grow">
16 + <div class="title">
17 + <Icon :name="iconFromOs(healthData.os)" :size="16" class="mr-1 relative top-0.5"></Icon>
18 + {{ healthData.os }}
19 + </div>
20 + <div class="description">
21 + {{ healthData.ip_address }}
22 + </div>
23 + </div>
24 + </div>
25 +
26 + <div class="badges-box flex flex-wrap items-center gap-3 mt-2">
27 + <Badge type="splitted" v-if="agentVersion">
28 + <template #label>Agent version</template>
29 + <template #value>{{ agentVersion }}</template>
30 + </Badge>
31 +
32 + <Badge type="splitted" v-if="source === 'velociraptor'">
33 + <template #label>Velociraptor Id</template>
34 + <template #value>{{ healthData.velociraptor_id }}</template>
35 + </Badge>
36 +
37 + <n-popover overlap placement="bottom-start">
38 + <template #trigger>
39 + <Badge type="splitted" hint-cursor>
40 + <template #iconLeft>
41 + <Icon :name="AgentIcon" :size="13" class="!opacity-80"></Icon>
42 + </template>
43 + <template #label>Agent</template>
44 + <template #value>
45 + {{ healthData.hostname }}
46 + </template>
47 + </Badge>
48 + </template>
49 + <div class="flex flex-col gap-1">
50 + <div class="box">
51 + agent_id:
52 + <code class="cursor-pointer text-primary-color" @click="gotoAgentPage(healthData.agent_id)">
53 + {{ healthData.agent_id }}
54 + <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
55 + </code>
56 + </div>
57 + <div class="box">
58 + hostname:
59 + <code>{{ healthData.hostname }}</code>
60 + </div>
61 + </div>
62 + </n-popover>
63 + </div>
64 + </div>
65 +
66 + <n-modal
67 + v-model:show="showDetails"
68 + preset="card"
69 + content-style="padding:0px"
70 + :style="{ maxWidth: 'min(800px, 90vw)', overflow: 'hidden' }"
71 + :title="`Health check ${source}`"
72 + :bordered="false"
73 + segmented
74 + >
75 + <div class="grid gap-2 grid-auto-flow-200 px-7 py-6">
76 + <KVCard v-for="(value, key) of healthData" :key="key">
77 + <template #key>{{ key }}</template>
78 + <template #value>{{ value || "-" }}</template>
79 + </KVCard>
80 + </div>
81 + </n-modal>
82 + </div>
83 +</template>
84 +
85 +<script setup lang="ts">
86 +import Icon from "@/components/common/Icon.vue"
87 +import Badge from "@/components/common/Badge.vue"
88 +import { computed, ref } from "vue"
89 +import KVCard from "@/components/common/KVCard.vue"
90 +import { NPopover, NModal } from "naive-ui"
91 +import type { CustomerAgentHealth, CustomerHealthcheckSource } from "@/types/customers.d"
92 +import dayjs from "@/utils/dayjs"
93 +import { iconFromOs } from "@/utils"
94 +import { useSettingsStore } from "@/stores/settings"
95 +import { useRouter } from "vue-router"
96 +
97 +const { healthData, source, bgSecondary, type } = defineProps<{
98 + healthData: CustomerAgentHealth
99 + source: CustomerHealthcheckSource
100 + type?: "healthy" | "unhealthy"
101 + bgSecondary?: boolean
102 +}>()
103 +
104 +const InfoIcon = "carbon:information"
105 +const AgentIcon = "carbon:police"
106 +const LinkIcon = "carbon:launch"
107 +
108 +const showDetails = ref(false)
109 +const router = useRouter()
110 +
111 +const agentVersion = computed(() => {
112 + let agent = ""
113 +
114 + switch (source) {
115 + case "wazuh":
116 + agent = healthData.wazuh_agent_version
117 + break
118 + case "velociraptor":
119 + agent = healthData.velociraptor_agent_version
120 + break
121 + }
122 +
123 + return agent
124 +})
125 +
126 +const cardDate = computed(() => {
127 + let date = ""
128 + switch (source) {
129 + case "wazuh":
130 + date = healthData.wazuh_last_seen
131 + break
132 + case "velociraptor":
133 + date = healthData.velociraptor_last_seen
134 + break
135 + }
136 +
137 + return date ? formatDate(date) : ""
138 +})
139 +
140 +const dFormats = useSettingsStore().dateFormat
141 +
142 +function formatDate(timestamp: string | number, utc: boolean = true): string {
143 + return dayjs(timestamp).utc(utc).format(dFormats.datetimesec)
144 +}
145 +
146 +function gotoAgentPage(agentId: string) {
147 + router.push(`/agent/${agentId}`).catch(() => {})
148 +}
149 +</script>
150 +
151 +<style lang="scss" scoped>
152 +.customer-healthcheck-item {
153 + border-radius: var(--border-radius);
154 + background-color: var(--bg-color);
155 + transition: all 0.2s var(--bezier-ease);
156 + border: var(--border-small-050);
157 +
158 + &.bg-secondary {
159 + background-color: var(--bg-secondary-color);
160 + }
161 +
162 + .header-box {
163 + font-family: var(--font-family-mono);
164 + font-size: 13px;
165 +
166 + .id {
167 + word-break: break-word;
168 + color: var(--fg-secondary-color);
169 + line-height: 1.2;
170 +
171 + &:hover {
172 + color: var(--primary-color);
173 + }
174 + }
175 +
176 + .time {
177 + color: var(--fg-secondary-color);
178 + }
179 + }
180 +
181 + .main-box {
182 + .content {
183 + word-break: break-word;
184 +
185 + .description {
186 + color: var(--fg-secondary-color);
187 + font-size: 13px;
188 + }
189 + }
190 + }
191 +
192 + &.healthy {
193 + border-color: var(--success-color);
194 + }
195 + &.unhealthy {
196 + border-color: var(--warning-color);
197 +
198 + .header-box {
199 + .id {
200 + &:hover {
201 + color: var(--warning-color);
202 + }
203 + }
204 + }
205 + }
206 +
207 + &:hover {
208 + box-shadow: 0px 0px 0px 1px inset var(--primary-color);
209 +
210 + &.healthy {
211 + background-color: var(--primary-005-color);
212 + box-shadow: none;
213 + }
214 + &.unhealthy {
215 + background-color: var(--secondary3-opacity-005-color);
216 + box-shadow: none;
217 + }
218 + }
219 +}
220 +</style>
src/components/customers/CustomerHealthcheckList.vue new
+180
@@ -0,0 +1,180 @@
1 +<template>
2 + <div class="flex p-6 pt-4 pb-0 justify-end">
3 + <div>
4 + <n-input-group>
5 + <n-select
6 + v-model:value="filters.unit"
7 + :options="unitOptions"
8 + placeholder="Time unit"
9 + clearable
10 + class="!w-28"
11 + />
12 + <n-input-number v-model:value="filters.time" :min="1" clearable placeholder="Time" class="!w-32" />
13 + </n-input-group>
14 + </div>
15 + </div>
16 + <n-spin :show="loading">
17 + <div class="customer-healthcheck-list">
18 + <div class="list flex flex-col gap-2 p-6 pt-4" v-if="healthyList.length">
19 + <div class="title healthy flex items-center gap-2">
20 + <Icon :name="CheckIcon" :size="16"></Icon>
21 + Healthy
22 + <code>{{ healthyList.length }}</code>
23 + </div>
24 + <CustomerHealthcheckItem
25 + v-for="item of healthyList"
26 + :key="item.id"
27 + :health-data="item"
28 + :source="source"
29 + type="healthy"
30 + bg-secondary
31 + class="item-appear item-appear-bottom item-appear-005"
32 + />
33 + </div>
34 + <div class="list flex flex-col gap-2 p-6 pt-4" v-if="unhealthyList.length">
35 + <div class="title unhealthy flex items-center gap-2">
36 + <Icon :name="AlertIcon" :size="16"></Icon>
37 + Unhealthy
38 + <code>{{ unhealthyList.length }}</code>
39 + </div>
40 + <CustomerHealthcheckItem
41 + v-for="item of unhealthyList"
42 + :key="item.id"
43 + :health-data="item"
44 + :source="source"
45 + type="unhealthy"
46 + bg-secondary
47 + class="item-appear item-appear-bottom item-appear-005"
48 + />
49 + </div>
50 + <n-empty v-if="!healthyList.length && !unhealthyList.length && !loading" class="justify-center h-48" />
51 + </div>
52 + </n-spin>
53 +</template>
54 +
55 +<script setup lang="ts">
56 +import Icon from "@/components/common/Icon.vue"
57 +import { onBeforeMount, ref, watch } from "vue"
58 +import _get from "lodash/get"
59 +import Api from "@/api"
60 +import CustomerHealthcheckItem from "./CustomerHealthcheckItem.vue"
61 +import { useMessage, NSpin, NEmpty, NSelect, NInputGroup, NInputNumber } from "naive-ui"
62 +import type { CustomerAgentHealth, CustomerHealthcheckSource } from "@/types/customers.d"
63 +import { watchDebounced } from "@vueuse/core"
64 +import type { CustomerAgentsHealthcheckQuery } from "@/api/customers"
65 +
66 +const { source, customerCode } = defineProps<{
67 + source: CustomerHealthcheckSource
68 + customerCode: string
69 +}>()
70 +
71 +const CheckIcon = "carbon:checkmark-outline"
72 +const AlertIcon = "mdi:alert-outline"
73 +
74 +const loading = ref(false)
75 +const healthyList = ref<CustomerAgentHealth[]>([])
76 +const unhealthyList = ref<CustomerAgentHealth[]>([])
77 +const message = useMessage()
78 +
79 +const unitOptions = [
80 + { label: "Minutes", value: "minutes" },
81 + { label: "Hours", value: "hours" },
82 + { label: "Days", value: "days" }
83 +]
84 +
85 +const filters = ref<Partial<{ time: number; unit: "minutes" | "hours" | "days" }>>({})
86 +
87 +function getList() {
88 + loading.value = true
89 +
90 + const params = {
91 + method: "" as "getCustomerAgentsHealthcheckWazuh" | "getCustomerAgentsHealthcheckVelociraptor",
92 + healthy: "",
93 + unhealthy: ""
94 + }
95 +
96 + switch (source) {
97 + case "wazuh":
98 + params.method = "getCustomerAgentsHealthcheckWazuh"
99 + params.healthy = "healthy_wazuh_agents"
100 + params.unhealthy = "unhealthy_wazuh_agents"
101 + break
102 + case "velociraptor":
103 + params.method = "getCustomerAgentsHealthcheckVelociraptor"
104 + params.healthy = "healthy_velociraptor_agents"
105 + params.unhealthy = "unhealthy_velociraptor_agents"
106 + break
107 + }
108 +
109 + if (!params.method) {
110 + return
111 + }
112 +
113 + let query: CustomerAgentsHealthcheckQuery | undefined = undefined
114 + if (filters.value.time && filters.value.unit) {
115 + query = {}
116 + query[filters.value.unit] = filters.value.time
117 + }
118 +
119 + Api.customers[params.method](customerCode, query)
120 + .then(res => {
121 + if (res.data.success) {
122 + healthyList.value = _get(res, `data.${params.healthy}`, [])
123 + unhealthyList.value = _get(res, `data.${params.unhealthy}`, [])
124 + } else {
125 + message.warning(res.data?.message || "An error occurred. Please try again later.")
126 + }
127 + })
128 + .catch(err => {
129 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
130 + })
131 + .finally(() => {
132 + loading.value = false
133 + })
134 +}
135 +
136 +watchDebounced(
137 + () => filters.value.time,
138 + () => {
139 + if ((filters.value.time && filters.value.unit) || (!filters.value.time && !filters.value.unit)) {
140 + getList()
141 + }
142 + },
143 + { debounce: 500 }
144 +)
145 +watch(
146 + () => filters.value.unit,
147 + () => {
148 + if ((filters.value.time && filters.value.unit) || (!filters.value.time && !filters.value.unit)) {
149 + getList()
150 + }
151 + }
152 +)
153 +
154 +onBeforeMount(() => {
155 + getList()
156 +})
157 +</script>
158 +
159 +<style lang="scss" scoped>
160 +.customer-healthcheck-list {
161 + min-height: 200px;
162 +
163 + .list {
164 + .title {
165 + margin-bottom: 10px;
166 +
167 + &.healthy {
168 + color: var(--primary-color);
169 + }
170 + &.unhealthy {
171 + color: var(--warning-color);
172 + }
173 + }
174 +
175 + &:not(:last-child) {
176 + margin-bottom: 20px;
177 + }
178 + }
179 +}
180 +</style>
src/components/customers/CustomerInfo.vue new
+110
@@ -0,0 +1,110 @@
1 +<template>
2 + <div class="customer-info">
3 + <div class="p-7 pt-4" v-if="editing">
4 + <CustomerForm @submitted="submitted" :customer="customer" :lockCode="true">
5 + <template #additionalActions>
6 + <n-button @click="editing = false">Close</n-button>
7 + </template>
8 + </CustomerForm>
9 + </div>
10 + <template v-else>
11 + <div class="flex items-center justify-between gap-4 px-7 pt-2">
12 + <n-button size="small" @click="editing = true" :disabled="loadingDelete">
13 + <template #icon>
14 + <Icon :name="EditIcon" :size="14"></Icon>
15 + </template>
16 + Edit
17 + </n-button>
18 + <n-button size="small" type="error" ghost @click="handleDelete" :loading="loadingDelete">
19 + <template #icon>
20 + <Icon :name="DeleteIcon" :size="15"></Icon>
21 + </template>
22 + Delete Customer
23 + </n-button>
24 + </div>
25 +
26 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4">
27 + <KVCard v-for="(value, key) of customer" :key="key">
28 + <template #key>{{ key }}</template>
29 + <template #value>{{ value || "-" }}</template>
30 + </KVCard>
31 + </div>
32 + </template>
33 + </div>
34 +</template>
35 +
36 +<script setup lang="ts">
37 +import Icon from "@/components/common/Icon.vue"
38 +import { h, ref, toRefs, watch } from "vue"
39 +import KVCard from "@/components/common/KVCard.vue"
40 +import CustomerForm from "./CustomerForm.vue"
41 +import Api from "@/api"
42 +import { useMessage, NButton, useDialog } from "naive-ui"
43 +import type { Customer } from "@/types/customers.d"
44 +
45 +const emit = defineEmits<{
46 + (e: "update:loading", value: boolean): void
47 + (e: "delete"): void
48 + (e: "submitted", value: Customer): void
49 +}>()
50 +
51 +const props = defineProps<{
52 + customer: Customer
53 +}>()
54 +const { customer } = toRefs(props)
55 +
56 +const EditIcon = "uil:edit-alt"
57 +const DeleteIcon = "ph:trash"
58 +
59 +const loadingDelete = ref(false)
60 +const editing = ref(false)
61 +const dialog = useDialog()
62 +const message = useMessage()
63 +
64 +function submitted(newData: Customer) {
65 + emit("submitted", newData)
66 + editing.value = false
67 +}
68 +
69 +function deleteCustomer() {
70 + loadingDelete.value = true
71 +
72 + Api.customers
73 + .deleteCustomer(customer.value.customer_code)
74 + .then(res => {
75 + if (res.data.success) {
76 + emit("delete")
77 + } else {
78 + message.warning(res.data?.message || "An error occurred. Please try again later.")
79 + }
80 + })
81 + .catch(err => {
82 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
83 + })
84 + .finally(() => {
85 + loadingDelete.value = false
86 + })
87 +}
88 +
89 +function handleDelete() {
90 + dialog.warning({
91 + title: "Confirm",
92 + content: () =>
93 + h("div", {
94 + innerHTML: `Are you sure you want to delete the Customer: <strong>${customer.value.customer_code}</strong> ?`
95 + }),
96 + positiveText: "Yes I'm sure",
97 + negativeText: "Cancel",
98 + onPositiveClick: () => {
99 + deleteCustomer()
100 + },
101 + onNegativeClick: () => {
102 + message.info("Delete canceled")
103 + }
104 + })
105 +}
106 +
107 +watch(loadingDelete, val => {
108 + emit("update:loading", val)
109 +})
110 +</script>
src/components/customers/CustomerItem.vue new
+311
@@ -0,0 +1,311 @@
1 +<template>
2 + <n-spin :show="loading" :class="{ highlight }" :id="'customer-' + customer.customer_code" class="customer-item">
3 + <div class="px-4 py-3 flex flex-col gap-2">
4 + <div class="header-box flex justify-between items-center">
5 + <div class="id">#{{ customer.customer_code }}</div>
6 + <div class="actions" v-if="!hideCardActions">
7 + <Badge type="cursor" @click="showDetails = true">
8 + <template #iconLeft>
9 + <Icon :name="DetailsIcon" :size="14"></Icon>
10 + </template>
11 + <template #value>Details</template>
12 + </Badge>
13 + </div>
14 + </div>
15 + <div class="main-box flex items-center gap-3">
16 + <n-avatar
17 + :src="customerInfo?.logo_file"
18 + fallback-src="/images/img-not-found.svg"
19 + round
20 + :size="40"
21 + lazy
22 + />
23 +
24 + <div class="content flex flex-col gap-1 grow">
25 + <div class="title">{{ customerInfo?.customer_name }}</div>
26 + <div class="description">
27 + {{ customerInfo?.contact_first_name }} {{ customerInfo?.contact_last_name }}
28 + </div>
29 + </div>
30 + </div>
31 +
32 + <div class="badges-box flex flex-wrap items-center gap-3 mt-2">
33 + <Badge type="splitted">
34 + <template #iconLeft>
35 + <Icon :name="UserTypeIcon" :size="14"></Icon>
36 + </template>
37 + <template #label>Type</template>
38 + <template #value>{{ customerInfo?.customer_type || "-" }}</template>
39 + </Badge>
40 + <n-popover trigger="hover">
41 + <template #trigger>
42 + <Badge type="splitted" class="cursor-help">
43 + <template #iconLeft>
44 + <Icon :name="LocationIcon" :size="13"></Icon>
45 + </template>
46 + <template #value>
47 + {{ [customerInfo?.city, customerInfo?.state].join(", ") || "-" }}
48 + </template>
49 + </Badge>
50 + </template>
51 +
52 + <div class="flex flex-col gap-1">
53 + <div class="box">
54 + address_line1:
55 + <code>{{ customerInfo?.address_line1 }}</code>
56 + </div>
57 + <div class="box">
58 + address_line2:
59 + <code>{{ customerInfo?.address_line2 }}</code>
60 + </div>
61 + <div class="box">
62 + postal_code:
63 + <code>{{ customerInfo?.postal_code }}</code>
64 + </div>
65 + <div class="box">
66 + city:
67 + <code>{{ customerInfo?.city }}</code>
68 + </div>
69 + <div class="box">
70 + state:
71 + <code>{{ customerInfo?.state }}</code>
72 + </div>
73 + <div class="box">
74 + country:
75 + <code>{{ customerInfo?.country }}</code>
76 + </div>
77 + </div>
78 + </n-popover>
79 + <Badge type="splitted">
80 + <template #iconLeft>
81 + <Icon :name="PhoneIcon" :size="13"></Icon>
82 + </template>
83 + <template #value>{{ customerInfo?.phone || "-" }}</template>
84 + </Badge>
85 + <Badge type="splitted" v-if="customerInfo?.parent_customer_code">
86 + <template #iconLeft>
87 + <Icon :name="ParentIcon" :size="13"></Icon>
88 + </template>
89 + <template #label>Parent</template>
90 + <template #value>{{ customerInfo?.parent_customer_code }}</template>
91 + </Badge>
92 + </div>
93 + </div>
94 +
95 + <n-modal
96 + v-model:show="showDetails"
97 + preset="card"
98 + content-style="padding:0px"
99 + :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
100 + :title="customerInfo?.customer_name"
101 + :bordered="false"
102 + segmented
103 + >
104 + <Transition :name="`slide-tabs-${selectedTabsGroup === 'customer' ? 'left' : 'right'}`">
105 + <n-tabs type="line" animated :tabs-padding="24" v-if="selectedTabsGroup === 'customer'">
106 + <n-tab-pane name="Info" tab="Info" display-directive="show:lazy">
107 + <CustomerInfoComponent
108 + :customer="customerInfo"
109 + @delete="deletedItem()"
110 + @submitted="customerInfo = $event"
111 + v-model:loading="loadingDelete"
112 + v-if="customerInfo"
113 + />
114 + </n-tab-pane>
115 + <n-tab-pane name="Meta" tab="Meta" display-directive="show:lazy">
116 + <CustomerMetaComponent
117 + :customerMeta="customerMeta"
118 + :customerCode="customer.customer_code"
119 + @delete="customerMeta = null"
120 + @submitted="customerMeta = $event"
121 + />
122 + </n-tab-pane>
123 + <template #suffix>
124 + <div class="pr-8 hover:text-primary-color cursor-pointer" @click="selectedTabsGroup = 'agents'">
125 + Agents
126 + </div>
127 + </template>
128 + </n-tabs>
129 + <n-tabs type="line" animated :tabs-padding="24" v-else-if="selectedTabsGroup === 'agents'">
130 + <template #prefix>
131 + <div
132 + class="pl-6 relative top-1 hover:text-primary-color cursor-pointer"
133 + @click="selectedTabsGroup = 'customer'"
134 + >
135 + <Icon :name="ArrowIcon" :size="20"></Icon>
136 + </div>
137 + </template>
138 + <n-tab-pane name="Agents" tab="Agents" display-directive="show:lazy">
139 + <n-scrollbar style="max-height: 470px" trigger="none">
140 + <CustomerAgents :customer="customerInfo" v-if="customerInfo" />
141 + </n-scrollbar>
142 + </n-tab-pane>
143 + <n-tab-pane name="Healthcheck Wazuh" tab="Healthcheck Wazuh" display-directive="show:lazy">
144 + <n-scrollbar style="max-height: 470px" trigger="none">
145 + <CustomerHealthcheckList source="wazuh" :customerCode="customer.customer_code" />
146 + </n-scrollbar>
147 + </n-tab-pane>
148 + <n-tab-pane
149 + name="Healthcheck Velociraptor"
150 + tab="Healthcheck Velociraptor"
151 + display-directive="show:lazy"
152 + >
153 + <n-scrollbar style="max-height: 470px" trigger="none">
154 + <CustomerHealthcheckList source="velociraptor" :customerCode="customer.customer_code" />
155 + </n-scrollbar>
156 + </n-tab-pane>
157 + </n-tabs>
158 + </Transition>
159 + </n-modal>
160 + </n-spin>
161 +</template>
162 +
163 +<script setup lang="ts">
164 +// TODO: add mablibre on location popup ??
165 +
166 +import Icon from "@/components/common/Icon.vue"
167 +import Badge from "@/components/common/Badge.vue"
168 +import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
169 +import CustomerInfoComponent from "./CustomerInfo.vue"
170 +import CustomerMetaComponent from "./CustomerMeta.vue"
171 +import CustomerAgents from "./CustomerAgents.vue"
172 +import CustomerHealthcheckList from "./CustomerHealthcheckList.vue"
173 +import Api from "@/api"
174 +import { NAvatar, useMessage, NPopover, NModal, NTabs, NTabPane, NSpin, NScrollbar } from "naive-ui"
175 +import type { Customer, CustomerMeta } from "@/types/customers.d"
176 +
177 +const emit = defineEmits<{
178 + (e: "delete"): void
179 +}>()
180 +
181 +const props = defineProps<{
182 + customer: Customer
183 + highlight?: boolean | null | undefined
184 + hideCardActions?: boolean | null | undefined
185 +}>()
186 +const { customer, highlight, hideCardActions } = toRefs(props)
187 +
188 +const DetailsIcon = "carbon:settings-adjust"
189 +const UserTypeIcon = "solar:shield-user-linear"
190 +const ParentIcon = "material-symbols-light:supervisor-account-outline-rounded"
191 +const ArrowIcon = "carbon:arrow-left"
192 +const LocationIcon = "carbon:location"
193 +const PhoneIcon = "carbon:phone"
194 +
195 +const showDetails = ref(false)
196 +const selectedTabsGroup = ref<"customer" | "agents">("customer")
197 +const loadingFull = ref(false)
198 +const loadingDelete = ref(false)
199 +const message = useMessage()
200 +const customerInfo = ref<Customer | null>(null)
201 +const customerMeta = ref<CustomerMeta | null>(null)
202 +
203 +const loading = computed(() => loadingFull.value || loadingDelete.value)
204 +
205 +function getFull() {
206 + loadingFull.value = true
207 +
208 + Api.customers
209 + .getCustomerFull(customer.value.customer_code)
210 + .then(res => {
211 + if (res.data.success) {
212 + customerInfo.value = res.data.customer
213 + customerMeta.value = res.data.customer_meta || null
214 + } else {
215 + message.warning(res.data?.message || "An error occurred. Please try again later.")
216 + }
217 + })
218 + .catch(err => {
219 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
220 + })
221 + .finally(() => {
222 + loadingFull.value = false
223 + })
224 +}
225 +
226 +function deletedItem() {
227 + showDetails.value = false
228 + emit("delete")
229 +}
230 +
231 +watch(showDetails, val => {
232 + if (val) {
233 + selectedTabsGroup.value = "customer"
234 +
235 + if (
236 + customer.value.customer_code &&
237 + (!customer.value.customer_name || !customerMeta.value?.customer_meta_graylog_index)
238 + ) {
239 + getFull()
240 + }
241 + }
242 +})
243 +
244 +onBeforeMount(() => {
245 + customerInfo.value = customer.value
246 +
247 + if (customer.value.customer_code && !customer.value.customer_name) {
248 + getFull()
249 + }
250 +})
251 +</script>
252 +
253 +<style lang="scss" scoped>
254 +.customer-item {
255 + border-radius: var(--border-radius);
256 + background-color: var(--bg-color);
257 + transition: all 0.2s var(--bezier-ease);
258 + border: var(--border-small-050);
259 +
260 + .header-box {
261 + font-size: 13px;
262 + .id {
263 + font-family: var(--font-family-mono);
264 + word-break: break-word;
265 + color: var(--fg-secondary-color);
266 + line-height: 1.2;
267 + }
268 + }
269 +
270 + .main-box {
271 + .content {
272 + word-break: break-word;
273 +
274 + .description {
275 + color: var(--fg-secondary-color);
276 + font-size: 13px;
277 + }
278 + }
279 + }
280 +
281 + &.highlight {
282 + box-shadow: 0px 0px 0px 1px inset var(--primary-color);
283 + }
284 +}
285 +</style>
286 +
287 +<style>
288 +.slide-tabs-right-enter-active,
289 +.slide-tabs-right-leave-active,
290 +.slide-tabs-left-enter-active,
291 +.slide-tabs-left-leave-active {
292 + transition: all 0.2s ease-out;
293 + position: absolute;
294 +}
295 +
296 +.slide-tabs-left-enter-from {
297 + transform: translateX(-100%);
298 +}
299 +
300 +.slide-tabs-left-leave-to {
301 + transform: translateX(100%);
302 +}
303 +
304 +.slide-tabs-right-enter-from {
305 + transform: translateX(100%);
306 +}
307 +
308 +.slide-tabs-right-leave-to {
309 + transform: translateX(-100%);
310 +}
311 +</style>
src/components/customers/CustomerMeta.vue new
+119
@@ -0,0 +1,119 @@
1 +<template>
2 + <div class="customer-meta">
3 + <div class="p-7 pt-4" v-if="editing">
4 + <CustomerMetaForm
5 + @submitted="submitted"
6 + :customerMeta="customerMeta || undefined"
7 + :customerCode="customerCode"
8 + >
9 + <template #additionalActions>
10 + <n-button @click="editing = false">Close</n-button>
11 + </template>
12 + </CustomerMetaForm>
13 + </div>
14 + <template v-else>
15 + <div class="flex items-center justify-between gap-4 px-7 pt-2" v-if="customerMeta">
16 + <n-button size="small" @click="editing = true" :disabled="loadingDelete">
17 + <template #icon>
18 + <Icon :name="EditIcon" :size="14"></Icon>
19 + </template>
20 + Edit
21 + </n-button>
22 + <n-button size="small" type="error" ghost @click="handleDelete" :loading="loadingDelete">
23 + <template #icon>
24 + <Icon :name="DeleteIcon" :size="15"></Icon>
25 + </template>
26 + Clear Meta
27 + </n-button>
28 + </div>
29 + <div class="flex items-center justify-between gap-4 px-7 pt-2" v-else>
30 + <n-button size="small" @click="editing = true" type="primary">
31 + <template #icon>
32 + <Icon :name="AddIcon" :size="14"></Icon>
33 + </template>
34 + Add Meta
35 + </n-button>
36 + </div>
37 +
38 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4">
39 + <KVCard v-for="(value, key) of customerMeta" :key="key">
40 + <template #key>{{ key }}</template>
41 + <template #value>{{ value || "-" }}</template>
42 + </KVCard>
43 + </div>
44 + </template>
45 + </div>
46 +</template>
47 +
48 +<script setup lang="ts">
49 +import Icon from "@/components/common/Icon.vue"
50 +import { h, ref, toRefs } from "vue"
51 +import KVCard from "@/components/common/KVCard.vue"
52 +import CustomerMetaForm from "./CustomerMetaForm.vue"
53 +import Api from "@/api"
54 +import { useMessage, NButton, useDialog } from "naive-ui"
55 +import type { CustomerMeta } from "@/types/customers.d"
56 +
57 +const emit = defineEmits<{
58 + (e: "delete"): void
59 + (e: "submitted", value: CustomerMeta): void
60 +}>()
61 +
62 +const props = defineProps<{
63 + customerMeta?: CustomerMeta | null
64 + customerCode: string
65 +}>()
66 +const { customerMeta, customerCode } = toRefs(props)
67 +
68 +const EditIcon = "uil:edit-alt"
69 +const DeleteIcon = "ph:trash"
70 +const AddIcon = "carbon:add-alt"
71 +
72 +const loadingDelete = ref(false)
73 +const editing = ref(false)
74 +const dialog = useDialog()
75 +const message = useMessage()
76 +
77 +function submitted(newData: CustomerMeta) {
78 + emit("submitted", newData)
79 + editing.value = false
80 +}
81 +
82 +function deleteCustomer() {
83 + loadingDelete.value = true
84 +
85 + Api.customers
86 + .deleteCustomerMeta(customerCode.value)
87 + .then(res => {
88 + if (res.data.success) {
89 + emit("delete")
90 + } else {
91 + message.warning(res.data?.message || "An error occurred. Please try again later.")
92 + }
93 + })
94 + .catch(err => {
95 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
96 + })
97 + .finally(() => {
98 + loadingDelete.value = false
99 + })
100 +}
101 +
102 +function handleDelete() {
103 + dialog.warning({
104 + title: "Confirm",
105 + content: () =>
106 + h("div", {
107 + innerHTML: `Are you sure you want to delete Meta tags for the Customer: <strong>${customerCode.value}</strong> ?`
108 + }),
109 + positiveText: "Yes I'm sure",
110 + negativeText: "Cancel",
111 + onPositiveClick: () => {
112 + deleteCustomer()
113 + },
114 + onNegativeClick: () => {
115 + message.info("Delete canceled")
116 + }
117 + })
118 +}
119 +</script>
src/components/customers/CustomerMetaForm.vue new
+244
@@ -0,0 +1,244 @@
1 +<template>
2 + <n-spin :show="loading" class="customer-meta-form">
3 + <n-form :label-width="80" :model="form" :rules="rules" ref="formRef">
4 + <div class="flex flex-col gap-4">
5 + <div class="flex flex-wrap gap-4">
6 + <div v-for="(val, key) of form" :key="key" class="grow">
7 + <n-form-item :label="fieldsMeta[key].label" :path="key" class="grow">
8 + <n-input
9 + v-model:value.trim="form[key]"
10 + :placeholder="fieldsMeta[key].placeholder"
11 + clearable
12 + />
13 + </n-form-item>
14 + </div>
15 + </div>
16 + <div class="flex justify-between gap-4">
17 + <div class="flex gap-4">
18 + <slot name="additionalActions"></slot>
19 + </div>
20 + <div class="flex gap-4">
21 + <n-button @click="reset()" :disabled="loading">Reset</n-button>
22 + <n-button type="primary" :disabled="!isValid" @click="validate()" :loading="loading">
23 + Submit
24 + </n-button>
25 + </div>
26 + </div>
27 + </div>
28 + </n-form>
29 + </n-spin>
30 +</template>
31 +
32 +<script setup lang="ts">
33 +import { computed, onBeforeMount, onMounted, ref, toRefs, watch } from "vue"
34 +import Api from "@/api"
35 +import {
36 + useMessage,
37 + NForm,
38 + NFormItem,
39 + NInput,
40 + NButton,
41 + NSpin,
42 + type FormValidationError,
43 + type FormInst,
44 + type FormRules
45 +} from "naive-ui"
46 +import type { CustomerMeta } from "@/types/customers.d"
47 +import _trim from "lodash/trim"
48 +import _get from "lodash/get"
49 +
50 +const emit = defineEmits<{
51 + (e: "update:loading", value: boolean): void
52 + (e: "submitted", value: CustomerMeta): void
53 + (
54 + e: "mounted",
55 + value: {
56 + reset: () => void
57 + }
58 + ): void
59 +}>()
60 +
61 +const props = defineProps<{
62 + customerMeta?: CustomerMeta
63 + customerCode: string
64 + resetOnSubmit?: boolean
65 +}>()
66 +const { customerMeta, customerCode, resetOnSubmit } = toRefs(props)
67 +
68 +const loading = ref(false)
69 +const message = useMessage()
70 +const form = ref<CustomerMeta>(getClearForm())
71 +const formRef = ref<FormInst | null>(null)
72 +
73 +const rules: FormRules = {
74 + customer_meta_graylog_index: {
75 + required: true,
76 + message: "Please input Graylog Index",
77 + trigger: ["input", "blur"]
78 + },
79 + customer_meta_graylog_stream: {
80 + required: true,
81 + message: "Please input Graylog Stream",
82 + trigger: ["input", "blur"]
83 + },
84 + customer_meta_grafana_org_id: {
85 + required: true,
86 + message: "Please input Grafana Org Id",
87 + trigger: ["input", "blur"]
88 + },
89 + customer_meta_index_retention: {
90 + required: true,
91 + message: "Please input Index retention",
92 + trigger: ["input", "blur"]
93 + },
94 + customer_meta_wazuh_group: {
95 + required: true,
96 + message: "Please input Wazuh Group",
97 + trigger: ["input", "blur"]
98 + },
99 + customer_meta_wazuh_registration_port: {
100 + required: true,
101 + message: "Please input Wazuh registration port",
102 + trigger: ["input", "blur"]
103 + },
104 + customer_meta_wazuh_log_ingestion_port: {
105 + required: true,
106 + message: "Please input Wazuh log ingestion port",
107 + trigger: ["input", "blur"]
108 + },
109 + customer_meta_wazuh_auth_password: {
110 + required: true,
111 + message: "Please input Wazuh auth password",
112 + trigger: ["input", "blur"]
113 + }
114 +}
115 +
116 +const fieldsMeta = {
117 + customer_meta_graylog_index: {
118 + label: "Graylog Index",
119 + placeholder: "Graylog Index..."
120 + },
121 + customer_meta_graylog_stream: {
122 + label: "Graylog Stream",
123 + placeholder: "Graylog Stream..."
124 + },
125 + customer_meta_grafana_org_id: {
126 + label: "Grafana Org Id",
127 + placeholder: "Grafana Org Id..."
128 + },
129 + customer_meta_index_retention: {
130 + label: "Index retention",
131 + placeholder: "Index retention..."
132 + },
133 + customer_meta_wazuh_group: {
134 + label: "Wazuh Group",
135 + placeholder: "Wazuh Group..."
136 + },
137 + customer_meta_wazuh_registration_port: {
138 + label: "Wazuh registration port",
139 + placeholder: "Wazuh registration port..."
140 + },
141 + customer_meta_wazuh_log_ingestion_port: {
142 + label: "Wazuh log ingestion port",
143 + placeholder: "Wazuh log ingestion port..."
144 + },
145 + customer_meta_wazuh_auth_password: {
146 + label: "Wazuh auth password",
147 + placeholder: "Wazuh auth password..."
148 + }
149 +}
150 +
151 +const isValid = computed(() => {
152 + let valid = true
153 +
154 + for (const key in rules) {
155 + const rule = rules[key] as FormRules
156 +
157 + if (rule.required && !_trim(_get(form.value, key))) {
158 + valid = false
159 + }
160 + }
161 +
162 + return valid
163 +})
164 +
165 +function validate() {
166 + if (!formRef.value) return
167 +
168 + formRef.value.validate((errors?: Array<FormValidationError>) => {
169 + if (!errors) {
170 + submit()
171 + } else {
172 + message.warning("You must fill in the required fields correctly.")
173 + return false
174 + }
175 + })
176 +}
177 +
178 +function getClearForm(customerMeta?: Partial<CustomerMeta>) {
179 + return {
180 + customer_meta_graylog_index: customerMeta?.customer_meta_graylog_index || "",
181 + customer_meta_graylog_stream: customerMeta?.customer_meta_graylog_stream || "",
182 + customer_meta_grafana_org_id: customerMeta?.customer_meta_grafana_org_id || "",
183 + customer_meta_index_retention: customerMeta?.customer_meta_index_retention || "",
184 + customer_meta_wazuh_group: customerMeta?.customer_meta_wazuh_group || "",
185 + customer_meta_wazuh_registration_port: customerMeta?.customer_meta_wazuh_registration_port || "",
186 + customer_meta_wazuh_log_ingestion_port: customerMeta?.customer_meta_wazuh_log_ingestion_port || "",
187 + customer_meta_wazuh_auth_password: customerMeta?.customer_meta_wazuh_auth_password || ""
188 + }
189 +}
190 +
191 +function reset() {
192 + form.value = getClearForm()
193 +}
194 +
195 +function submit() {
196 + loading.value = true
197 +
198 + const method = customerMeta.value?.customer_meta_graylog_index ? "updateCustomerMeta" : "createCustomerMeta"
199 +
200 + Api.customers[method](form.value, customerCode.value)
201 + .then(res => {
202 + if (res.data.success) {
203 + emit("submitted", res.data.customer_meta)
204 + if (resetOnSubmit.value) {
205 + reset()
206 + }
207 + } else {
208 + message.warning(res.data?.message || "An error occurred. Please try again later.")
209 + }
210 + })
211 + .catch(err => {
212 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
213 + })
214 + .finally(() => {
215 + loading.value = false
216 + })
217 +}
218 +
219 +function setForm() {
220 + form.value = getClearForm(customerMeta.value)
221 +}
222 +
223 +watch(loading, val => {
224 + emit("update:loading", val)
225 +})
226 +
227 +watch(customerMeta, val => {
228 + if (val) {
229 + setForm()
230 + }
231 +})
232 +
233 +onBeforeMount(() => {
234 + if (customerMeta.value) {
235 + setForm()
236 + }
237 +})
238 +
239 +onMounted(() => {
240 + emit("mounted", {
241 + reset
242 + })
243 +})
244 +</script>
src/components/customers/CustomersList.vue new
+135
@@ -0,0 +1,135 @@
1 +<template>
2 + <div class="customers-list">
3 + <div class="header mb-4 flex gap-2 justify-between">
4 + <div>
5 + Total:
6 + <strong class="font-mono">{{ totalCustomers }}</strong>
7 + </div>
8 + <div>
9 + <n-button size="small" type="primary" @click="showAddCustomer = true">Add Customer</n-button>
10 + </div>
11 + </div>
12 + <n-spin :show="loadingCustomers">
13 + <div class="list">
14 + <template v-if="customersList.length">
15 + <CustomerItem
16 + v-for="customer of customersList"
17 + :key="customer.customer_code"
18 + :customer="customer"
19 + :highlight="customer.customer_code === highlight"
20 + :hideCardActions="loadingCustomers"
21 + @delete="getCustomers()"
22 + class="item-appear item-appear-bottom item-appear-005 mb-2"
23 + />
24 + </template>
25 + <template v-else>
26 + <n-empty description="No items found" class="justify-center h-48" v-if="!loadingCustomers" />
27 + </template>
28 + </div>
29 + </n-spin>
30 +
31 + <n-drawer
32 + v-model:show="showAddCustomer"
33 + :width="500"
34 + style="max-width: 90vw"
35 + :trap-focus="false"
36 + display-directive="show"
37 + >
38 + <n-drawer-content title="Add Customer" closable :native-scrollbar="false">
39 + <CustomerForm @mounted="customerFormCTX = $event" @submitted="getCustomers()" :resetOnSubmit="true" />
40 + </n-drawer-content>
41 + </n-drawer>
42 + </div>
43 +</template>
44 +
45 +<script setup lang="ts">
46 +import { ref, onBeforeMount, computed, watch, toRefs, nextTick } from "vue"
47 +import { useMessage, NSpin, NEmpty, NButton, NDrawer, NDrawerContent } from "naive-ui"
48 +import Api from "@/api"
49 +import CustomerForm from "./CustomerForm.vue"
50 +import CustomerItem from "./CustomerItem.vue"
51 +import type { Customer } from "@/types/customers.d"
52 +
53 +const props = defineProps<{ highlight: string | null | undefined }>()
54 +const { highlight } = toRefs(props)
55 +
56 +const customerFormCTX = ref<{ reset: () => void } | null>(null)
57 +const message = useMessage()
58 +const loadingCustomers = ref(false)
59 +const showAddCustomer = ref(false)
60 +const customersList = ref<Customer[]>([])
61 +
62 +const totalCustomers = computed<number>(() => {
63 + return customersList.value.length || 0
64 +})
65 +
66 +function getCustomers() {
67 + loadingCustomers.value = true
68 +
69 + Api.customers
70 + .getCustomers()
71 + .then(res => {
72 + if (res.data.success) {
73 + customersList.value = res.data?.customers || []
74 + } else {
75 + message.warning(res.data?.message || "An error occurred. Please try again later.")
76 + }
77 + })
78 + .catch(err => {
79 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
80 + })
81 + .finally(() => {
82 + loadingCustomers.value = false
83 + })
84 +}
85 +
86 +function scrollToAlert(id: string) {
87 + const element = document.getElementById(`customer-${id}`)
88 + const scrollContent = document.querySelector("#main > .n-scrollbar > .n-scrollbar-container") as HTMLElement
89 +
90 + if (element && scrollContent) {
91 + const wrap: HTMLElement = scrollContent
92 + const middle = element.offsetTop - wrap.offsetHeight / 2
93 + scrollContent?.scrollTo({ top: middle, behavior: "smooth" })
94 + }
95 +}
96 +
97 +watch(showAddCustomer, () => {
98 + customerFormCTX.value?.reset()
99 +})
100 +
101 +watch(loadingCustomers, val => {
102 + if (!val) {
103 + nextTick(() => {
104 + setTimeout(() => {
105 + if (highlight.value) {
106 + scrollToAlert(highlight.value)
107 + }
108 + }, 300)
109 + })
110 + }
111 +})
112 +
113 +watch(highlight, val => {
114 + if (val) {
115 + nextTick(() => {
116 + setTimeout(() => {
117 + scrollToAlert(val)
118 + })
119 + })
120 + }
121 +})
122 +
123 +onBeforeMount(() => {
124 + getCustomers()
125 +})
126 +</script>
127 +
128 +<style lang="scss" scoped>
129 +.customers-list {
130 + .list {
131 + container-type: inline-size;
132 + min-height: 200px;
133 + }
134 +}
135 +</style>
src/components/graylog/Events/Item.vue
+1 -1
@@ -42,7 +42,7 @@
42 :bordered="false"
43 segmented
44 >
45 - <n-tabs type="line" animated justify-content="space-evenly">
45 + <n-tabs type="line" animated :tabs-padding="24">
46 <n-tab-pane name="query" tab="Query" display-directive="show">
47 <div class="p-7 pt-4">
48 <n-input
src/components/graylog/Inputs/Item.vue
+1 -1
@@ -74,7 +74,7 @@
74 :bordered="false"
75 segmented
76 >
77 - <n-tabs type="line" animated justify-content="space-evenly">
77 + <n-tabs type="line" animated :tabs-padding="24">
78 <n-tab-pane name="info" tab="Info" display-directive="show:lazy">
79 <div class="p-7 pt-4">
80 <div class="mb-2">
src/components/graylog/Pipelines/PipeInfo.vue
+1 -1
@@ -1,5 +1,5 @@
1 <template>
2 - <n-tabs type="line" animated justify-content="space-evenly">
2 + <n-tabs type="line" animated :tabs-padding="24">
3 <n-tab-pane name="info" tab="Info" display-directive="show">
4 <div class="p-7 pt-4">
5 <div class="mb-2">
src/components/healthcheck/HealthcheckList.vue
+1 -22
@@ -41,7 +41,7 @@
41 v-for="alert of itemsPaginated"
42 :key="alert.checkID + alert.time"
43 :alert="alert"
44 - class="mb-2"
44 + class="item-appear item-appear-bottom item-appear-005 mb-2"
45 />
46 </template>
47 <template v-else>
@@ -142,27 +142,6 @@ onBeforeMount(() => {
142 .list {
143 container-type: inline-size;
144 min-height: 200px;
145 -
146 - .healthcheck-item {
147 - animation: healthcheck-item-fade 0.3s forwards;
148 - opacity: 0;
149 -
150 - @for $i from 0 through 30 {
151 - &:nth-child(#{$i}) {
152 - animation-delay: $i * 0.05s;
153 - }
154 - }
155 -
156 - @keyframes healthcheck-item-fade {
157 - from {
158 - opacity: 0;
159 - transform: translateY(10px);
160 - }
161 - to {
162 - opacity: 1;
163 - }
164 - }
165 - }
145 }
146 }
147 </style>
src/components/soc/SocAlertItem.vue
+6 -12
@@ -131,13 +131,13 @@
131 preset="card"
132 content-style="padding:0px"
133 :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(550px, 90vh)', overflow: 'hidden' }"
134 - :title="`#${alert.alert_id} - ${alert.alert_uuid}`"
134 + :title="`SOC Alert: #${alert.alert_id} - ${alert.alert_uuid}`"
135 :bordered="false"
136 segmented
137 >
138 - <n-tabs type="line" animated justify-content="space-evenly">
138 + <n-tabs type="line" animated :tabs-padding="24">
139 <n-tab-pane name="Context" tab="Context" display-directive="show:lazy">
140 - <div class="grid gap-2 soc-alert-context-grid p-7 pt-4">
140 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4">
141 <KVCard v-for="(value, key) of alert.alert_context" :key="key">
142 <template #key>{{ key }}</template>
143 <template #value>{{ value ?? "-" }}</template>
@@ -150,7 +150,7 @@
150 </div>
151 </n-tab-pane>
152 <n-tab-pane name="Customer" tab="Customer" display-directive="show:lazy">
153 - <div class="grid gap-2 soc-alert-context-grid p-7 pt-4">
153 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4">
154 <KVCard v-for="(value, key) of alert.customer" :key="key">
155 <template #key>{{ key }}</template>
156 <template #value>{{ value || "-" }}</template>
@@ -171,7 +171,7 @@
171 <template #label>Go to users page</template>
172 </Badge>
173 </div>
174 - <div class="grid gap-2 soc-alert-context-grid p-7 pt-4">
174 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4">
175 <KVCard>
176 <template #key>user_login</template>
177 <template #value>
@@ -225,6 +225,7 @@
225 </template>
226
227 <script setup lang="ts">
228 +// TODO: add customer goto function ??
229 import AlertItem from "@/components/alerts/Alert.vue"
230 import type { SocAlert } from "@/types/soc/alert.d"
231 import type { Alert } from "@/types/alerts.d"
@@ -435,10 +436,3 @@ onBeforeMount(() => {
436 }
437 }
438 </style>
438 -
439 -<style lang="scss">
440 -.soc-alert-context-grid {
441 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
442 - grid-auto-flow: row dense;
443 -}
444 -</style>
src/components/soc/SocAlertsList.vue
+1 -22
@@ -18,7 +18,7 @@
18 v-for="alert of list"
19 :key="alert.id"
20 :alert="alert.item"
21 - class="mb-2"
21 + class="item-appear item-appear-bottom item-appear-005 mb-2"
22 :is-bookmark="alert.isBookmark"
23 :users="usersList"
24 :highlight="alert.id.toString() === highlight"
@@ -193,27 +193,6 @@ onBeforeMount(() => {
193 .list {
194 container-type: inline-size;
195 min-height: 200px;
196 -
197 - .soc-alert-item {
198 - animation: soc-alert-item-fade 0.3s forwards;
199 - opacity: 0;
200 -
201 - @for $i from 0 through 30 {
202 - &:nth-child(#{$i}) {
203 - animation-delay: $i * 0.05s;
204 - }
205 - }
206 -
207 - @keyframes soc-alert-item-fade {
208 - from {
209 - opacity: 0;
210 - transform: translateY(10px);
211 - }
212 - to {
213 - opacity: 1;
214 - }
215 - }
216 - }
196 }
197 }
198 </style>
src/components/soc/SocCaseAssetsItem.vue
+3 -9
@@ -37,13 +37,13 @@
37 preset="card"
38 content-style="padding:0px"
39 :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
40 - :title="`#${asset.asset_id} - ${asset.asset_uuid}`"
40 + :title="`Assets #${asset.asset_id} - ${asset.asset_uuid}`"
41 :bordered="false"
42 segmented
43 >
44 - <n-tabs type="line" animated justify-content="space-evenly">
44 + <n-tabs type="line" animated :tabs-padding="24">
45 <n-tab-pane name="Info" tab="Info" display-directive="show">
46 - <div class="grid gap-2 soc-case-context-grid p-7 pt-4" v-if="properties">
46 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4" v-if="properties">
47 <KVCard v-for="(value, key) of properties" :key="key">
48 <template #key>{{ key }}</template>
49 <template #value>{{ value || "-" }}</template>
@@ -180,9 +180,3 @@ const properties = computed(() => {
180 }
181 }
182 </style>
183 -<style lang="scss">
184 -.soc-case-context-grid {
185 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
186 - grid-auto-flow: row dense;
187 -}
188 -</style>
src/components/soc/SocCaseItem.vue
+5 -9
@@ -86,11 +86,11 @@
86 preset="card"
87 content-style="padding:0px"
88 :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(550px, 90vh)', overflow: 'hidden' }"
89 - :title="caseData.case_uuid"
89 + :title="'SOC Case: ' + caseData.case_uuid"
90 :bordered="false"
91 segmented
92 >
93 - <n-tabs type="line" animated justify-content="space-evenly">
93 + <n-tabs type="line" animated :tabs-padding="24">
94 <n-tab-pane name="Info" tab="Info" display-directive="show">
95 <n-spin :show="loadingDetails">
96 <div class="px-7 py-4" v-if="extendedInfo">
@@ -117,7 +117,7 @@
117 </code>
118 </div>
119 </div>
120 - <div class="grid gap-2 soc-case-context-grid p-7 pt-4" v-if="properties">
120 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4" v-if="properties">
121 <KVCard v-for="(value, key) of properties" :key="key">
122 <template #key>{{ key }}</template>
123 <template #value>{{ value || "-" }}</template>
@@ -180,6 +180,8 @@
180 </template>
181
182 <script setup lang="ts">
183 +// TODO: add customer goto function ??
184 +
185 import Icon from "@/components/common/Icon.vue"
186 import KVCard from "@/components/common/KVCard.vue"
187 import Badge from "@/components/common/Badge.vue"
@@ -378,9 +380,3 @@ watch(showDetails, val => {
380 }
381 }
382 </style>
381 -<style lang="scss">
382 -.soc-case-context-grid {
383 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
384 - grid-auto-flow: row dense;
385 -}
386 -</style>
src/components/soc/SocCaseNote.vue
+3 -9
@@ -57,13 +57,13 @@
57 preset="card"
58 content-style="padding:0px"
59 :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
60 - :title="`#${note.note_id} - ${note.note_details.note_uuid}`"
60 + :title="`Note: #${note.note_id} - ${note.note_details.note_uuid}`"
61 :bordered="false"
62 segmented
63 >
64 - <n-tabs type="line" animated justify-content="space-evenly">
64 + <n-tabs type="line" animated :tabs-padding="24">
65 <n-tab-pane name="Info" tab="Info" display-directive="show">
66 - <div class="grid gap-2 soc-case-context-grid p-7 pt-4" v-if="properties">
66 + <div class="grid gap-2 grid-auto-flow-200 p-7 pt-4" v-if="properties">
67 <KVCard v-for="(value, key) of properties" :key="key">
68 <template #key>{{ key }}</template>
69 <template #value>{{ value || "-" }}</template>
@@ -196,9 +196,3 @@ const properties = computed(() => {
196 }
197 }
198 </style>
199 -<style lang="scss">
200 -.soc-case-context-grid {
201 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
202 - grid-auto-flow: row dense;
203 -}
204 -</style>
src/components/soc/SocCasesList.vue
+3 -24
@@ -55,7 +55,7 @@
55 <n-input-group>
56 <n-select
57 v-model:value="filters.unit"
58 - :options="timeOptions"
58 + :options="unitOptions"
59 placeholder="Time unit"
60 clearable
61 class="!w-28"
@@ -82,7 +82,7 @@
82 v-for="caseData of itemsPaginated"
83 :key="caseData.case_id"
84 :caseData="caseData"
85 - class="mb-2"
85 + class="item-appear item-appear-bottom item-appear-005 mb-2"
86 />
87 </template>
88 <template v-else>
@@ -169,7 +169,7 @@ const filtered = computed<boolean>(() => {
169 return !!filters.value.unit && !!filters.value.olderThan
170 })
171
172 -const timeOptions = [
172 +const unitOptions = [
173 { label: "Hours", value: "hours" },
174 { label: "Days", value: "days" },
175 { label: "Weeks", value: "weeks" }
@@ -224,27 +224,6 @@ onBeforeMount(() => {
224 .list {
225 container-type: inline-size;
226 min-height: 200px;
227 -
228 - .soc-case-item {
229 - animation: soc-case-item-fade 0.3s forwards;
230 - opacity: 0;
231 -
232 - @for $i from 0 through 30 {
233 - &:nth-child(#{$i}) {
234 - animation-delay: $i * 0.05s;
235 - }
236 - }
237 -
238 - @keyframes soc-case-item-fade {
239 - from {
240 - opacity: 0;
241 - transform: translateY(10px);
242 - }
243 - to {
244 - opacity: 1;
245 - }
246 - }
247 - }
227 }
228 }
229 </style>
src/layouts/common/Navbar/items.tsx
+14
@@ -200,6 +200,20 @@ export default function getItems(mode: "vertical" | "horizontal", collapsed: boo
200 key: "Healthcheck",
201 icon: renderIcon(BlankIcon)
202 },
203 + {
204 + label: () =>
205 + h(
206 + RouterLink,
207 + {
208 + to: {
209 + name: "Customers"
210 + }
211 + },
212 + { default: () => "Customers" }
213 + ),
214 + key: "Customers",
215 + icon: renderIcon(BlankIcon)
216 + },
217 {
218 type: "divider"
219 },
src/router/index.ts
+6
@@ -110,6 +110,12 @@ const router = createRouter({
110 component: () => import("@/views/socfortress/Healthcheck.vue"),
111 meta: { title: "Healthcheck", auth: true, roles: UserRole.All }
112 },
113 + {
114 + path: "/customers",
115 + name: "Customers",
116 + component: () => import("@/views/socfortress/Customers.vue"),
117 + meta: { title: "Customers", auth: true, roles: UserRole.All }
118 + },
119
120 // DEMO PAGES ==========================================================
121
src/types/customers.d.ts new
+50
@@ -0,0 +1,50 @@
1 +// required: customer_code, customer_name, contact_last_name, contact_first_name
2 +export interface Customer {
3 + customer_code: string
4 + customer_name: string
5 + contact_last_name: string
6 + contact_first_name: string
7 + parent_customer_code: string | null
8 + phone: string
9 + address_line1: string
10 + address_line2: string
11 + city: string
12 + state: string
13 + postal_code: string
14 + country: string
15 + customer_type: string
16 + logo_file: string
17 +}
18 +
19 +// all required
20 +export interface CustomerMeta {
21 + customer_meta_graylog_index: string
22 + customer_meta_graylog_stream: string
23 + customer_meta_grafana_org_id: string
24 + customer_meta_wazuh_group: string
25 + customer_meta_index_retention: string
26 + customer_meta_wazuh_registration_port: string
27 + customer_meta_wazuh_log_ingestion_port: string
28 + customer_meta_wazuh_auth_password: string
29 +}
30 +
31 +export type CustomerHealthcheckSource = "wazuh" | "velociraptor"
32 +
33 +export interface CustomerAgentHealth {
34 + id: number
35 + os: string
36 + label: string
37 + wazuh_last_seen: string
38 + velociraptor_last_seen: string
39 + velociraptor_agent_version: string
40 + ip_address: string
41 + agent_id: string
42 + hostname: string
43 + critical_asset: boolean
44 + velociraptor_id: string
45 + wazuh_agent_version: string
46 + customer_code: string
47 + unhealthy_wazuh_agent: boolean | null
48 + unhealthy_velociraptor_agent: boolean | null
49 + unhealthy_recent_logs_collected: null
50 +}
src/utils/index.ts
+12
@@ -35,6 +35,18 @@ export function renderIcon(icon: Component | string) {
35 }
36 }
37
38 +export function iconFromOs(os: string): string {
39 + const test = os.toLowerCase()
40 + if (test.indexOf("mac") !== -1 || test.indexOf("darwin") !== -1 || test.indexOf("apple") !== -1) {
41 + return "uit:apple-alt"
42 + }
43 + if (test.indexOf("win") !== -1 || test.indexOf("microsoft") !== -1) {
44 + return "arcticons:microsoft-alt"
45 + }
46 +
47 + return "uil:linux"
48 +}
49 +
50 export function getOS(): OS {
51 let os: OS = "Unknown"
52 if (navigator.userAgent.indexOf("Win") != -1) os = "Windows"
src/views/Apps/Chat.vue
+1 -19
@@ -82,7 +82,7 @@
82 <div
83 v-for="conversation of store.activeChat.conversation"
84 :key="conversation.id"
85 - class="conversation flex"
85 + class="conversation item-appear item-appear-bottom item-appear-010 flex"
86 :class="{ mine: conversation.isMine }"
87 >
88 <div class="avatar">
@@ -443,24 +443,6 @@ useHideLayoutFooter()
443 .conversation {
444 padding: 20px 30px;
445 gap: 14px;
446 - opacity: 0;
447 - animation: conversation-fade 0.3s forwards;
448 -
449 - @for $i from 0 through 40 {
450 - &:nth-last-child(#{$i}) {
451 - animation-delay: $i * 0.1s;
452 - }
453 - }
454 -
455 - @keyframes conversation-fade {
456 - from {
457 - opacity: 0;
458 - transform: translateY(10px);
459 - }
460 - to {
461 - opacity: 1;
462 - }
463 - }
446
447 .messages-group {
448 width: fit-content;
src/views/Apps/Kanban.vue
+6 -1
@@ -37,7 +37,12 @@
37 </div>
38 </template>
39 <template #item="{ element: task }">
40 - <TaskCard :task="task" :mobile="isMobile()" @click="selectTask(task)" />
40 + <TaskCard
41 + :task="task"
42 + :mobile="isMobile()"
43 + @click="selectTask(task)"
44 + class="item-appear item-appear-bottom item-appear-005"
45 + />
46 </template>
47 <template #footer>
48 <button
src/views/Apps/Mailbox.vue
+1
@@ -127,6 +127,7 @@
127 :key="email.id"
128 :email="email"
129 @select="selectedEmail = $event"
130 + class="item-appear item-appear-bottom item-appear-005"
131 />
132 </n-scrollbar>
133 </div>
src/views/socfortress/Agents.vue
+1 -21
@@ -25,6 +25,7 @@
25 show-actions
26 @delete="syncAgents()"
27 @click="gotoAgentPage(agent)"
28 + class="item-appear item-appear-bottom item-appear-005"
29 />
30 </template>
31 <template v-else>
@@ -177,27 +178,6 @@ onBeforeMount(() => {
178
179 .agents-list {
180 width: 100%;
180 -
181 - .agent-card {
182 - opacity: 0;
183 - animation: agent-card-fade 0.3s forwards;
184 -
185 - @for $i from 0 through 20 {
186 - &:nth-child(#{$i}) {
187 - animation-delay: $i * 0.05s;
188 - }
189 - }
190 -
191 - @keyframes agent-card-fade {
192 - from {
193 - opacity: 0;
194 - transform: translateY(10px);
195 - }
196 - to {
197 - opacity: 1;
198 - }
199 - }
200 - }
181 }
182 }
183 @container (max-width: 770px) {
src/views/socfortress/Customers.vue new
+21
@@ -0,0 +1,21 @@
1 +<template>
2 + <div class="page">
3 + <CustomersList :highlight="highlight" />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import CustomersList from "@/components/customers/CustomersList.vue"
9 +import { onBeforeMount, ref } from "vue"
10 +import { useRoute } from "vue-router"
11 +
12 +const route = useRoute()
13 +
14 +const highlight = ref<string | undefined>(undefined)
15 +
16 +onBeforeMount(() => {
17 + if (route.query?.code) {
18 + highlight.value = route.query.code.toString()
19 + }
20 +})
21 +</script>