22// The .NET Foundation licenses this file to you under the MIT license.
33// See the LICENSE file in the project root for more information.
44
5- using Microsoft . AspNetCore . Mvc ;
5+ using DevProxy . Abstractions . Proxy ;
6+ using DevProxy . Commands ;
67using DevProxy . Jwt ;
7- using System . Security . Cryptography . X509Certificates ;
8- using System . ComponentModel . DataAnnotations ;
98using DevProxy . Proxy ;
10- using DevProxy . Abstractions . Proxy ;
9+ using Microsoft . AspNetCore . Mvc ;
10+ using System . ComponentModel . DataAnnotations ;
11+ using System . Security . Cryptography . X509Certificates ;
1112
1213namespace DevProxy . ApiControllers ;
1314
@@ -137,4 +138,265 @@ public IActionResult GetRootCertificate([FromQuery][Required] string format)
137138
138139 return File ( pemBytes , "application/x-x509-ca-cert" , "devProxy.pem" ) ;
139140 }
140- }
141+
142+ [ HttpGet ( "logs" ) ]
143+ public async Task GetLogsAsync (
144+ [ FromQuery ] int ? lines ,
145+ [ FromQuery ] bool follow = false ,
146+ [ FromQuery ] string ? since = null ,
147+ CancellationToken cancellationToken = default )
148+ {
149+ // Only available in detached/daemon mode
150+ if ( ! DevProxyCommand . IsInternalDaemon )
151+ {
152+ Response . StatusCode = StatusCodes . Status404NotFound ;
153+ await Response . WriteAsync ( "Logs endpoint is only available in detached mode." , cancellationToken ) ;
154+ return ;
155+ }
156+
157+ var logFile = DevProxyCommand . DetachedLogFilePath ;
158+ if ( string . IsNullOrEmpty ( logFile ) || ! System . IO . File . Exists ( logFile ) )
159+ {
160+ Response . StatusCode = StatusCodes . Status404NotFound ;
161+ await Response . WriteAsync ( "Log file not found." , cancellationToken ) ;
162+ return ;
163+ }
164+
165+ var acceptHeader = Request . Headers . Accept . ToString ( ) ;
166+ var useJson = acceptHeader . Contains ( "application/json" , StringComparison . OrdinalIgnoreCase ) ;
167+ var useSse = acceptHeader . Contains ( "text/event-stream" , StringComparison . OrdinalIgnoreCase ) || follow ;
168+
169+ if ( useSse )
170+ {
171+ Response . ContentType = "text/event-stream" ;
172+ Response . Headers . CacheControl = "no-cache" ;
173+ Response . Headers . Connection = "keep-alive" ;
174+
175+ await StreamLogsAsync ( logFile , lines ?? 50 , since , useJson , cancellationToken ) ;
176+ }
177+ else
178+ {
179+ Response . ContentType = useJson ? "application/json" : "text/plain" ;
180+ await WriteLogsAsync ( logFile , lines ?? 50 , since , useJson , cancellationToken ) ;
181+ }
182+ }
183+
184+ private async Task WriteLogsAsync ( string logFile , int lineCount , string ? since , bool useJson , CancellationToken cancellationToken )
185+ {
186+ var allLines = await ReadAllLinesAsync ( logFile , cancellationToken ) ;
187+ var filteredLines = FilterLines ( allLines , since ) . TakeLast ( lineCount ) . ToList ( ) ;
188+
189+ if ( useJson )
190+ {
191+ var logEntries = filteredLines . Select ( ParseLogLine ) . ToList ( ) ;
192+ var json = System . Text . Json . JsonSerializer . Serialize ( logEntries ) ;
193+ await Response . WriteAsync ( json , cancellationToken ) ;
194+ }
195+ else
196+ {
197+ foreach ( var line in filteredLines )
198+ {
199+ await Response . WriteAsync ( line + Environment . NewLine , cancellationToken ) ;
200+ }
201+ }
202+ }
203+
204+ private async Task StreamLogsAsync ( string logFile , int initialLines , string ? since , bool useJson , CancellationToken cancellationToken )
205+ {
206+ // Write initial lines
207+ var allLines = await ReadAllLinesAsync ( logFile , cancellationToken ) ;
208+ var filteredLines = FilterLines ( allLines , since ) . TakeLast ( initialLines ) . ToList ( ) ;
209+
210+ foreach ( var line in filteredLines )
211+ {
212+ await WriteSseEventAsync ( line , useJson , cancellationToken ) ;
213+ }
214+
215+ // Follow new lines
216+ var lastPosition = new FileInfo ( logFile ) . Length ;
217+
218+ while ( ! cancellationToken . IsCancellationRequested )
219+ {
220+ await Task . Delay ( 500 , cancellationToken ) ;
221+
222+ try
223+ {
224+ using var fs = new FileStream ( logFile , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
225+ if ( fs . Length > lastPosition )
226+ {
227+ _ = fs . Seek ( lastPosition , SeekOrigin . Begin ) ;
228+ using var reader = new StreamReader ( fs ) ;
229+
230+ string ? line ;
231+ while ( ( line = await reader . ReadLineAsync ( cancellationToken ) ) != null )
232+ {
233+ await WriteSseEventAsync ( line , useJson , cancellationToken ) ;
234+ }
235+
236+ lastPosition = fs . Length ;
237+ }
238+ }
239+ catch ( IOException )
240+ {
241+ // File might be temporarily locked
242+ }
243+ }
244+ }
245+
246+ private async Task WriteSseEventAsync ( string line , bool useJson , CancellationToken cancellationToken )
247+ {
248+ if ( useJson )
249+ {
250+ var logEntry = ParseLogLine ( line ) ;
251+ var json = System . Text . Json . JsonSerializer . Serialize ( logEntry ) ;
252+ await Response . WriteAsync ( $ "event: log\n data: { json } \n \n ", cancellationToken ) ;
253+ }
254+ else
255+ {
256+ await Response . WriteAsync ( $ "data: { line } \n \n ", cancellationToken ) ;
257+ }
258+
259+ await Response . Body . FlushAsync ( cancellationToken ) ;
260+ }
261+
262+ private static IEnumerable < string > FilterLines ( IList < string > lines , string ? since )
263+ {
264+ if ( string . IsNullOrEmpty ( since ) )
265+ {
266+ return lines ;
267+ }
268+
269+ var sinceTime = ParseSinceOption ( since ) ;
270+ if ( sinceTime == null )
271+ {
272+ return lines ;
273+ }
274+
275+ return lines . Where ( line => LineMatchesSince ( line , sinceTime . Value ) ) ;
276+ }
277+
278+ private static DateTime ? ParseSinceOption ( string ? since )
279+ {
280+ if ( string . IsNullOrEmpty ( since ) )
281+ {
282+ return null ;
283+ }
284+
285+ // Try parsing as a duration (e.g., "5m", "1h", "30s")
286+ if ( since . Length >= 2 )
287+ {
288+ var unit = since [ ^ 1 ] ;
289+ if ( int . TryParse ( since [ ..^ 1 ] , out var value ) )
290+ {
291+ return unit switch
292+ {
293+ 's' => DateTime . Now . AddSeconds ( - value ) ,
294+ 'm' => DateTime . Now . AddMinutes ( - value ) ,
295+ 'h' => DateTime . Now . AddHours ( - value ) ,
296+ 'd' => DateTime . Now . AddDays ( - value ) ,
297+ _ => null
298+ } ;
299+ }
300+ }
301+
302+ // Try parsing as a datetime
303+ if ( DateTime . TryParse ( since , System . Globalization . CultureInfo . InvariantCulture , System . Globalization . DateTimeStyles . None , out var dateTime ) )
304+ {
305+ return dateTime ;
306+ }
307+
308+ return null ;
309+ }
310+
311+ private static bool LineMatchesSince ( string line , DateTime sinceTime )
312+ {
313+ // Parse timestamp from line format: [HH:mm:ss.fff] ...
314+ if ( line . Length < 14 || line [ 0 ] != '[' || line [ 13 ] != ']' )
315+ {
316+ return true ;
317+ }
318+
319+ var timestampStr = line [ 1 ..13 ] ;
320+ if ( TimeSpan . TryParseExact ( timestampStr , "hh\\ :mm\\ :ss\\ .fff" , System . Globalization . CultureInfo . InvariantCulture , out var timeOfDay ) )
321+ {
322+ var lineTime = DateTime . Today . Add ( timeOfDay ) ;
323+ if ( lineTime > DateTime . Now )
324+ {
325+ lineTime = lineTime . AddDays ( - 1 ) ;
326+ }
327+
328+ return lineTime >= sinceTime ;
329+ }
330+
331+ return true ;
332+ }
333+
334+ private static LogEntryDto ParseLogLine ( string line )
335+ {
336+ // Parse: [HH:mm:ss.fff] level: category: message
337+ var entry = new LogEntryDto { Raw = line } ;
338+
339+ if ( line . Length < 14 || line [ 0 ] != '[' || line [ 13 ] != ']' )
340+ {
341+ entry . Message = line ;
342+ return entry ;
343+ }
344+
345+ entry . Time = line [ 1 ..13 ] ;
346+
347+ if ( line . Length < 16 )
348+ {
349+ entry . Message = line ;
350+ return entry ;
351+ }
352+
353+ var rest = line [ 15 ..] ; // Skip "] "
354+ var colonIndex = rest . IndexOf ( ':' , StringComparison . Ordinal ) ;
355+ if ( colonIndex > 0 )
356+ {
357+ entry . Level = rest [ ..colonIndex ] . Trim ( ) ;
358+ rest = rest [ ( colonIndex + 1 ) ..] . TrimStart ( ) ;
359+
360+ colonIndex = rest . IndexOf ( ':' , StringComparison . Ordinal ) ;
361+ if ( colonIndex > 0 )
362+ {
363+ entry . Category = rest [ ..colonIndex ] . Trim ( ) ;
364+ entry . Message = rest [ ( colonIndex + 1 ) ..] . TrimStart ( ) ;
365+ }
366+ else
367+ {
368+ entry . Message = rest ;
369+ }
370+ }
371+ else
372+ {
373+ entry . Message = rest ;
374+ }
375+
376+ return entry ;
377+ }
378+
379+ private sealed class LogEntryDto
380+ {
381+ public string ? Time { get ; set ; }
382+ public string ? Level { get ; set ; }
383+ public string ? Category { get ; set ; }
384+ public string ? Message { get ; set ; }
385+ public string ? Raw { get ; set ; }
386+ }
387+
388+ private static async Task < List < string > > ReadAllLinesAsync ( string filePath , CancellationToken cancellationToken )
389+ {
390+ var lines = new List < string > ( ) ;
391+ using var fs = new FileStream ( filePath , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
392+ using var reader = new StreamReader ( fs ) ;
393+
394+ string ? line ;
395+ while ( ( line = await reader . ReadLineAsync ( cancellationToken ) ) != null )
396+ {
397+ lines . Add ( line ) ;
398+ }
399+
400+ return lines ;
401+ }
402+ }
0 commit comments