Files
portainer/app/docker/components/log-viewer/logViewer.html
T
claude code agent c3cdb8007e feat(logs): live HTTP stream + append-only render for container logs
Replace the 3s $interval polling of container logs with a live HTTP
stream, and stop re-writing already-rendered lines (fixes selection bug).

- streamContainerLogs (containers.service.ts): fetch + ReadableStream
  reader with follow=1, same-origin credentials:'include' (httpOnly JWT
  cookie; CSRF only guards mutations), agent-target / manager-operation
  headers replicated for Agent/Edge, AbortSignal-driven lifetime.
- containerLogsController: stream instead of poll; append parsed lines
  into the buffer (push, never replace), cap at 5000 lines trimming from
  the head; AbortController on pause/destroy/param-change; reconnect with
  3s backoff resuming from `since` (dropping tail) on stream end/error;
  Live toggle pauses/resumes the stream; tail/since/timestamps changes
  restart the stream.
- log-viewer: `track by log.id` (was $index), filtering moved out of the
  template into the controller (applyFilter via $watchCollection), removed
  inert force-glue, decoupled auto-scroll from log collection, relabelled
  "Auto-refresh logs" -> "Live logs", clearer empty states.

Backend unchanged (logs already stream transparently through the Docker
proxy). Shared task/service log views keep working via the new id'd lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:03:23 +03:00

95 lines
5.2 KiB
HTML

<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="file" title-text="Log viewer settings"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="$ctrl.state.logCollection"
label="'Live logs'"
tooltip="'Disabling this option pauses the live log stream. Auto-scroll follows the bottom of the log and pauses on its own when you scroll up.'"
on-change="($ctrl.handleLogsCollectionChange)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field label-class="'col-sm-2'" checked="$ctrl.state.wrapLines" label="'Wrap lines'" on-change="($ctrl.handleLogsWrapLinesChange)"></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="$ctrl.displayTimestamps"
label="'Display timestamps'"
on-change="($ctrl.handleDisplayTimestampsChange)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<label for="logs_since" class="col-sm-2 control-label text-left"> Fetch </label>
<div class="col-sm-2">
<select class="form-control" ng-model="$ctrl.sinceTimestamp" id="logs_since">
<option selected value="">All logs</option>
<option ng-repeat="dt in $ctrl.state.availableSinceDatetime" ng-value="dt.value" title="{{ dt.value }}">{{ dt.desc }}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="logs_search" class="col-sm-2 control-label text-left"> Search </label>
<div class="col-sm-8">
<input class="form-control" type="text" name="logs_search" ng-model="$ctrl.state.search" ng-change="$ctrl.state.selectedLines.length = 0;" placeholder="Filter..." />
</div>
</div>
<div class="form-group">
<label for="lines_count" class="col-sm-2 control-label text-left"> Lines </label>
<div class="col-sm-8">
<input class="form-control" type="number" name="lines_count" ng-model="$ctrl.lineCount" placeholder="Enter no of lines..." />
</div>
</div>
<div class="form-group" ng-if="$ctrl.state.copySupported">
<label class="col-sm-2 control-label text-left"> Actions </label>
<div class="col-sm-10">
<button class="btn btn-primary btn-sm" type="button" ng-click="$ctrl.downloadLogs()" style="margin-left: 0" data-cy="download-logs-button"
><pr-icon icon="'download'"></pr-icon> Download logs</button
>
<button
class="btn btn-primary btn-sm"
ng-click="$ctrl.copy()"
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line) || !$ctrl.state.filteredLogs.length"
><pr-icon icon="'copy'" class-name="'space-right'"></pr-icon>Copy</button
>
<button
class="btn btn-primary btn-sm"
ng-click="$ctrl.copySelection()"
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line) || !$ctrl.state.filteredLogs.length || !$ctrl.state.selectedLines.length"
><pr-icon icon="'copy'" class-name="'space-right'"></pr-icon>Copy selected lines</button
>
<button class="btn btn-primary btn-sm" ng-click="$ctrl.clearSelection()" ng-disabled="$ctrl.state.selectedLines.length === 0"
><pr-icon icon="'x'" class-name="'space-right'"></pr-icon>Unselect</button
>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" style="height: 54%">
<div class="col-sm-12" style="height: 100%">
<pre ng-class="{ wrap_lines: $ctrl.state.wrapLines }" class="log_viewer" scroll-glue="$ctrl.state.autoScroll">
<div ng-repeat="log in $ctrl.state.filteredLogs track by log.id" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans track by $index" ng-style="{ 'color': span.fgColor, 'background-color': span.bgColor, 'font-weight': span.fontWeight }">{{ span.text }}</span></p></div>
<div ng-if="!$ctrl.state.filteredLogs.length && $ctrl.state.search" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div>
<div ng-if="!$ctrl.state.filteredLogs.length && !$ctrl.state.search" class="line"><p class="inner_line">No logs available</p></div>
</pre>
</div>
</div>