Skip to content

Commit 1c30685

Browse files
author
vsilent
committed
logs, containers in ui, ports, ws
1 parent c845579 commit 1c30685

File tree

11 files changed

+283
-20
lines changed

11 files changed

+283
-20
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ ebpf = []
7979
# Testing
8080
tokio-test = "0.4"
8181
tempfile = "3"
82+
actix-test = "0.1"
83+
awc = "3"
8284

8385
# Benchmarking
8486
criterion = { version = "0.5", features = ["html_reports"] }

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ services:
1919
echo "Starting Stackdog..."
2020
cargo run --bin stackdog
2121
ports:
22-
- "${APP_PORT:-8080}:${APP_PORT:-8080}"
22+
- "${APP_PORT:-5000}:${APP_PORT:-5000}"
2323
env_file:
2424
- .env
2525
environment:

src/api/containers.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,17 @@ fn to_container_response(
8383
security: &crate::docker::containers::ContainerSecurityStatus,
8484
stats: Option<&ContainerStats>,
8585
) -> ContainerResponse {
86+
let effective_status = if security.security_state == "Quarantined" {
87+
"Quarantined".to_string()
88+
} else {
89+
container.status.clone()
90+
};
91+
8692
ContainerResponse {
8793
id: container.id.clone(),
8894
name: container.name.clone(),
8995
image: container.image.clone(),
90-
status: container.status.clone(),
96+
status: effective_status,
9197
security_status: ApiContainerSecurityStatus {
9298
state: security.security_state.clone(),
9399
threats: security.threats,
@@ -261,6 +267,24 @@ mod tests {
261267
assert_eq!(response.network_activity.blocked_connections, None);
262268
}
263269

270+
#[actix_rt::test]
271+
async fn test_to_container_response_marks_quarantined_status_from_security_state() {
272+
let response = to_container_response(
273+
&sample_container(),
274+
&crate::docker::containers::ContainerSecurityStatus {
275+
container_id: "container-1".into(),
276+
risk_score: 88,
277+
threats: 3,
278+
security_state: "Quarantined".into(),
279+
},
280+
None,
281+
);
282+
283+
assert_eq!(response.status, "Quarantined");
284+
assert_eq!(response.security_status.state, "Quarantined");
285+
assert!(response.network_activity.suspicious_activity);
286+
}
287+
264288
#[actix_rt::test]
265289
async fn test_get_containers() {
266290
let pool = create_pool(":memory:").unwrap();

src/api/logs.rs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub async fn list_sources(pool: web::Data<DbPool>) -> impl Responder {
3838
///
3939
/// GET /api/logs/sources/{path}
4040
pub async fn get_source(pool: web::Data<DbPool>, path: web::Path<String>) -> impl Responder {
41-
match log_sources::get_log_source_by_path(&pool, &path) {
41+
match log_sources::get_log_source_by_path(&pool, &path.into_inner()) {
4242
Ok(Some(source)) => HttpResponse::Ok().json(source),
4343
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
4444
"error": "Log source not found"
@@ -77,7 +77,7 @@ pub async fn add_source(
7777
///
7878
/// DELETE /api/logs/sources/{path}
7979
pub async fn delete_source(pool: web::Data<DbPool>, path: web::Path<String>) -> impl Responder {
80-
match log_sources::delete_log_source(&pool, &path) {
80+
match log_sources::delete_log_source(&pool, &path.into_inner()) {
8181
Ok(_) => HttpResponse::NoContent().finish(),
8282
Err(e) => {
8383
log::error!("Failed to delete log source: {}", e);
@@ -136,8 +136,8 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
136136
web::scope("/api/logs")
137137
.route("/sources", web::get().to(list_sources))
138138
.route("/sources", web::post().to(add_source))
139-
.route("/sources/{path}", web::get().to(get_source))
140-
.route("/sources/{path}", web::delete().to(delete_source))
139+
.route("/sources/{path:.*}", web::get().to(get_source))
140+
.route("/sources/{path:.*}", web::delete().to(delete_source))
141141
.route("/summaries", web::get().to(list_summaries)),
142142
);
143143
}
@@ -236,6 +236,33 @@ mod tests {
236236
assert_eq!(resp.status(), 404);
237237
}
238238

239+
#[actix_rt::test]
240+
async fn test_get_source_with_full_filesystem_path() {
241+
let pool = setup_pool();
242+
let source = LogSource::new(
243+
LogSourceType::CustomFile,
244+
"/var/log/app.log".into(),
245+
"App Log".into(),
246+
);
247+
log_sources::upsert_log_source(&pool, &source).unwrap();
248+
249+
let app = test::init_service(
250+
App::new()
251+
.app_data(web::Data::new(pool))
252+
.configure(configure_routes),
253+
)
254+
.await;
255+
256+
let req = test::TestRequest::get()
257+
.uri("/api/logs/sources//var/log/app.log")
258+
.to_request();
259+
let resp = test::call_service(&app, req).await;
260+
assert_eq!(resp.status(), 200);
261+
262+
let body: serde_json::Value = test::read_body_json(resp).await;
263+
assert_eq!(body["path_or_id"], "/var/log/app.log");
264+
}
265+
239266
#[actix_rt::test]
240267
async fn test_delete_source() {
241268
let pool = setup_pool();
@@ -262,6 +289,33 @@ mod tests {
262289
assert_eq!(resp.status(), 204);
263290
}
264291

292+
#[actix_rt::test]
293+
async fn test_delete_source_with_full_filesystem_path() {
294+
let pool = setup_pool();
295+
let source = LogSource::new(
296+
LogSourceType::CustomFile,
297+
"/var/log/app.log".into(),
298+
"App Log".into(),
299+
);
300+
log_sources::upsert_log_source(&pool, &source).unwrap();
301+
302+
let app = test::init_service(
303+
App::new()
304+
.app_data(web::Data::new(pool.clone()))
305+
.configure(configure_routes),
306+
)
307+
.await;
308+
309+
let req = test::TestRequest::delete()
310+
.uri("/api/logs/sources//var/log/app.log")
311+
.to_request();
312+
let resp = test::call_service(&app, req).await;
313+
assert_eq!(resp.status(), 204);
314+
315+
let stored = log_sources::get_log_source_by_path(&pool, "/var/log/app.log").unwrap();
316+
assert!(stored.is_none());
317+
}
318+
265319
#[actix_rt::test]
266320
async fn test_list_summaries_empty() {
267321
let pool = setup_pool();

tests/api/websocket_test.rs

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,145 @@
11
//! WebSocket API tests
22
3+
use actix::Actor;
4+
use actix_test::start;
5+
use actix_web::{web, App};
6+
use awc::ws::Frame;
7+
use chrono::Utc;
8+
use futures_util::StreamExt;
9+
use serde_json::Value;
10+
use stackdog::alerting::alert::{AlertSeverity, AlertStatus, AlertType};
11+
use stackdog::api::websocket::{self, WebSocketHub};
12+
use stackdog::database::models::Alert;
13+
use stackdog::database::{create_alert, create_pool, init_database};
14+
15+
async fn read_text_frame<S>(framed: &mut S) -> Value
16+
where
17+
S: futures_util::Stream<Item = Result<Frame, awc::error::WsProtocolError>> + Unpin,
18+
{
19+
loop {
20+
match framed
21+
.next()
22+
.await
23+
.expect("expected websocket frame")
24+
.expect("valid websocket frame")
25+
{
26+
Frame::Text(bytes) => {
27+
return serde_json::from_slice(&bytes).expect("valid websocket json");
28+
}
29+
Frame::Ping(_) | Frame::Pong(_) => continue,
30+
other => panic!("unexpected websocket frame: {other:?}"),
31+
}
32+
}
33+
}
34+
35+
fn sample_alert(id: &str) -> Alert {
36+
Alert {
37+
id: id.to_string(),
38+
alert_type: AlertType::ThreatDetected,
39+
severity: AlertSeverity::High,
40+
message: format!("alert-{id}"),
41+
status: AlertStatus::New,
42+
timestamp: Utc::now().to_rfc3339(),
43+
metadata: None,
44+
}
45+
}
46+
347
#[cfg(test)]
448
mod tests {
49+
use super::*;
50+
551
#[actix_rt::test]
6-
async fn test_websocket_connection() {
7-
// TODO: Implement when API is ready
8-
assert!(true, "Test placeholder");
52+
async fn test_websocket_connection_receives_initial_stats_snapshot() {
53+
let pool = create_pool(":memory:").unwrap();
54+
init_database(&pool).unwrap();
55+
create_alert(&pool, sample_alert("a1")).await.unwrap();
56+
57+
let hub = WebSocketHub::new().start();
58+
let pool_for_app = pool.clone();
59+
let hub_for_app = hub.clone();
60+
let server = start(move || {
61+
App::new()
62+
.app_data(web::Data::new(pool_for_app.clone()))
63+
.app_data(web::Data::new(hub_for_app.clone()))
64+
.configure(websocket::configure_routes)
65+
});
66+
67+
let (_response, mut framed) = awc::Client::new()
68+
.ws(server.url("/ws"))
69+
.connect()
70+
.await
71+
.unwrap();
72+
73+
let message = read_text_frame(&mut framed).await;
74+
assert_eq!(message["type"], "stats:updated");
75+
assert_eq!(message["payload"]["alerts_new"], 1);
976
}
1077

1178
#[actix_rt::test]
12-
async fn test_websocket_subscribe() {
13-
// TODO: Implement when API is ready
14-
assert!(true, "Test placeholder");
79+
async fn test_websocket_receives_broadcast_events() {
80+
let pool = create_pool(":memory:").unwrap();
81+
init_database(&pool).unwrap();
82+
83+
let hub = WebSocketHub::new().start();
84+
let pool_for_app = pool.clone();
85+
let hub_for_app = hub.clone();
86+
let server = start(move || {
87+
App::new()
88+
.app_data(web::Data::new(pool_for_app.clone()))
89+
.app_data(web::Data::new(hub_for_app.clone()))
90+
.configure(websocket::configure_routes)
91+
});
92+
93+
let (_response, mut framed) = awc::Client::new()
94+
.ws(server.url("/ws"))
95+
.connect()
96+
.await
97+
.unwrap();
98+
99+
let _initial = read_text_frame(&mut framed).await;
100+
101+
websocket::broadcast_event(
102+
&hub,
103+
"alert:created",
104+
serde_json::json!({ "id": "alert-1" }),
105+
)
106+
.await;
107+
108+
let message = read_text_frame(&mut framed).await;
109+
assert_eq!(message["type"], "alert:created");
110+
assert_eq!(message["payload"]["id"], "alert-1");
15111
}
16112

17113
#[actix_rt::test]
18-
async fn test_websocket_receive_events() {
19-
// TODO: Implement when API is ready
20-
assert!(true, "Test placeholder");
114+
async fn test_websocket_receives_broadcast_stats_updates() {
115+
let pool = create_pool(":memory:").unwrap();
116+
init_database(&pool).unwrap();
117+
118+
let hub = WebSocketHub::new().start();
119+
let pool_for_app = pool.clone();
120+
let hub_for_app = hub.clone();
121+
let server = start(move || {
122+
App::new()
123+
.app_data(web::Data::new(pool_for_app.clone()))
124+
.app_data(web::Data::new(hub_for_app.clone()))
125+
.configure(websocket::configure_routes)
126+
});
127+
128+
let (_response, mut framed) = awc::Client::new()
129+
.ws(server.url("/ws"))
130+
.connect()
131+
.await
132+
.unwrap();
133+
134+
let initial = read_text_frame(&mut framed).await;
135+
assert_eq!(initial["type"], "stats:updated");
136+
assert_eq!(initial["payload"]["alerts_new"], 0);
137+
138+
create_alert(&pool, sample_alert("a2")).await.unwrap();
139+
websocket::broadcast_stats(&hub, &pool).await.unwrap();
140+
141+
let updated = read_text_frame(&mut framed).await;
142+
assert_eq!(updated["type"], "stats:updated");
143+
assert_eq!(updated["payload"]["alerts_new"], 1);
21144
}
22145
}

web/src/components/ContainerList.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,17 @@ const ContainerList: React.FC = () => {
119119
<div className="container-items">
120120
{containers.map((container) => (
121121
<div key={container.id} className="container-item">
122+
{(() => {
123+
const isQuarantined =
124+
container.status === 'Quarantined' || container.securityStatus.state === 'Quarantined';
125+
126+
return (
127+
<>
122128
<div className="container-header">
123129
<h5>{container.name}</h5>
124-
<Badge bg={getStatusBadge(container.status)}>{container.status}</Badge>
130+
<Badge bg={getStatusBadge(isQuarantined ? 'Quarantined' : container.status)}>
131+
{isQuarantined ? 'Quarantined' : container.status}
132+
</Badge>
125133
</div>
126134
<div className="container-details">
127135
<p className="mb-1"><strong>Image:</strong> {container.image}</p>
@@ -159,7 +167,7 @@ const ContainerList: React.FC = () => {
159167
>
160168
Details
161169
</Button>
162-
{container.status === 'Running' && (
170+
{!isQuarantined && container.status === 'Running' && (
163171
<Button
164172
variant="outline-danger"
165173
size="sm"
@@ -171,7 +179,7 @@ const ContainerList: React.FC = () => {
171179
Quarantine
172180
</Button>
173181
)}
174-
{container.status === 'Quarantined' && (
182+
{isQuarantined && (
175183
<Button
176184
variant="outline-success"
177185
size="sm"
@@ -181,6 +189,9 @@ const ContainerList: React.FC = () => {
181189
</Button>
182190
)}
183191
</div>
192+
</>
193+
);
194+
})()}
184195
</div>
185196
))}
186197
</div>

web/src/components/__tests__/ContainerList.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ describe('ContainerList Component', () => {
140140
});
141141
});
142142

143+
test('shows release action when security state is quarantined', async () => {
144+
const quarantinedBySecurityState = {
145+
...mockContainers[0],
146+
status: 'Running' as const,
147+
securityStatus: {
148+
...mockContainers[0].securityStatus,
149+
state: 'Quarantined' as const,
150+
},
151+
};
152+
153+
(apiService.getContainers as jest.Mock).mockResolvedValue([quarantinedBySecurityState]);
154+
155+
render(<ContainerList />);
156+
157+
expect(await screen.findByText('web-server')).toBeInTheDocument();
158+
expect(screen.getAllByText('Quarantined').length).toBeGreaterThanOrEqual(2);
159+
expect(screen.getByText('Release')).toBeInTheDocument();
160+
expect(screen.queryByText('Quarantine')).not.toBeInTheDocument();
161+
});
162+
143163
test('filters by status', async () => {
144164
render(<ContainerList />);
145165

0 commit comments

Comments
 (0)