Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stratos",
"version": "v5.0.0-dev.136+build.20260712.d6121118fb",
"version": "v5.0.0-dev.137+build.20260713.cbdbf3f210",
"type": "module",
"description": "Stratos Console",
"main": "index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class EndpointsSignalService {
});
}

// 'expired' deliberately does NOT count as connected: `connectedEndpoints` /
// `haveConnected` gate request fan-out (e.g. cf-current-user-roles sync,
// service-catalog listing) to endpoints that can actually serve a request.
// An expired token can't — jetstream would just get a 401 back.
function isConnected(endpoint: EndpointModel): boolean {
if (!endpoint || !endpoint.cnsi_type) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CustomizationService } from '../../../core/customizations.types';
import { naturalCompare } from '../../../shared/utils/natural-sort';
import { EndpointsService } from '../../../core/endpoints.service';
import { IHeaderBreadcrumbLink } from '../../../shared/components/page-header/page-header.types';
import { EndpointReauthReportService } from '../../../shared/services/endpoint-reauth-report.service';
import { SidePanelMode, SidePanelService } from '../../../shared/services/side-panel.service';
import { TabNavService } from '../../../tab-nav.service';
import { PageSideNavComponent, IPageSideNavTab } from '../page-side-nav/page-side-nav.component';
Expand Down Expand Up @@ -58,6 +59,7 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit
private dashboardSignals = inject(DashboardSignalService);
private dashboardData = inject(DashboardDataService);
private userFavorites = inject(UserFavoritesDataService);
private endpointReauthReport = inject(EndpointReauthReportService);

public activeTabLabel$!: Observable<string>;
public subNavData$!: Observable<[string, Portal<any>, IPageSideNavTab, IHeaderBreadcrumbLink[]]>;
Expand Down Expand Up @@ -193,6 +195,9 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit
// Initialize user favorites - fire and forget, no subscription needed
this.userFavorites.load();

// Once-per-session arrival report of endpoints that already need
// re-authentication - fire and forget, no subscription needed.
void this.endpointReauthReport.reportOnce();
}

ngOnDestroy() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export interface ConnectEndpointData {
}

