Skip to content

Commit 9751b6a

Browse files
committed
Show a pulsing indicator when the service goes away
1 parent 5aec063 commit 9751b6a

2 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
import { Component } from '@angular/core';
19+
import { CommonModule } from '@angular/common';
20+
import { MatTooltipModule } from '@angular/material/tooltip';
21+
import { LucideAngularModule, PlugZap } from 'lucide-angular';
22+
import { Observable, of, delay, switchMap, distinctUntilChanged } from 'rxjs';
23+
import { SignalRService } from '../../services/signalr.service';
24+
25+
/**
26+
* Shows when the service has gone away -- it stopped, crashed, or is being
27+
* restarted mid-development, which for this app is a normal thing to do.
28+
* Without it the UI just quietly stops updating and looks fine.
29+
*/
30+
@Component({
31+
selector: 'app-connection-status',
32+
standalone: true,
33+
imports: [CommonModule, MatTooltipModule, LucideAngularModule],
34+
template: `
35+
<span class="conn-offline"
36+
*ngIf="isOffline$ | async"
37+
role="status"
38+
matTooltip="Disconnected from the Papercut service — retrying…">
39+
<lucide-icon [img]="icons.PlugZap" [size]="15"></lucide-icon>
40+
</span>
41+
`,
42+
styles: [`
43+
:host { display: contents; }
44+
45+
.conn-offline {
46+
display: inline-flex;
47+
align-items: center;
48+
justify-content: center;
49+
width: 30px;
50+
height: 30px;
51+
border-radius: 6px;
52+
color: var(--pc-danger);
53+
cursor: default;
54+
animation: conn-pulse 1.6s ease-in-out infinite;
55+
}
56+
57+
@keyframes conn-pulse {
58+
0%, 100% { opacity: 1; transform: scale(1); }
59+
50% { opacity: 0.35; transform: scale(0.9); }
60+
}
61+
62+
/* A pulsing icon is an animation someone may have asked not to see; the
63+
colour and the tooltip still carry the message without it. */
64+
@media (prefers-reduced-motion: reduce) {
65+
.conn-offline { animation: none; }
66+
}
67+
`]
68+
})
69+
export class ConnectionStatusComponent {
70+
protected readonly icons = { PlugZap };
71+
72+
/**
73+
* SignalR drops to disconnected during its own reconnect attempts, so a
74+
* short hold keeps an ordinary blip from flashing an alarm. Coming back is
75+
* reported immediately -- there is nothing to debounce about good news.
76+
*/
77+
readonly isOffline$: Observable<boolean>;
78+
79+
private static readonly GraceMs = 2000;
80+
81+
constructor(signalRService: SignalRService) {
82+
this.isOffline$ = signalRService.isConnected$.pipe(
83+
switchMap(connected =>
84+
connected ? of(false) : of(true).pipe(delay(ConnectionStatusComponent.GraceMs))
85+
),
86+
distinctUntilChanged()
87+
);
88+
}
89+
}

src/Papercut.Service/Web/src/app/components/navigation/navigation.component.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { RulesApiService } from '../../services/rules-api.service';
2525
import { OptionsDialogComponent } from '../options-dialog/options-dialog.component';
2626
import { RulesDialogComponent } from '../rules-dialog/rules-dialog.component';
2727
import { LogDialogComponent } from '../log-dialog/log-dialog.component';
28+
import { ConnectionStatusComponent } from '../connection-status/connection-status.component';
2829
import { Observable, map } from 'rxjs';
2930

3031
@Component({
@@ -35,7 +36,8 @@ import { Observable, map } from 'rxjs';
3536
RouterModule,
3637
MatMenuModule,
3738
MatTooltipModule,
38-
LucideAngularModule
39+
LucideAngularModule,
40+
ConnectionStatusComponent
3941
],
4042
template: `
4143
<nav class="papercut-navbar">
@@ -79,6 +81,9 @@ import { Observable, map } from 'rxjs';
7981
<span>{{ mcpCopied ? 'Copied!' : 'MCP' }}</span>
8082
</button>
8183
84+
<!-- Only visible when the service has gone away -->
85+
<app-connection-status></app-connection-status>
86+
8287
<span class="nav-divider desktop-only"></span>
8388
8489
<!-- Theme accent picker -->

0 commit comments

Comments
 (0)