mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Live: improved channel state (#27672)
This commit is contained in:
209
public/app/features/live/channel.ts
Normal file
209
public/app/features/live/channel.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
LiveChannelConfig,
|
||||
LiveChannel,
|
||||
LiveChannelScope,
|
||||
LiveChannelStatus,
|
||||
LiveChannelPresense,
|
||||
LiveChannelJoinLeave,
|
||||
LiveChannelMessage,
|
||||
LiveChannelConnectionState,
|
||||
} from '@grafana/data';
|
||||
import Centrifuge, {
|
||||
JoinLeaveContext,
|
||||
PublicationContext,
|
||||
SubscribeErrorContext,
|
||||
SubscribeSuccessContext,
|
||||
SubscriptionEvents,
|
||||
UnsubscribeContext,
|
||||
} from 'centrifuge/dist/centrifuge.protobuf';
|
||||
import { Subject, of, merge } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Internal class that maps Centrifuge support to GrafanaLive
|
||||
*/
|
||||
export class CentrifugeLiveChannel<TMessage = any, TPublish = any> implements LiveChannel<TMessage, TPublish> {
|
||||
readonly currentStatus: LiveChannelStatus;
|
||||
|
||||
readonly opened = Date.now();
|
||||
readonly id: string;
|
||||
readonly scope: LiveChannelScope;
|
||||
readonly namespace: string;
|
||||
readonly path: string;
|
||||
|
||||
readonly stream = new Subject<LiveChannelMessage<TMessage>>();
|
||||
|
||||
// When presense is enabled (rarely), this will be initalized
|
||||
private presense?: Subject<LiveChannelPresense>;
|
||||
|
||||
/** Static definition of the channel definition. This may describe the channel usage */
|
||||
config?: LiveChannelConfig;
|
||||
subscription?: Centrifuge.Subscription;
|
||||
shutdownCallback?: () => void;
|
||||
|
||||
constructor(id: string, scope: LiveChannelScope, namespace: string, path: string) {
|
||||
this.id = id;
|
||||
this.scope = scope;
|
||||
this.namespace = namespace;
|
||||
this.path = path;
|
||||
this.currentStatus = {
|
||||
id,
|
||||
timestamp: this.opened,
|
||||
state: LiveChannelConnectionState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
// This should only be called when centrifuge is connected
|
||||
initalize(config: LiveChannelConfig): SubscriptionEvents {
|
||||
if (this.config) {
|
||||
throw new Error('Channel already initalized: ' + this.id);
|
||||
}
|
||||
this.config = config;
|
||||
const prepare = config.processMessage ? config.processMessage : (v: any) => v;
|
||||
|
||||
const events: SubscriptionEvents = {
|
||||
// This means a message was recieved from the server
|
||||
publish: (ctx: PublicationContext) => {
|
||||
this.stream.next(prepare(ctx.data));
|
||||
|
||||
// Clear any error messages
|
||||
if (this.currentStatus.error) {
|
||||
this.currentStatus.timestamp = Date.now();
|
||||
delete this.currentStatus.error;
|
||||
this.sendStatus();
|
||||
}
|
||||
},
|
||||
error: (ctx: SubscribeErrorContext) => {
|
||||
this.currentStatus.timestamp = Date.now();
|
||||
this.currentStatus.error = ctx.error;
|
||||
this.sendStatus();
|
||||
},
|
||||
subscribe: (ctx: SubscribeSuccessContext) => {
|
||||
this.currentStatus.timestamp = Date.now();
|
||||
this.currentStatus.state = LiveChannelConnectionState.Connected;
|
||||
this.sendStatus();
|
||||
},
|
||||
unsubscribe: (ctx: UnsubscribeContext) => {
|
||||
this.currentStatus.timestamp = Date.now();
|
||||
this.currentStatus.state = LiveChannelConnectionState.Disconnected;
|
||||
this.sendStatus();
|
||||
},
|
||||
};
|
||||
|
||||
if (config.hasPresense) {
|
||||
events.join = (ctx: JoinLeaveContext) => {
|
||||
const message: LiveChannelJoinLeave = {
|
||||
user: ctx.info.user,
|
||||
};
|
||||
this.stream.next({
|
||||
type: 'join',
|
||||
message,
|
||||
});
|
||||
};
|
||||
events.leave = (ctx: JoinLeaveContext) => {
|
||||
const message: LiveChannelJoinLeave = {
|
||||
user: ctx.info.user,
|
||||
};
|
||||
this.stream.next({
|
||||
type: 'leave',
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
this.getPresense = () => {
|
||||
return this.subscription!.presence().then(v => {
|
||||
return {
|
||||
users: Object.keys(v.presence),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private sendStatus() {
|
||||
this.stream.next({ type: 'status', message: { ...this.currentStatus } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stream of events and
|
||||
*/
|
||||
getStream() {
|
||||
const status: LiveChannelMessage<TMessage> = { type: 'status', message: { ...this.currentStatus } };
|
||||
return merge(of(status), this.stream.asObservable());
|
||||
}
|
||||
|
||||
/**
|
||||
* This is configured by the server when the config supports presense
|
||||
*/
|
||||
getPresense?: () => Promise<LiveChannelPresense>;
|
||||
|
||||
/**
|
||||
* This is configured by the server when config supports writing
|
||||
*/
|
||||
publish?: (msg: TPublish) => Promise<any>;
|
||||
|
||||
/**
|
||||
* This will close and terminate all streams for this channel
|
||||
*/
|
||||
disconnect() {
|
||||
this.currentStatus.state = LiveChannelConnectionState.Shutdown;
|
||||
this.currentStatus.timestamp = Date.now();
|
||||
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
this.subscription.removeAllListeners(); // they keep all listeners attached after unsubscribe
|
||||
this.subscription = undefined;
|
||||
}
|
||||
|
||||
this.stream.complete();
|
||||
|
||||
if (this.presense) {
|
||||
this.presense.complete();
|
||||
}
|
||||
|
||||
this.stream.next({ type: 'status', message: { ...this.currentStatus } });
|
||||
this.stream.complete();
|
||||
|
||||
if (this.shutdownCallback) {
|
||||
this.shutdownCallback();
|
||||
}
|
||||
}
|
||||
|
||||
shutdownWithError(err: string) {
|
||||
this.currentStatus.error = err;
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorChannel(
|
||||
msg: string,
|
||||
id: string,
|
||||
scope: LiveChannelScope,
|
||||
namespace: string,
|
||||
path: string
|
||||
): LiveChannel {
|
||||
const errorStatus: LiveChannelStatus = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
state: LiveChannelConnectionState.Invalid,
|
||||
error: msg,
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
opened: Date.now(),
|
||||
scope,
|
||||
namespace,
|
||||
path,
|
||||
|
||||
// return an error
|
||||
getStream: () =>
|
||||
of({
|
||||
type: 'status',
|
||||
message: errorStatus,
|
||||
}),
|
||||
|
||||
// already disconnected
|
||||
disconnect: () => {},
|
||||
};
|
||||
}
|
||||
47
public/app/features/live/features.ts
Normal file
47
public/app/features/live/features.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LiveChannelConfig } from '@grafana/data';
|
||||
import { grafanaLiveCoreFeatures } from './scopes';
|
||||
|
||||
export function registerLiveFeatures() {
|
||||
const channels = [
|
||||
{
|
||||
path: 'random-2s-stream',
|
||||
description: 'Random stream with points every 2s',
|
||||
},
|
||||
{
|
||||
path: 'random-flakey-stream',
|
||||
description: 'Random stream with flakey data points',
|
||||
},
|
||||
];
|
||||
|
||||
grafanaLiveCoreFeatures.register(
|
||||
'testdata',
|
||||
{
|
||||
getChannelConfig: (path: string) => {
|
||||
return channels.find(c => c.path === path);
|
||||
},
|
||||
getSupportedPaths: () => channels,
|
||||
},
|
||||
'Test data generations'
|
||||
);
|
||||
|
||||
const chatConfig: LiveChannelConfig = {
|
||||
path: 'chat',
|
||||
description: 'Broadcast text messages to a channel',
|
||||
canPublish: () => true,
|
||||
hasPresense: true,
|
||||
};
|
||||
|
||||
grafanaLiveCoreFeatures.register(
|
||||
'experimental',
|
||||
{
|
||||
getChannelConfig: (path: string) => {
|
||||
if ('chat' === path) {
|
||||
return chatConfig;
|
||||
}
|
||||
throw new Error('invalid path: ' + path);
|
||||
},
|
||||
getSupportedPaths: () => [chatConfig],
|
||||
},
|
||||
'Experimental features'
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
import Centrifuge, {
|
||||
PublicationContext,
|
||||
SubscriptionEvents,
|
||||
SubscribeSuccessContext,
|
||||
UnsubscribeContext,
|
||||
JoinLeaveContext,
|
||||
SubscribeErrorContext,
|
||||
} from 'centrifuge/dist/centrifuge.protobuf';
|
||||
import Centrifuge from 'centrifuge/dist/centrifuge.protobuf';
|
||||
import SockJS from 'sockjs-client';
|
||||
import { GrafanaLiveSrv, setGrafanaLiveSrv, ChannelHandler, config } from '@grafana/runtime';
|
||||
import { Observable, Subject, BehaviorSubject } from 'rxjs';
|
||||
import { KeyValue } from '@grafana/data';
|
||||
import { GrafanaLiveSrv, setGrafanaLiveSrv, getGrafanaLiveSrv, config } from '@grafana/runtime';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { LiveChannel, LiveChannelScope } from '@grafana/data';
|
||||
import { CentrifugeLiveChannel, getErrorChannel } from './channel';
|
||||
import {
|
||||
GrafanaLiveScope,
|
||||
grafanaLiveCoreFeatures,
|
||||
GrafanaLiveDataSourceScope,
|
||||
GrafanaLivePluginScope,
|
||||
} from './scopes';
|
||||
import { registerLiveFeatures } from './features';
|
||||
|
||||
interface Channel<T = any> {
|
||||
subject: Subject<T>;
|
||||
subscription?: Centrifuge.Subscription;
|
||||
}
|
||||
export class CentrifugeSrv implements GrafanaLiveSrv {
|
||||
readonly open = new Map<string, CentrifugeLiveChannel>();
|
||||
|
||||
class CentrifugeSrv implements GrafanaLiveSrv {
|
||||
centrifuge: Centrifuge;
|
||||
channels: KeyValue<Channel> = {};
|
||||
connectionState: BehaviorSubject<boolean>;
|
||||
standardCallbacks: SubscriptionEvents;
|
||||
readonly centrifuge: Centrifuge;
|
||||
readonly connectionState: BehaviorSubject<boolean>;
|
||||
readonly connectionBlocker: Promise<void>;
|
||||
readonly scopes: Record<LiveChannelScope, GrafanaLiveScope>;
|
||||
|
||||
constructor() {
|
||||
this.centrifuge = new Centrifuge(`${config.appUrl}live/sockjs`, {
|
||||
@@ -29,19 +27,27 @@ class CentrifugeSrv implements GrafanaLiveSrv {
|
||||
});
|
||||
this.centrifuge.connect(); // do connection
|
||||
this.connectionState = new BehaviorSubject<boolean>(this.centrifuge.isConnected());
|
||||
this.connectionBlocker = new Promise<void>(resolve => {
|
||||
if (this.centrifuge.isConnected()) {
|
||||
return resolve();
|
||||
}
|
||||
const connectListener = () => {
|
||||
resolve();
|
||||
this.centrifuge.removeListener('connect', connectListener);
|
||||
};
|
||||
this.centrifuge.addListener('connect', connectListener);
|
||||
});
|
||||
|
||||
this.scopes = {
|
||||
[LiveChannelScope.Grafana]: grafanaLiveCoreFeatures,
|
||||
[LiveChannelScope.DataSource]: new GrafanaLiveDataSourceScope(),
|
||||
[LiveChannelScope.Plugin]: new GrafanaLivePluginScope(),
|
||||
};
|
||||
|
||||
// Register global listeners
|
||||
this.centrifuge.on('connect', this.onConnect);
|
||||
this.centrifuge.on('disconnect', this.onDisconnect);
|
||||
this.centrifuge.on('publish', this.onServerSideMessage);
|
||||
|
||||
this.standardCallbacks = {
|
||||
subscribe: this.onSubscribe,
|
||||
unsubscribe: this.onUnsubscribe,
|
||||
join: this.onJoin,
|
||||
leave: this.onLeave,
|
||||
error: this.onError,
|
||||
};
|
||||
}
|
||||
|
||||
//----------------------------------------------------------
|
||||
@@ -62,38 +68,61 @@ class CentrifugeSrv implements GrafanaLiveSrv {
|
||||
console.log('Publication from server-side channel', context);
|
||||
};
|
||||
|
||||
//----------------------------------------------------------
|
||||
// Channel functions
|
||||
//----------------------------------------------------------
|
||||
/**
|
||||
* Get a channel. If the scope, namespace, or path is invalid, a shutdown
|
||||
* channel will be returned with an error state indicated in its status
|
||||
*/
|
||||
getChannel<TMessage, TPublish>(
|
||||
scopeId: LiveChannelScope,
|
||||
namespace: string,
|
||||
path: string
|
||||
): LiveChannel<TMessage, TPublish> {
|
||||
const id = `${scopeId}/${namespace}/${path}`;
|
||||
let channel = this.open.get(id);
|
||||
if (channel != null) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
// export interface SubscriptionEvents {
|
||||
// publish?: (ctx: PublicationContext) => void;
|
||||
// join?: (ctx: JoinLeaveContext) => void;
|
||||
// leave?: (ctx: JoinLeaveContex) => void;
|
||||
// subscribe?: (ctx: SubscribeSuccessContext) => void;
|
||||
// error?: (ctx: SubscribeErrorContext) => void;
|
||||
// unsubscribe?: (ctx: UnsubscribeContext) => void;
|
||||
// }
|
||||
const scope = this.scopes[scopeId];
|
||||
if (!scope) {
|
||||
return getErrorChannel('invalid scope', id, scopeId, namespace, path);
|
||||
}
|
||||
|
||||
onSubscribe = (context: SubscribeSuccessContext) => {
|
||||
console.log('onSubscribe', context);
|
||||
};
|
||||
channel = new CentrifugeLiveChannel(id, scopeId, namespace, path);
|
||||
channel.shutdownCallback = () => {
|
||||
this.open.delete(id); // remove it from the list of open channels
|
||||
};
|
||||
this.open.set(id, channel);
|
||||
|
||||
onUnsubscribe = (context: UnsubscribeContext) => {
|
||||
console.log('onUnsubscribe', context);
|
||||
};
|
||||
// Initalize the channel in the bacground
|
||||
this.initChannel(scope, channel).catch(err => {
|
||||
channel?.shutdownWithError(err);
|
||||
this.open.delete(id);
|
||||
});
|
||||
|
||||
onJoin = (context: JoinLeaveContext) => {
|
||||
console.log('onJoin', context);
|
||||
};
|
||||
// return the not-yet initalized channel
|
||||
return channel;
|
||||
}
|
||||
|
||||
onLeave = (context: JoinLeaveContext) => {
|
||||
console.log('onLeave', context);
|
||||
};
|
||||
|
||||
onError = (context: SubscribeErrorContext) => {
|
||||
console.log('onError', context);
|
||||
};
|
||||
private async initChannel(scope: GrafanaLiveScope, channel: CentrifugeLiveChannel): Promise<void> {
|
||||
const support = await scope.getChannelSupport(channel.namespace);
|
||||
if (!support) {
|
||||
throw new Error(channel.namespace + 'does not support streaming');
|
||||
}
|
||||
const config = support.getChannelConfig(channel.path);
|
||||
if (!config) {
|
||||
throw new Error('unknown path: ' + channel.path);
|
||||
}
|
||||
const events = channel.initalize(config);
|
||||
if (!this.centrifuge.isConnected()) {
|
||||
await this.connectionBlocker;
|
||||
}
|
||||
if (config.canPublish && config.canPublish()) {
|
||||
channel.publish = (data: any) => this.centrifuge.publish(channel.id, data);
|
||||
}
|
||||
channel.subscription = this.centrifuge.subscribe(channel.id, events);
|
||||
return;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------
|
||||
// Exported functions
|
||||
@@ -112,52 +141,13 @@ class CentrifugeSrv implements GrafanaLiveSrv {
|
||||
getConnectionState() {
|
||||
return this.connectionState.asObservable();
|
||||
}
|
||||
|
||||
initChannel<T>(path: string, handler: ChannelHandler<T>) {
|
||||
if (this.channels[path]) {
|
||||
console.log('Already connected to:', path);
|
||||
return;
|
||||
}
|
||||
const c: Channel = {
|
||||
subject: new Subject<T>(),
|
||||
};
|
||||
this.channels[path] = c;
|
||||
|
||||
console.log('initChannel', this.centrifuge.isConnected(), path, handler);
|
||||
const callbacks: SubscriptionEvents = {
|
||||
...this.standardCallbacks,
|
||||
publish: (ctx: PublicationContext) => {
|
||||
// console.log('GOT', JSON.stringify(ctx.data), ctx);
|
||||
const v = handler.onPublish(ctx.data);
|
||||
c.subject.next(v);
|
||||
},
|
||||
};
|
||||
c.subscription = this.centrifuge.subscribe(path, callbacks);
|
||||
}
|
||||
|
||||
getChannelStream<T>(path: string): Observable<T> {
|
||||
let c = this.channels[path];
|
||||
if (!c) {
|
||||
this.initChannel(path, noopChannelHandler);
|
||||
c = this.channels[path];
|
||||
}
|
||||
return c!.subject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data to a channel. This feature is disabled for most channels and will return an error
|
||||
*/
|
||||
publish<T>(channel: string, data: any): Promise<T> {
|
||||
return this.centrifuge.publish(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
const noopChannelHandler: ChannelHandler = {
|
||||
onPublish: (v: any) => {
|
||||
return v; // Just pass the object along
|
||||
},
|
||||
};
|
||||
export function getGrafanaLiveCentrifugeSrv() {
|
||||
return getGrafanaLiveSrv() as CentrifugeSrv;
|
||||
}
|
||||
|
||||
export function initGrafanaLive() {
|
||||
setGrafanaLiveSrv(new CentrifugeSrv());
|
||||
registerLiveFeatures();
|
||||
}
|
||||
|
||||
146
public/app/features/live/scopes.ts
Normal file
146
public/app/features/live/scopes.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { LiveChannelScope, LiveChannelSupport, SelectableValue } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { config } from 'app/core/config';
|
||||
import { loadPlugin } from '../plugins/PluginPage';
|
||||
|
||||
export abstract class GrafanaLiveScope {
|
||||
constructor(protected scope: LiveChannelScope) {}
|
||||
|
||||
/**
|
||||
* Load the real namespaces
|
||||
*/
|
||||
abstract async getChannelSupport(namespace: string): Promise<LiveChannelSupport | undefined>;
|
||||
|
||||
/**
|
||||
* List the possible values within this scope
|
||||
*/
|
||||
abstract async listNamespaces(): Promise<Array<SelectableValue<string>>>;
|
||||
}
|
||||
|
||||
class GrafanaLiveCoreScope extends GrafanaLiveScope {
|
||||
readonly features = new Map<string, LiveChannelSupport>();
|
||||
readonly namespaces: Array<SelectableValue<string>> = [];
|
||||
|
||||
constructor() {
|
||||
super(LiveChannelScope.Grafana);
|
||||
}
|
||||
|
||||
register(feature: string, support: LiveChannelSupport, description: string): GrafanaLiveCoreScope {
|
||||
this.features.set(feature, support);
|
||||
this.namespaces.push({
|
||||
value: feature,
|
||||
label: feature,
|
||||
description,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the real namespaces
|
||||
*/
|
||||
async getChannelSupport(namespace: string) {
|
||||
const v = this.features.get(namespace);
|
||||
if (v) {
|
||||
return Promise.resolve(v);
|
||||
}
|
||||
throw new Error('unknown feature: ' + namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the possible values within this scope
|
||||
*/
|
||||
listNamespaces() {
|
||||
return Promise.resolve(this.namespaces);
|
||||
}
|
||||
}
|
||||
export const grafanaLiveCoreFeatures = new GrafanaLiveCoreScope();
|
||||
|
||||
export class GrafanaLiveDataSourceScope extends GrafanaLiveScope {
|
||||
names?: Array<SelectableValue<string>>;
|
||||
|
||||
constructor() {
|
||||
super(LiveChannelScope.DataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the real namespaces
|
||||
*/
|
||||
async getChannelSupport(namespace: string) {
|
||||
const ds = await getDataSourceSrv().get(namespace);
|
||||
return ds.channelSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the possible values within this scope
|
||||
*/
|
||||
async listNamespaces() {
|
||||
if (this.names) {
|
||||
return Promise.resolve(this.names);
|
||||
}
|
||||
const names: Array<SelectableValue<string>> = [];
|
||||
for (const [key, ds] of Object.entries(config.datasources)) {
|
||||
if (ds.meta.live) {
|
||||
try {
|
||||
const s = this.getChannelSupport(key); // ds.name or ID?
|
||||
if (s) {
|
||||
names.push({
|
||||
label: ds.name,
|
||||
value: ds.type,
|
||||
description: ds.type,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
err.isHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (this.names = names);
|
||||
}
|
||||
}
|
||||
|
||||
export class GrafanaLivePluginScope extends GrafanaLiveScope {
|
||||
names?: Array<SelectableValue<string>>;
|
||||
|
||||
constructor() {
|
||||
super(LiveChannelScope.Plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the real namespaces
|
||||
*/
|
||||
async getChannelSupport(namespace: string) {
|
||||
const plugin = await loadPlugin(namespace);
|
||||
if (!plugin.channelSupport) {
|
||||
throw new Error('Unknown plugin: ' + namespace);
|
||||
}
|
||||
return plugin.channelSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the possible values within this scope
|
||||
*/
|
||||
async listNamespaces() {
|
||||
if (this.names) {
|
||||
return Promise.resolve(this.names);
|
||||
}
|
||||
const names: Array<SelectableValue<string>> = [];
|
||||
// TODO add list to config
|
||||
for (const [key, panel] of Object.entries(config.panels)) {
|
||||
if (panel.live) {
|
||||
try {
|
||||
const s = this.getChannelSupport(key); // ds.name or ID?
|
||||
if (s) {
|
||||
names.push({
|
||||
label: panel.name,
|
||||
value: key,
|
||||
description: panel.info?.description,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
err.isHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (this.names = names);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user