// Why is this here instead of somewhere more common? Answer - Because it'd create circulate dependencies due to reliance on entityCatalog
//
// endpoint-token-lifecycle (Task 3): left as-is rather than widened to treat
// 'expired' as connected — this export currently has no importers anywhere
// in the codebase (verified by repo-wide grep), so there is no dialog flow
// to break either way. If a caller shows up, re-decide against that flow's
// actual behaviour rather than defaulting to widen.
export const isEndpointConnected = (endpoint: EndpointModel): boolean => {
if (!endpoint.cnsi_type) {
return endpoint.connectionStatus === 'connected';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,14 @@ export function getEndpointUsername(endpoint: EndpointModel) {
// An endpoint is only effectively connected while its stored token is still
// usable. An expired token must read as Disconnected rather than a broken
// card — e.g. korifi pasted tokens have no refresh, so every session ends in
// expiry (#5588). Expiry only matters when jetstream cannot renew: refresh-
// capable endpoints (token_renewable) mint a fresh access token on use, so
// their access-token expiry is harmless. token_expiry is epoch seconds;
// 0/absent = no known expiry.
// expiry (#5588). This expiry math now lives in `computeConnectionStatus`
// (store package, endpoint.types.ts) and is baked into `connectionStatus` at
// hydration time — the manual token_renewable/token_expiry check that used
// to live here is redundant with that computed status. Sole caller is the
// home-page endpoint card's Connect-prompt gate, where treating 'expired'
// the same as 'disconnected' (i.e. offering Connect) is correct.
export function isEndpointConnected(endpoint: EndpointModel): boolean {
if (endpoint.connectionStatus !== 'connected') {
return false;
}
return endpoint.token_renewable ||
!endpoint.token_expiry || endpoint.token_expiry * 1000 > Date.now();
return endpoint.connectionStatus === 'connected';
}

export const DEFAULT_ENDPOINT_TYPE = 'cf';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { EndpointModel } from '@stratosui/store';

import { ConfirmationDialogService } from '../../shared/components/confirmation-dialog.service';
import { EndpointAuthStateService } from '../../shared/services/endpoint-auth-state.service';
import { TailwindDialogService } from '../../shared/services/tailwind-dialog.service';
import { TailwindSnackBarService } from '../../shared/services/tailwind-snackbar.service';
import { EndpointRowActionsService } from './endpoint-row-actions.service';
import { EndpointsSignalConfigService } from './endpoints-page/endpoints-signal-config.service';

function ep(connectionStatus: string): EndpointModel {
return { guid: 'guid-1', name: 'ep1', cnsi_type: 'cf', connectionStatus } as unknown as EndpointModel;
function ep(connectionStatus: string, guid = 'guid-1'): EndpointModel {
return { guid, name: 'ep1', cnsi_type: 'cf', connectionStatus } as unknown as EndpointModel;
}

describe('EndpointRowActionsService', () => {
let service: EndpointRowActionsService;
let tailwindDialog: { open: ReturnType<typeof vi.fn> };
let authState: EndpointAuthStateService;

beforeEach(() => {
tailwindDialog = { open: vi.fn() };
Expand All @@ -29,6 +31,7 @@ describe('EndpointRowActionsService', () => {
],
});
service = TestBed.inject(EndpointRowActionsService);
authState = TestBed.inject(EndpointAuthStateService);
});

it('offers Disconnect and Reconnect for a connected endpoint', () => {
Expand All @@ -52,4 +55,16 @@ describe('EndpointRowActionsService', () => {
reconnect?.invoke(ep('connected'));
expect(tailwindDialog.open).toHaveBeenCalledTimes(1);
});

it('offers Disconnect and Reconnect for an expired endpoint', () => {
const labels = service.buildEndpointActions(ep('expired')).map(a => a.label);
expect(labels).toEqual(['Disconnect', 'Reconnect', 'Edit', 'Unregister']);
});

it('offers Disconnect and Reconnect for a connected endpoint the interceptor marked stale this session', () => {
authState.markStale('guid-1');
const labels = service.buildEndpointActions(ep('connected', 'guid-1')).map(a => a.label);
expect(labels).toEqual(['Disconnect', 'Reconnect', 'Edit', 'Unregister']);
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,30 @@ import { EndpointModel, entityCatalog } from '@stratosui/store';
import { ConfirmationDialogConfig } from '../../shared/components/confirmation-dialog.config';
import { ConfirmationDialogService } from '../../shared/components/confirmation-dialog.service';
import { SignalListRowAction } from '../../shared/components/signal-list/signal-list.component';
import { EndpointAuthStateService } from '../../shared/services/endpoint-auth-state.service';
import { TailwindDialogService } from '../../shared/services/tailwind-dialog.service';
import { TailwindSnackBarService } from '../../shared/services/tailwind-snackbar.service';
import { ConnectEndpointDialogComponent } from './connect-endpoint-dialog/connect-endpoint-dialog.component';
import { EndpointsSignalConfigService } from './endpoints-page/endpoints-signal-config.service';

/**
* True when an endpoint's stored token is expired or effectively dead —
* either the store computed `'expired'` from wire data at hydration (Task 3
* of the token-lifecycle work), or the auth interceptor witnessed a 401 for
* this guid THIS session (`EndpointAuthStateService.stale`) before the next
* info refetch reflects the server-side disposal. Exported here (rather than
* duplicated) so the endpoints-list status pill
* (`EndpointsSignalListComponent`) and this service's action gate can never
* disagree about what "expired" means. Lives in this file rather than the
* store package because it needs no store-only knowledge beyond
* `EndpointModel`, and core components already import from this service
* file - no new dependency edge, no cycle.
*/
export function isEndpointExpired(ep: EndpointModel, staleGuids: ReadonlySet<string>): boolean {
return ep.connectionStatus === 'expired' ||
(ep.connectionStatus === 'connected' && !!ep.guid && staleGuids.has(ep.guid));
}

/**
* Per-endpoint kebab actions (Connect / Disconnect / Edit / Unregister) with
* their dialog and snackbar flows. Shared by the /endpoints signal list and
Expand All @@ -32,6 +51,7 @@ export class EndpointRowActionsService {
private confirmDialog = inject(ConfirmationDialogService);
private tailwindDialog = inject(TailwindDialogService);
private snackBar = inject(TailwindSnackBarService);
private authState = inject(EndpointAuthStateService);

/**
* `unregister: false` for surfaces that project endpoints without managing
Expand All @@ -40,7 +60,11 @@ export class EndpointRowActionsService {
* stay on the Endpoints page.
*/
buildEndpointActions = (ep: EndpointModel, opts: { unregister?: boolean } = {}): readonly SignalListRowAction<EndpointModel>[] => {
const isConnected = ep.connectionStatus === 'connected';
// 'expired' rows (and 'connected' rows the interceptor has marked stale
// this session) still hold a stored token - Disconnect must stay
// available, and they get the same Reconnect entry a healthy connected
// endpoint does, so the two states share the isConnected branch below.
const isConnected = ep.connectionStatus === 'connected' || isEndpointExpired(ep, this.authState.stale());
const isDisconnected = ep.connectionStatus === 'disconnected';
const def = entityCatalog.getEndpoint(ep.cnsi_type ?? '', ep.sub_type);
const connectable = !(def?.definition?.unConnectable);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection, signal } from '@angular/core';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { describe, it, expect, beforeEach } from 'vitest';
import { vi } from 'vitest';
import type { EndpointModel } from '@stratosui/store';
import { UserFavoriteManager } from '@stratosui/store';

import { ConfirmationDialogService } from '../../../shared/components/confirmation-dialog.service';
import { EndpointAuthStateService } from '../../../shared/services/endpoint-auth-state.service';
import { TailwindDialogService } from '../../../shared/services/tailwind-dialog.service';
import { TailwindSnackBarService } from '../../../shared/services/tailwind-snackbar.service';
import { EndpointRowActionsService } from '../endpoint-row-actions.service';
import { EndpointsSignalConfigService } from './endpoints-signal-config.service';
import { EndpointsSignalListComponent } from './endpoints-signal-list.component';

function ep(connectionStatus: string, guid = 'guid-1'): EndpointModel {
return { guid, name: 'ep1', cnsi_type: 'cf', connectionStatus } as unknown as EndpointModel;
}

// Minimal stand-in for EndpointsSignalConfigService - the component's
// constructor only reads these fields to build listConfig(); none of the
// actual filter/sort/paging behaviour is exercised here (that's
// view-pipeline.spec.ts's job).
function makeEndpointsConfigStub(): EndpointsSignalConfigService {
return {
initialize: vi.fn(),
view: {
pagedItems: signal<EndpointModel[]>([]),
totalFilteredResults: signal(0),
totalPages: signal(0),
totalItems: signal(0),
},
pageIndex: signal(0),
pageSize: signal(25),
loading: signal(false),
nameFilter: signal(''),
viewMode: signal('table'),
sort: signal({ field: 'name', direction: 'asc' }),
registerSortExtractor: vi.fn(),
refresh: vi.fn(),
clearFilters: vi.fn(),
disconnectEndpoint: vi.fn(),
unregisterEndpoint: vi.fn(),
} as unknown as EndpointsSignalConfigService;
}

describe('EndpointsSignalListComponent - expired status + reconnect action', () => {
let component: EndpointsSignalListComponent;
let authState: EndpointAuthStateService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
{ provide: EndpointsSignalConfigService, useValue: makeEndpointsConfigStub() },
{ provide: UserFavoriteManager, useValue: { getAllFavorites: () => of([{}, {}]) } as unknown as UserFavoriteManager },
{ provide: Router, useValue: { navigate: vi.fn() } },
{ provide: ConfirmationDialogService, useValue: { open: vi.fn() } },
{ provide: TailwindDialogService, useValue: { open: vi.fn() } },
{ provide: TailwindSnackBarService, useValue: { show: vi.fn() } },
// Real row-actions + auth-state services so the action gate under
// test is the actual production wiring, not a stub of itself.
EndpointRowActionsService,
EndpointAuthStateService,
],
});
const fixture = TestBed.createComponent(EndpointsSignalListComponent);
component = fixture.componentInstance;
authState = TestBed.inject(EndpointAuthStateService);
});

function statusColumn() {
const col = component.listConfig()!.columns.find(c => c.key === 'status');
if (!col) throw new Error('status column not found');
return col;
}

function actionsColumn() {
const col = component.listConfig()!.columns.find(c => c.key === 'actions');
if (!col) throw new Error('actions column not found');
return col;
}

it('renders an expired endpoint as "Expired" with the warning pill color, and offers Reconnect + Disconnect', () => {
const endpoint = ep('expired');
const status = statusColumn();
expect(status.render(endpoint)).toBe('Expired');
expect(status.pillColor?.(endpoint)).toBe('warning');

const labels = actionsColumn().actions!(endpoint).map(a => a.label);
expect(labels).toContain('Reconnect');
expect(labels).toContain('Disconnect');
});

it('keeps today\'s behavior for a healthy connected endpoint: "Connected", success color, Disconnect + Reconnect', () => {
const endpoint = ep('connected');
const status = statusColumn();
expect(status.render(endpoint)).toBe('Connected');
expect(status.pillColor?.(endpoint)).toBe('success');

const labels = actionsColumn().actions!(endpoint).map(a => a.label);
expect(labels).toContain('Reconnect');
expect(labels).toContain('Disconnect');
});

it('overlays "Expired" (warning) on a connected endpoint the interceptor marked stale this session', () => {
const endpoint = ep('connected', 'guid-stale');
authState.markStale('guid-stale');

const status = statusColumn();
expect(status.render(endpoint)).toBe('Expired');
expect(status.pillColor?.(endpoint)).toBe('warning');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import { SignalListCellTemplateDirective } from '../../../shared/components/sign
import { EndpointCardComponent } from '../../../shared/components/endpoint-list/endpoint-card/endpoint-card.component';
import { EndpointListHelper } from '../../../shared/components/endpoint-list/endpoint-list.helpers';
import { TableCellEndpointAddressComponent } from '../../../shared/components/endpoint-list/table-cell-endpoint-address/table-cell-endpoint-address.component';
import { EndpointRowActionsService } from '../endpoint-row-actions.service';
import { EndpointAuthStateService } from '../../../shared/services/endpoint-auth-state.service';
import { EndpointRowActionsService, isEndpointExpired } from '../endpoint-row-actions.service';
import { EndpointsSignalConfigService } from './endpoints-signal-config.service';

// Signal-native replacement for the inner <app-list> on /endpoints. Reuses
Expand Down Expand Up @@ -56,6 +57,7 @@ export class EndpointsSignalListComponent {
private endpointsConfig = inject(EndpointsSignalConfigService);
private userFavoriteManager = inject(UserFavoriteManager);
private rowActions = inject(EndpointRowActionsService);
private authState = inject(EndpointAuthStateService);

/**
* Primary "Register Endpoint" action surfaced on the L5 sub-nav row above
Expand Down Expand Up @@ -136,11 +138,22 @@ export class EndpointsSignalListComponent {
const userLabel = (ep: EndpointModel): string => ep.user?.name ?? '';

const statusLabel = (ep: EndpointModel): string => {
// 'expired' arrives computed on connectionStatus (Task 3, from wire
// data); the authState overlay catches 401s the interceptor witnessed
// THIS session, before the next info refetch reflects the
// server-side disposal - see isEndpointExpired in
// endpoint-row-actions.service.ts (shared with the action gate there).
if (isEndpointExpired(ep, this.authState.stale())) {
return 'Expired';
}
const s = ep.connectionStatus ?? 'unknown';
return s.charAt(0).toUpperCase() + s.slice(1);
};

const statusColor = (ep: EndpointModel): SignalListPillColor => {
if (isEndpointExpired(ep, this.authState.stale())) {
return 'warning';
}
const s = ep.connectionStatus;
if (s === 'connected') return 'success';
if (s === 'checking') return 'warning';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ describe('HomePageEndpointCardComponent', () => {
});

it('presents a connected endpoint with an expired token as disconnected', () => {
component.endpoint = { ...component.endpoint, connectionStatus: 'connected', token_expiry: 1 };
// endpoint-token-lifecycle (Task 3): connectionStatus is now computed
// once at hydration time (computeConnectionStatus) rather than derived
// again here from token_expiry, so an expired token is represented
// directly as connectionStatus: 'expired' rather than the old
// 'connected' + stale-token_expiry combination.
component.endpoint = { ...component.endpoint, connectionStatus: 'expired', token_expiry: 1 };
expect(component.disconnected).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<app-page-header-events class="endpoint-warning" [endpointIds$]="endpointIds$" [simpleErrorMessage]="true">
</app-page-header-events>
<!-- 'expired' intentionally skips the endpoint-card--disconnected dimming: the
signal-list renders it as an amber warning pill (needs attention), and
dimming the card would work against that - it should stay as noticeable as
connected. -->
<app-meta-card class="endpoint-card" [routerLink]="endpointLink" [appDisableRouterLink]="!endpointLink"
[ngClass]="{'endpoint-card--no-link': !endpointLink, 'endpoint-card--disconnected': connectionStatus === 'disconnected', 'endpoint-card--checking': connectionStatus === 'checking'}"
[favorite]="favorite" [actionMenu]="actionMenu" [status$]="cardStatus$" [statusIcon]="false" [statusBackground]="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
}
<app-custom-icon fontSet="stratos-icons">endpoints_disconnected</app-custom-icon>
}
@if (row.connectionStatus === 'expired') {
@if (config?.showLabel) {
<span class="mr-2 text-warning-shade-600 dark:text-warning-shade-300">Expired @if (row.local) {
<span class="mr-2">(local)</span>
}</span>
}
<!-- No dedicated "expired" glyph exists in the stratos-icons font;
reuse endpoints_disconnected rather than add a new font entry. -->
<app-custom-icon fontSet="stratos-icons" class="text-warning-shade-600 dark:text-warning-shade-300">endpoints_disconnected</app-custom-icon>
}
@if (row.connectionStatus === 'checking') {
@if (config?.showLabel) {
<span class="mr-2">Updating </span>
Expand Down
Loading
Loading