Merge remote-tracking branch 'origin/dev' into api-v1.5.5

This commit is contained in:
Taha Yassine Kraiem 2022-03-25 14:39:03 +01:00
commit 9e3a5004f0
24 changed files with 923 additions and 245 deletions

View file

@ -432,7 +432,7 @@ def __get_autocomplete_table(value, project_id):
AND value ILIKE %(value)s
LIMIT 5)""")
with pg_client.PostgresClient() as cur:
query = cur.mogrify("UNION ALL".join(sub_queries) + ";",
query = cur.mogrify(" UNION ".join(sub_queries) + ";",
{"project_id": project_id, "value": helper.string_to_sql_like(value),
"svalue": helper.string_to_sql_like("^" + value)})
cur.execute(query)

View file

@ -201,7 +201,7 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data: schemas.Funnel
data.events = filter_stages(data.events)
data.events = __fix_stages(data.events)
if len(data.events) == 0:
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id)
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id, flatten=False)
if f is None:
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=data.range_value,
@ -217,16 +217,21 @@ def get_top_insights(project_id, user_id, funnel_id, range_value=None, start_dat
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
insights = helper.list_to_camel_case(insights)
if len(insights) > 0:
# fix: this fix for huge drop count
if total_drop_due_to_issues > insights[0]["sessionsCount"]:
total_drop_due_to_issues = insights[0]["sessionsCount"]
# end fix
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"data": {"stages": helper.list_to_camel_case(insights),
return {"data": {"stages": insights,
"totalDropDueToIssues": total_drop_due_to_issues}}
def get_top_insights_on_the_fly(funnel_id, user_id, project_id, data: schemas.FunnelInsightsPayloadSchema):
data.events = filter_stages(__parse_events(data.events))
if len(data.events) == 0:
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id)
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id, flatten=False)
if f is None:
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=data.rangeValue,
@ -235,9 +240,14 @@ def get_top_insights_on_the_fly(funnel_id, user_id, project_id, data: schemas.Fu
data = schemas.FunnelInsightsPayloadSchema.parse_obj(f["filter"])
data.events = __fix_stages(data.events)
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data.dict(), project_id=project_id)
insights = helper.list_to_camel_case(insights)
if len(insights) > 0:
# fix: this fix for huge drop count
if total_drop_due_to_issues > insights[0]["sessionsCount"]:
total_drop_due_to_issues = insights[0]["sessionsCount"]
# end fix
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"data": {"stages": helper.list_to_camel_case(insights),
return {"data": {"stages": insights,
"totalDropDueToIssues": total_drop_due_to_issues}}
@ -256,7 +266,7 @@ def get_issues_on_the_fly(funnel_id, user_id, project_id, data: schemas.FunnelSe
data.events = filter_stages(data.events)
data.events = __fix_stages(data.events)
if len(data.events) == 0:
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id)
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id, flatten=False)
if f is None:
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=data.rangeValue,
@ -307,7 +317,7 @@ def get(funnel_id, project_id, user_id, flatten=True, fix_stages=True):
def search_by_issue(user_id, project_id, funnel_id, issue_id, data: schemas.FunnelSearchPayloadSchema, range_value=None,
start_date=None, end_date=None):
if len(data.events) == 0:
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id)
f = get(funnel_id=funnel_id, project_id=project_id, user_id=user_id, flatten=False)
if f is None:
return {"errors": ["funnel not found"]}
data.startDate = data.startDate if data.startDate is not None else start_date

View file

@ -385,6 +385,19 @@ def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, d
return sessions
def __is_valid_event(is_any: bool, event: schemas._SessionSearchEventSchema):
return not (not is_any and len(event.value) == 0 and event.type not in [schemas.EventType.request_details,
schemas.EventType.graphql_details] \
or event.type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
schemas.PerformanceEventType.location_avg_cpu_load,
schemas.PerformanceEventType.location_avg_memory_usage
] and (event.source is None or len(event.source) == 0) \
or event.type in [schemas.EventType.request_details, schemas.EventType.graphql_details] and (
event.filters is None or len(event.filters) == 0))
def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id, extra_event=None):
ss_constraints = []
full_args = {"project_id": project_id, "startDate": data.startDate, "endDate": data.endDate,
@ -600,6 +613,13 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
value_key=f_k))
# ---------------------------------------------------------------------------
if len(data.events) > 0:
valid_events_count = 0
for event in data.events:
is_any = _isAny_opreator(event.operator)
if not isinstance(event.value, list):
event.value = [event.value]
if __is_valid_event(is_any=is_any, event=event):
valid_events_count += 1
events_query_from = []
event_index = 0
or_events = data.events_order == schemas.SearchEventOrder._or
@ -610,16 +630,7 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
is_any = _isAny_opreator(event.operator)
if not isinstance(event.value, list):
event.value = [event.value]
if not is_any and len(event.value) == 0 and event_type not in [schemas.EventType.request_details,
schemas.EventType.graphql_details] \
or event_type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
schemas.PerformanceEventType.location_avg_cpu_load,
schemas.PerformanceEventType.location_avg_memory_usage
] and (event.source is None or len(event.source) == 0) \
or event_type in [schemas.EventType.request_details, schemas.EventType.graphql_details] and (
event.filters is None or len(event.filters) == 0):
if not __is_valid_event(is_any=is_any, event=event):
continue
op = __get_sql_operator(event.operator)
is_not = False
@ -938,7 +949,7 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
""")
else:
events_query_from.append(f"""\
(SELECT main.session_id, MIN(main.timestamp) AS timestamp
(SELECT main.session_id, {"MIN" if event_index < (valid_events_count - 1) else "MAX"}(main.timestamp) AS timestamp
FROM {event_from}
WHERE {" AND ".join(event_where)}
GROUP BY 1

View file

@ -528,7 +528,7 @@ def get_issues(stages, rows, first_stage=None, last_stage=None, drop_only=False)
split = issue.split('__^__')
issues_dict['significant' if is_sign else 'insignificant'].append({
"type": split[0],
"title": get_issue_title(split[0]),
"title": helper.get_issue_title(split[0]),
"affected_sessions": affected_sessions[issue],
"unaffected_sessions": session_counts[1] - affected_sessions[issue],
"lost_conversions": lost_conversions,
@ -641,27 +641,3 @@ def get_overview(filter_d, project_id, first_stage=None, last_stage=None):
output['stages'] = stages_list
output['criticalIssuesCount'] = n_critical_issues
return output
def get_issue_title(issue_type):
return {'click_rage': "Click Rage",
'dead_click': "Dead Click",
'excessive_scrolling': "Excessive Scrolling",
'bad_request': "Bad Request",
'missing_resource': "Missing Image",
'memory': "High Memory Usage",
'cpu': "High CPU",
'slow_resource': "Slow Resource",
'slow_page_load': "Slow Page Performance",
'crash': "Crash",
'ml_cpu': "High CPU",
'ml_memory': "High Memory Usage",
'ml_dead_click': "Dead Click",
'ml_click_rage': "Click Rage",
'ml_mouse_thrashing': "Mouse Thrashing",
'ml_excessive_scrolling': "Excessive Scrolling",
'ml_slow_resources': "Slow Resource",
'custom': "Custom Event",
'js_exception': "Error",
'custom_event_error': "Custom Error",
'js_error': "Error"}.get(issue_type, issue_type)

View file

@ -213,11 +213,11 @@ def values_for_operator(value: Union[str, list], op: schemas.SearchEventOperator
if value is None:
return value
if op == schemas.SearchEventOperator._starts_with:
return value + '%'
return f"{value}%"
elif op == schemas.SearchEventOperator._ends_with:
return '%' + value
return f"%{value}"
elif op == schemas.SearchEventOperator._contains or op == schemas.SearchEventOperator._not_contains:
return '%' + value + '%'
return f"%{value}%"
return value

View file

@ -1,3 +1,5 @@
\set ON_ERROR_STOP true
SET client_min_messages TO NOTICE;
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
@ -22,7 +24,7 @@ $$
LIMIT 1)
ORDER BY LOWER(email)) THEN
raise notice 'duplicate users detected';
FOR duplicate IN SELECT user_id, email, deleted_at, verified_email, jwt_iat
FOR duplicate IN SELECT user_id, email, deleted_at, jwt_iat
FROM users
WHERE lower(email) =
(SELECT LOWER(email)

1
frontend/.gitignore vendored
View file

@ -8,3 +8,4 @@ app/components/ui/SVG.js
*.DS_Store
.env
*css.d.ts
*.cache

View file

@ -8,14 +8,14 @@ const DOCUMENTATION = 'NPM';
// const SEGMENT = 'SEGMENT';
// const GOOGLE_TAG = 'GOOGLE TAG';
const TABS = [
{ key: PROJECT, text: PROJECT },
{ key: DOCUMENTATION, text: DOCUMENTATION },
{ key: PROJECT, text: PROJECT },
// { key: SEGMENT, text: SEGMENT },
// { key: GOOGLE_TAG, text: GOOGLE_TAG }
];
class TrackingCodeModal extends React.PureComponent {
state = { copied: false, changed: false, activeTab: PROJECT };
state = { copied: false, changed: false, activeTab: DOCUMENTATION };
setActiveTab = (tab) => {
this.setState({ activeTab: tab });

View file

@ -10,12 +10,12 @@ import cn from 'classnames';
const PROJECT = 'Using Script';
const DOCUMENTATION = 'Using NPM';
const TABS = [
{ key: DOCUMENTATION, text: DOCUMENTATION },
{ key: PROJECT, text: PROJECT },
{ key: DOCUMENTATION, text: DOCUMENTATION }
];
class TrackingCodeModal extends React.PureComponent {
state = { copied: false, changed: false, activeTab: PROJECT };
state = { copied: false, changed: false, activeTab: DOCUMENTATION };
setActiveTab = (tab) => {
this.setState({ activeTab: tab });

View file

@ -31,16 +31,6 @@ export default abstract class BaseScreen {
const screen = document.createElement('div');
setTimeout(function() {
iframe.contentDocument?.addEventListener('mousemove', function() {
overlay.style.display = 'block';
})
overlay.addEventListener('contextmenu', function() {
overlay.style.display = 'none';
})
}, 10)
screen.className = styles.screen;
screen.appendChild(iframe);
screen.appendChild(overlay);
@ -58,6 +48,20 @@ export default abstract class BaseScreen {
// parentElement.onresize = this.scale;
window.addEventListener('resize', this.scale);
this.scale();
/* == For the Inspecting Document content == */
this.overlay.addEventListener('contextmenu', () => {
this.overlay.style.display = 'none'
const doc = this.document
if (!doc) { return }
const returnOverlay = () => {
this.overlay.style.display = 'block'
doc.removeEventListener('mousemove', returnOverlay)
doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection
}
doc.addEventListener('mousemove', returnOverlay)
doc.addEventListener('mouseclick', returnOverlay)
})
}
get window(): WindowProxy | null {
@ -70,10 +74,10 @@ export default abstract class BaseScreen {
private boundingRect: DOMRect | null = null;
private getBoundingClientRect(): DOMRect {
//if (this.boundingRect === null) {
return this.boundingRect = this.overlay.getBoundingClientRect(); // expensive operation?
//}
//return this.boundingRect;
if (this.boundingRect === null) {
return this.boundingRect = this.overlay.getBoundingClientRect() // expensive operation?
}
return this.boundingRect
}
getInternalViewportCoordinates({ x, y }: Point): Point {
@ -85,17 +89,22 @@ export default abstract class BaseScreen {
const screenX = (x - overlayX) * scale;
const screenY = (y - overlayY) * scale;
return { x: screenX, y: screenY };
return { x: Math.round(screenX), y: Math.round(screenY) };
}
getCurrentScroll(): Point {
const docEl = this.document?.documentElement
const x = docEl ? docEl.scrollLeft : 0
const y = docEl ? docEl.scrollTop : 0
return { x, y }
}
getInternalCoordinates(p: Point): Point {
const { x, y } = this.getInternalViewportCoordinates(p);
const docEl = this.document?.documentElement
const scrollX = docEl ? docEl.scrollLeft : 0
const scrollY = docEl ? docEl.scrollTop : 0
const sc = this.getCurrentScroll()
return { x: x+scrollX, y: y+scrollY };
return { x: x+sc.x, y: y+sc.y };
}
getElementFromInternalPoint({ x, y }: Point): Element | null {

View file

@ -1,4 +1,5 @@
.screen {
user-select: none;
overflow: hidden;
position: absolute;
transform-origin: left top;

View file

@ -0,0 +1,84 @@
export default class AnnotationCanvas {
readonly canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D | null = null
private painting: boolean = false
constructor() {
this.canvas = document.createElement('canvas')
Object.assign(this.canvas.style, {
position: "fixed",
cursor: "url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAAXNSR0IArs4c6QAAAWNJREFUOE+l1D1Lw1AUBuD35Catg5NzaCMRMilINnGok7sguLg4OlRcBTd/hqBVB0ed7KDgIPgXhJoaG10Kgk4a83EkhcYYktimd703z31zzuESSqwGIDs1bRvAIiRcWrZ9ETFUwhJ6XTsDsPH7Le1bz08H42JkGMa09+W2CVhKBmHC7jhYlOgUTPdUEa3Q86+SIDN/j4olf43BtJMFjoJl1AgMUJMUcRInZHT+w7KgYakGoDxVafmue0hBsJeLmaapvPffziFhraDjDMKWZdvHRaNRlCi2mUNHYl55dBwrDysFZWGloTQ2EZTEJoZiTFXVmaos34Ixn9e5qNgCaHR6vW7emcFozNVmN1ERbfb9myww3bVCTK9rPsDrpCh37HnXAC3Ek5lqf9ErM0im1zUG8BmGtCqq4mEIjppoeEESA5g/JIkaLMuv7AVHEgfNohqlU/7Fol3mPodiufvS7Yz7cP4ARjbPWyYPZSMAAAAASUVORK5CYII=') 0 20, crosshair",
left: 0,
top: 0,
//zIndex: 2147483647 - 2,
})
}
isPainting() {
return this.painting
}
private resizeCanvas = () => {
if (!this.canvas.parentElement) { return }
this.canvas.width = this.canvas.parentElement.offsetWidth
this.canvas.height = this.canvas.parentElement.offsetHeight
}
private lastPosition: [number, number] = [0,0]
start = (p: [number, number]) => {
this.painting = true
this.clrTmID && clearTimeout(this.clrTmID)
this.lastPosition = p
}
stop = () => {
this.painting = false
this.fadeOut()
}
move = (p: [number, number]) =>{
if (!this.ctx || !this.painting) { return }
this.ctx.globalAlpha = 1.0
this.ctx.beginPath()
this.ctx.moveTo(this.lastPosition[0], this.lastPosition[1])
this.ctx.lineTo(p[0], p[1])
this.ctx.lineWidth = 8
this.ctx.lineCap = "round"
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = "red"
this.ctx.stroke()
this.lastPosition = p
}
clrTmID: ReturnType<typeof setTimeout> | null = null
private fadeOut() {
let timeoutID: ReturnType<typeof setTimeout>
const fadeStep = () => {
if (!this.ctx || this.painting ) { return }
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.fillStyle = "rgba(255, 255, 255, 0.1)"
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.globalCompositeOperation = 'source-over'
timeoutID = setTimeout(fadeStep,100)
}
this.clrTmID = setTimeout(() => {
clearTimeout(timeoutID)
this.ctx &&
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}, 3700)
fadeStep()
}
mount(parent: HTMLElement) {
parent.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
window.addEventListener("resize", this.resizeCanvas)
this.resizeCanvas()
}
remove() {
if (this.canvas.parentNode){
this.canvas.parentNode.removeChild(this.canvas)
}
window.removeEventListener("resize", this.resizeCanvas)
}
}

View file

@ -7,8 +7,8 @@ import store from 'App/store';
import type { LocalStream } from './LocalStream';
import { update, getState } from '../../store';
import { iceServerConfigFromString } from 'App/utils'
import MStreamReader from '../messages/MStreamReader';;
import AnnotationCanvas from './AnnotationCanvas';
import MStreamReader from '../messages/MStreamReader';
import JSONRawMessageReader from '../messages/JSONRawMessageReader'
export enum CallingState {
@ -136,12 +136,13 @@ export default class AssistManager {
//socket.onAny((...args) => console.log(...args))
socket.on("connect", () => {
waitingForMessages = true
this.setStatus(ConnectionStatus.WaitingMessages)
this.setStatus(ConnectionStatus.WaitingMessages) // TODO: happens frequently on bad network
})
socket.on("disconnect", () => {
this.toggleRemoteControl(false)
})
socket.on('messages', messages => {
//console.log(messages.filter(m => m._id === 41 || m._id === 44))
showDisconnectTimeout && clearTimeout(showDisconnectTimeout);
jmr.append(messages) // as RawMessage[]
@ -173,9 +174,8 @@ export default class AssistManager {
this.setStatus(ConnectionStatus.Disconnected)
}, 30000)
if (getState().remoteControl === RemoteControlStatus.Requesting ||
getState().remoteControl === RemoteControlStatus.Enabled) {
this.toggleRemoteControl(false)
if (getState().remoteControl === RemoteControlStatus.Requesting) {
this.toggleRemoteControl(false) // else its remaining
}
// Call State
@ -200,7 +200,7 @@ export default class AssistManager {
private onMouseMove = (e: MouseEvent): void => {
if (!this.socket) { return }
const data = this.md.getInternalCoordinates(e)
this.socket.emit("move", [ Math.round(data.x), Math.round(data.y) ])
this.socket.emit("move", [ data.x, data.y ])
}
private onWheel = (e: WheelEvent): void => {
@ -213,15 +213,23 @@ export default class AssistManager {
private onMouseClick = (e: MouseEvent): void => {
if (!this.socket) { return; }
const data = this.md.getInternalViewportCoordinates(e);
const data = this.md.getInternalViewportCoordinates(e)
// const el = this.md.getElementFromPoint(e); // requires requestiong node_id from domManager
const el = this.md.getElementFromInternalPoint(data)
if (el instanceof HTMLElement) {
el.focus()
el.oninput = e => e.preventDefault();
el.onkeydown = e => e.preventDefault();
el.oninput = e => {
if (el instanceof HTMLTextAreaElement
|| el instanceof HTMLInputElement
) {
this.socket && this.socket.emit("input", el.value)
} else if (el.isContentEditable) {
this.socket && this.socket.emit("input", el.innerText)
}
}
//el.onkeydown = e => e.preventDefault()
}
this.socket.emit("click", [ Math.round(data.x), Math.round(data.y) ]);
this.socket.emit("click", [ data.x, data.y ]);
}
private toggleRemoteControl(newState: boolean){
@ -310,6 +318,8 @@ export default class AssistManager {
this.callConnection && this.callConnection.close()
update({ calling: CallingState.NoCall })
this.callArgs = null
this.annot?.remove()
this.annot = null
}
private initiateCallEnd = () => {
@ -355,6 +365,8 @@ export default class AssistManager {
}
}
private annot: AnnotationCanvas | null = null
private _call() {
if (![CallingState.NoCall, CallingState.Reconnecting].includes(getState().calling)) { return }
update({ calling: CallingState.Connecting })
@ -379,6 +391,34 @@ export default class AssistManager {
call.on('stream', stream => {
update({ calling: CallingState.OnCall })
this.callArgs && this.callArgs.onStream(stream)
if (!this.annot) {
const annot = this.annot = new AnnotationCanvas()
annot.mount(this.md.overlay)
annot.canvas.addEventListener("mousedown", e => {
if (!this.socket) { return }
const data = this.md.getInternalViewportCoordinates(e)
annot.start([ data.x, data.y ])
this.socket.emit("startAnnotation", [ data.x, data.y ])
})
annot.canvas.addEventListener("mouseleave", () => {
if (!this.socket) { return }
annot.stop()
this.socket.emit("stopAnnotation")
})
annot.canvas.addEventListener("mouseup", () => {
if (!this.socket) { return }
annot.stop()
this.socket.emit("stopAnnotation")
})
annot.canvas.addEventListener("mousemove", e => {
if (!this.socket || !annot.isPainting()) { return }
const data = this.md.getInternalViewportCoordinates(e)
annot.move([ data.x, data.y ])
this.socket.emit("moveAnnotation", [ data.x, data.y ])
})
}
});
//call.peerConnection.addEventListener("track", e => console.log('newtrack',e.track))
@ -409,6 +449,10 @@ export default class AssistManager {
this.socket.close()
document.removeEventListener('visibilitychange', this.onVisChange)
}
if (this.annot) {
this.annot.remove()
this.annot = null
}
}
}

View file

@ -13,7 +13,7 @@ const oss = {
ORIGIN: () => 'window.location.origin',
API_EDP: () => 'window.location.origin + "/api"',
ASSETS_HOST: () => 'window.location.origin + "/assets"',
VERSION: '1.5.3',
VERSION: '1.5.4',
SOURCEMAP: true,
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
MINIO_PORT: process.env.MINIO_PORT,
@ -21,7 +21,7 @@ const oss = {
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
ICE_SERVERS: process.env.ICE_SERVERS,
TRACKER_VERSION: '3.5.3' // trackerInfo.version,
TRACKER_VERSION: '3.5.4' // trackerInfo.version,
}
module.exports = {

View file

@ -1,3 +1,5 @@
\set ON_ERROR_STOP true
SET client_min_messages TO NOTICE;
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
@ -22,7 +24,7 @@ $$
LIMIT 1)
ORDER BY LOWER(email)) THEN
raise notice 'duplicate users detected';
FOR duplicate IN SELECT user_id, email, deleted_at, verified_email, jwt_iat
FOR duplicate IN SELECT user_id, email, deleted_at, jwt_iat
FROM users
WHERE lower(email) =
(SELECT LOWER(email)

View file

@ -17,13 +17,13 @@ function migrate() {
IFS=',' read -r -a migration_versions <<< "$1"
for version in ${migration_versions[*]}; do
echo "Migrating postgresql version $version"
psql -f ${pgdir}/${version}/${version}.sql
psql -f ${pgdir}/${version}/${version}.sql 2>&1
done
}
function init() {
echo "Initializing postgresql"
psql -f ${pgdir}/init_schema.sql
psql -f ${pgdir}/init_schema.sql 2>&1
}
# /bin/bash postgresql.sh migrate $migration_versions

View file

@ -9,8 +9,15 @@
<!--CSS -->
<!-- <link href="css/styles.css" rel="stylesheet"> -->
<style>
body {
margin: 0;
padding: 0;
}
.text-uppercase {
text-transform: uppercase;
}
.connecting-message {
margin-top: 50%;
/* margin-top: 50%; */
font-size: 20px;
color: #aaa;
text-align: center;
@ -19,31 +26,90 @@
}
.status-connecting .connecting-message {
display: block;
/* display: block; */
}
.status-connecting .card {
display: none;
/* display: none; */
}
.card{
min-width: 324px;
width: 350px;
max-width: 800px;
/*min-height: 220px;*/
max-height: 450px;
/*resize: both;
overflow: auto;*/
font: 14px 'Roboto', sans-serif;
/* min-width: 324px; */
width: 300px;
/* max-width: 800px; */
/* border: solid thin #ccc; */
/* box-shadow: 0 0 10px #aaa; */
border: solid 4px rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.card-footers {
display: flex;
border-bottom: solid thin #CCC;
padding: 5px 5px;
justify-content: space-between;
}
.card-footers .assist-controls {
display: flex;
align-items: center;
}
.btn-danger {
background-color: #CC0000 !important;
color: white;
}
.btn-danger:hover {
background-color: #FF0000 !important;
color: white;
}
.btn {
padding: 5px 8px;
font-size: 14px;
border-radius: 3px;
background-color: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
}
.btn span {
margin-left: 10px;
}
.btn:hover {
filter: brightness(0.9);
}
.card .card-header{
cursor: move;
padding: 14px 18px;
display: flex;
justify-content: space-between;
border-bottom: solid thin #ccc;
}
#agent-name, #duration{
cursor:default;
}
#video-container {
background-color: rgb(90, 90, 90);
position: relative;
overflow: hidden;
/* width: 300px; */
}
#video-container video {
width: 100% !important;
height: auto;
object-fit: cover;
}
#local-stream, #remote-stream {
display:none;
/* display:none; */ /* TODO uncomment this line */
}
#video-container.remote #remote-stream {
display: block;
@ -57,20 +123,30 @@
#local-stream{
width: 35%;
/* top: 50%; */
/* left: 70%; */
position: absolute;
z-index: 99;
bottom: 5px;
right: 5px;
border: thin solid rgba(255,255,255, .3);
overflow: hidden;
}
#audio-btn {
margin-right: 10px;
}
#audio-btn .bi-mic {
fill: #CC0000;
}
#audio-btn .bi-mic-mute {
display:none;
}
#audio-btn:after {
text-transform: capitalize;
content: 'Mute'
/* text-transform: capitalize; */
color: #CC0000;
content: 'Mute';
padding-left: 5px;
}
#audio-btn.muted .bi-mic-mute {
display: inline-block;
@ -79,19 +155,26 @@
display:none;
}
#audio-btn.muted:after {
content: 'Unmute'
content: 'Unmute';
padding-left: 5px;
}
#video-btn .bi-camera-video {
fill: #CC0000;
}
#video-btn .bi-camera-video-off {
display:none;
}
#video-btn:after {
text-transform: capitalize;
content: 'Stop Video'
/* text-transform: capitalize; */
color: #CC0000;
content: 'Stop Video';
padding-left: 5px;
}
#video-btn.off:after {
content: 'Start Video'
content: 'Start Video';
padding-left: 5px;
}
#video-btn.off .bi-camera-video-off {
display: inline-block;
@ -100,16 +183,201 @@
display:none;
}
/* CHART */
#chat-card {
display: flex;
flex-direction: column;
font-size: 14px;
background-color: white;
}
#chat-card .chat-messages { display: none; }
#chat-card .chat-input { display: none; }
#chat-card .chat-header .arrow-state { transform: rotate(180deg); }
#chat-card.active .chat-messages { display: flex; }
#chat-card.active .chat-input { display: flex; }
#chat-card.active .chat-header .arrow-state { transform: rotate(0deg); }
#chat-card .chat-header {
border-bottom: solid thin #ccc;
padding: 8px 16px;
display: flex;
justify-content: space-between;
cursor: pointer;
}
#chat-card .chat-header .chat-title {
display: flex;
align-items: center;
}
#chat-card .chat-header .chat-title span {
margin-left: 6px;
}
#chat-card .chat-messages {
padding: 8px 16px;
overflow-y: auto;
height: 250px;
overflow-y: auto;
flex-direction: column;
justify-content: flex-end;
}
#chat-card .message-text {
padding: 8px 16px;
border-radius: 20px;
color: #666666;
margin-bottom: 2px;
}
#chat-card .message .message-text {
/* max-width: 70%; */
width: fit-content;
}
#chat-card .message {
margin-bottom: 15px;
}
#chat-card .chat-messages .message.left .message-text {
text-align: left;
background: #D7E2E2;
border-radius: 0px 30px 30px 30px;
}
#chat-card .message .message-user {
font-size: 12px;
font-weight: bold;
color: #999999;
}
#chat-card .message .message-time {
font-size: 12px;
color: #999999;
margin-left: 4px;
}
#chat-card .chat-messages .message.right {
margin-left: auto;
text-align: right;
}
#chat-card .chat-messages .message.right .message-text {
background: #E4E4E4;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
border-radius: 30px 30px 0px 30px;
}
#chat-card .chat-input {
margin: 10px;
border-radius: 3px;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
background-color: #DDDDDD;
position: relative;
}
#chat-card .chat-input .input {
width: 100%;
border: none;
border-radius: 0px;
padding: 8px 16px;
font-size: 16px;
color: #333;
background-color: transparent;
}
.send-btn {
width: 26px;
height: 26px;
background-color: #AAA;
position: absolute;
right: 5px;
top: 0;
bottom: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: auto;
cursor: pointer;
}
.send-btn:hover {
background-color: #999;
}
.send-btn svg {
fill: #DDDDDD;
}
.confirm-window .title {
margin-bottom: 10px;
}
.confirm-window {
font: 14px 'Roboto', sans-serif;
padding: 20px;
background-color: #F3F3F3;
border-radius: 3px;
/* position: absolute; */
width: fit-content;
color: #666666;
display: none;
}
.confirm-window .actions {
background-color: white;
padding: 10px;
display: flex;
box-shadow: 0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
.btn-lg {
font-size: 14px;
padding: 10px 14px;
}
.btn-success {
background: rgba(0, 167, 47, 1);
color: white;
}
/* .btn-error:hover,
.btn-success:hover {
filter: brightness(0.9);
} */
.btn-error {
background: #FFE9E9;
/* border-color: #d43f3a; */
color: #CC0000;
}
</style>
<link href="css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="remote-control-confirm" class="confirm-window">
<div class="title">The agent is requesting remote control</div>
<div class="actions">
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">Grant remote access</button>
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
</div>
</div>
<div id="call-confirm" class="confirm-window">
<div class="title">Answer the call so the agent can assist.</div>
<div class="actions">
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-telephone" viewBox="0 0 16 16">
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
</svg>
<span>Answer</span>
</button>
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
</div>
</div>
<section id="or-assist" class="status-connecting">
<div class="connecting-message"> Connecting... </div>
<div class="card border-dark shadow">
<div class="card shadow">
<div class="drag-area card-header d-flex justify-content-between">
<div class="user-info">
<span>Call with</span>
@ -123,44 +391,98 @@
</div>
<div id="video-container" class="card-body bg-dark p-0 d-flex align-items-center position-relative">
<div id="local-stream" class="ratio ratio-4x3 rounded m-0 p-0 shadow">
<p class="text-white m-auto text-center">Starting video...</p>
<!-- <p class="text-white m-auto text-center">Starting video...</p> -->
<video id="video-local" autoplay muted></video>
</div>
<div id="remote-stream" class="ratio ratio-4x3 m-0 p-0">
<p id="remote-stream-placeholder" class="text-white m-auto text-center">Starting video...</p>
<!-- <p id="remote-stream-placeholder" class="text-white m-auto text-center">Starting video...</p> -->
<video id="video-remote" autoplay></video>
</div>
</div>
<div class="card-footer bg-transparent d-flex justify-content-between">
<div class="card-footers">
<div class="assist-controls">
<a href="#" id="audio-btn" class="btn btn-light btn-sm text-uppercase me-2"><i>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mic" viewBox="0 0 16 16">
<!-- Add class .muted to #audio-btn when user mutes audio -->
<button
href="#"
id="audio-btn"
class="btn btn-light btn-sm text-uppercase me-2"
>
<i>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic" viewBox="0 0 16 16">
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z"/>
<path d="M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0v5zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mic-mute" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic-mute" viewBox="0 0 16 16">
<path d="M13 8c0 .564-.094 1.107-.266 1.613l-.814-.814A4.02 4.02 0 0 0 12 8V7a.5.5 0 0 1 1 0v1zm-5 4c.818 0 1.578-.245 2.212-.667l.718.719a4.973 4.973 0 0 1-2.43.923V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 1 0v1a4 4 0 0 0 4 4zm3-9v4.879l-1-1V3a2 2 0 0 0-3.997-.118l-.845-.845A3.001 3.001 0 0 1 11 3z"/>
<path d="m9.486 10.607-.748-.748A2 2 0 0 1 6 8v-.878l-1-1V8a3 3 0 0 0 4.486 2.607zm-7.84-9.253 12 12 .708-.708-12-12-.708.708z"/>
</svg>
</i></a>
<!-- Add class .mute to #audio-btn when user mutes audio -->
<a href="#" id="video-btn" class="off btn btn-light btn-sm text-uppercase ms-2"><i >
</i>
</button>
<!--Add class .off to #video-btn when user stops video -->
<button
href="#"
id="video-btn"
class="btn btn-light btn-sm text-uppercase ms-2"
>
<i>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera-video" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera-video-off" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.961 12.365a1.99 1.99 0 0 0 .522-1.103l3.11 1.382A1 1 0 0 0 16 11.731V4.269a1 1 0 0 0-1.406-.913l-3.111 1.382A2 2 0 0 0 9.5 3H4.272l.714 1H9.5a1 1 0 0 1 1 1v6a1 1 0 0 1-.144.518l.605.847zM1.428 4.18A.999.999 0 0 0 1 5v6a1 1 0 0 0 1 1h5.014l.714 1H2a2 2 0 0 1-2-2V5c0-.675.334-1.272.847-1.634l.58.814zM15 11.73l-3.5-1.555v-4.35L15 4.269v7.462zm-4.407 3.56-10-14 .814-.58 10 14-.814.58z"/>
</svg>
</i>
</button>
</div>
<button id="end-call-btn" href="#" class="btn btn-danger btn-sm text-uppercase" style="margin-right: 8px;">End</button>
</div>
</i></a>
<!--Add class .off to #video-btn when user stops video -->
<!-- CHAT - add .active class to show the messages and input -->
<div id="chat-card" class="active">
<div class="chat-header">
<div class="chat-title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-chat" viewBox="0 0 16 16">
<path d="M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z"/>
</svg>
<span>Chat</span>
</div>
<div class="assist-end">
<a id="end-call-btn" href="#" class="btn btn-danger btn-sm text-uppercase">End</a>
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="bi bi-chevron-up arrow-state" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z"/>
</svg>
</div>
</div>
<div class="chat-messages">
<div class="message left">
<div class="message-text"> Hey, did you get the key? </div>
<div>
<span class="message-user">Username</span>
<span class="message-time"> 00:00 </span>
</div>
</div>
<div class="message right">
<div class="message-text">
Oui, merci!
</div>
<div>
<span class="message-user">Username</span>
<span class="message-time">00:00</span>
</div>
</div>
</div>
<div class="chat-input">
<input type="text" class="input" placeholder="Type a message...">
<div class="send-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</svg>
</div>
</div>
</div>
</div>
</section>

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC",
"version": "3.5.5",
"version": "3.5.6",
"keywords": [
"WebRTC",
"assistance",

View file

@ -0,0 +1,80 @@
export default class AnnotationCanvas {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D | null = null
private painting: boolean = false
constructor() {
this.canvas = document.createElement('canvas')
Object.assign(this.canvas.style, {
position: "fixed",
left: 0,
top: 0,
pointerEvents: "none",
zIndex: 2147483647 - 2,
})
}
private resizeCanvas = () => {
this.canvas.width = window.innerWidth
this.canvas.height = window.innerHeight
}
private lastPosition: [number, number] = [0,0]
start = (p: [number, number]) => {
this.painting = true
this.clrTmID && clearTimeout(this.clrTmID)
this.lastPosition = p
}
stop = () => {
this.painting = false
this.fadeOut()
}
move = (p: [number, number]) =>{
if (!this.ctx || !this.painting) { return }
this.ctx.globalAlpha = 1.0
this.ctx.beginPath()
this.ctx.moveTo(this.lastPosition[0], this.lastPosition[1])
this.ctx.lineTo(p[0], p[1])
this.ctx.lineWidth = 8
this.ctx.lineCap = "round"
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = "red"
this.ctx.stroke()
this.lastPosition = p
}
clrTmID: ReturnType<typeof setTimeout> | null = null
private fadeOut() {
let timeoutID: ReturnType<typeof setTimeout>
const fadeStep = () => {
if (!this.ctx || this.painting ) { return }
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.fillStyle = "rgba(255, 255, 255, 0.1)"
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.globalCompositeOperation = 'source-over'
timeoutID = setTimeout(fadeStep,100)
}
this.clrTmID = setTimeout(() => {
clearTimeout(timeoutID)
this.ctx &&
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}, 4000)
fadeStep()
}
mount() {
document.body.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
window.addEventListener("resize", this.resizeCanvas)
this.resizeCanvas()
}
remove() {
if (this.canvas.parentNode){
this.canvas.parentNode.removeChild(this.canvas)
}
window.removeEventListener("resize", this.resizeCanvas)
}
}

View file

@ -5,8 +5,9 @@ import type { Properties } from 'csstype';
import { App } from '@openreplay/tracker';
import RequestLocalStream from './LocalStream.js';
import Mouse from './Mouse.js';
import RemoteControl from './RemoteControl.js';
import CallWindow from './CallWindow.js';
import AnnotationCanvas from './AnnotationCanvas.js';
import ConfirmWindow, { callConfirmDefault, controlConfirmDefault } from './ConfirmWindow.js';
import type { Options as ConfirmOptions } from './ConfirmWindow.js';
@ -14,12 +15,12 @@ import type { Options as ConfirmOptions } from './ConfirmWindow.js';
//@ts-ignore peerjs hack for webpack5 (?!) TODO: ES/node modules;
Peer = Peer.default || Peer;
type BehinEndCallback = () => ((()=>{}) | void)
type StartEndCallback = () => ((()=>{}) | void)
export interface Options {
onAgentConnect: BehinEndCallback,
onCallStart: BehinEndCallback,
onRemoteControlStart: BehinEndCallback,
onAgentConnect: StartEndCallback,
onCallStart: StartEndCallback,
onRemoteControlStart: StartEndCallback,
session_calling_peer_key: string,
session_control_peer_key: string,
callConfirm: ConfirmOptions,
@ -39,8 +40,11 @@ enum CallingState {
};
// TODO typing????
type OptionalCallback = (()=>{}) | void
type Agent = {
onDisconnect: ((()=>{}) | void), // TODO: better types here
onDisconnect?: OptionalCallback,
onControlReleased?: OptionalCallback,
name?: string
//
}
@ -139,6 +143,34 @@ export default class Assist {
})
socket.onAny((...args) => app.debug.log("Socket:", ...args))
const remoteControl = new RemoteControl(
this.options,
id => {
this.agents[id].onControlReleased = this.options.onRemoteControlStart()
this.emit("control_granted", id)
},
id => {
const cb = this.agents[id].onControlReleased
delete this.agents[id].onControlReleased
typeof cb === "function" && cb()
this.emit("control_rejected", id)
},
)
// TODO: check incoming args
socket.on("request_control", remoteControl.requestControl)
socket.on("release_control", remoteControl.releaseControl)
socket.on("scroll", remoteControl.scroll)
socket.on("click", remoteControl.click)
socket.on("move", remoteControl.move)
socket.on("input", remoteControl.input)
let annot: AnnotationCanvas | null = null
socket.on("moveAnnotation", (_, p) => annot && annot.move(p)) // TODO: restrict by id
socket.on("startAnnotation", (_, p) => annot && annot.start(p))
socket.on("stopAnnotation", () => annot && annot.stop())
socket.on("NEW_AGENT", (id: string, info) => {
this.agents[id] = {
onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(),
@ -148,7 +180,7 @@ export default class Assist {
this.app.stop();
this.app.start().then(() => { this.assistDemandedRestart = false })
})
socket.on("AGENTS_CONNECTED", (ids) => {
socket.on("AGENTS_CONNECTED", (ids: string[]) => {
ids.forEach(id =>{
this.agents[id] = {
onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(),
@ -157,61 +189,10 @@ export default class Assist {
this.assistDemandedRestart = true
this.app.stop();
this.app.start().then(() => { this.assistDemandedRestart = false })
const storedControllingAgent = sessionStorage.getItem(this.options.session_control_peer_key)
if (storedControllingAgent !== null && ids.includes(storedControllingAgent)) {
grantControl(storedControllingAgent)
socket.emit("control_granted", storedControllingAgent)
} else {
sessionStorage.removeItem(this.options.session_control_peer_key)
}
remoteControl.reconnect(ids)
})
let confirmRC: ConfirmWindow | null = null
const mouse = new Mouse() // TODO: lazy init
let controllingAgent: string | null = null
const requestControl = (id: string) => {
if (controllingAgent !== null) {
socket.emit("control_rejected", id)
return
}
controllingAgent = id // TODO: more explicit pending state
confirmRC = new ConfirmWindow(controlConfirmDefault(this.options.controlConfirm))
confirmRC.mount().then(allowed => {
if (allowed) {
grantControl(id)
socket.emit("control_granted", id)
} else {
releaseControl()
socket.emit("control_rejected", id)
}
}).catch()
}
let onRemoteControlStop: (()=>void) | null = null
const grantControl = (id: string) => {
controllingAgent = id
mouse.mount()
onRemoteControlStop = this.options.onRemoteControlStart() || null
sessionStorage.setItem(this.options.session_control_peer_key, id)
}
const releaseControl = () => {
typeof onRemoteControlStop === 'function' && onRemoteControlStop()
onRemoteControlStop = null
confirmRC?.remove()
mouse.remove()
controllingAgent = null
sessionStorage.removeItem(this.options.session_control_peer_key)
}
socket.on("request_control", requestControl)
socket.on("release_control", (id: string) => {
if (controllingAgent !== id) { return }
releaseControl()
})
socket.on("scroll", (id, d) => { id === controllingAgent && mouse.scroll(d) })
socket.on("click", (id, xy) => { id === controllingAgent && mouse.click(xy) })
socket.on("move", (id, xy) => { id === controllingAgent && mouse.move(xy) })
let confirmCall:ConfirmWindow | null = null
socket.on("AGENT_DISCONNECTED", (id) => {
@ -219,7 +200,7 @@ export default class Assist {
this.agents[id] && this.agents[id].onDisconnect != null && this.agents[id].onDisconnect()
delete this.agents[id]
controllingAgent === id && releaseControl()
remoteControl.releaseControl(id)
// close the call also
if (callingAgent === id) {
@ -281,11 +262,20 @@ export default class Assist {
style: this.options.confirmStyle,
}))
confirmAnswer = confirmCall.mount()
this.playNotificationSound()
this.onRemoteCallEnd = () => { // if call cancelled by a caller before confirmation
app.debug.log("Received call_end during confirm window opened")
confirmCall?.remove()
setCallingState(CallingState.False)
call.close()
}
setTimeout(() => {
if (this.callingState !== CallingState.Requesting) { return }
call.close()
confirmCall?.remove()
this.notifyCallEnd()
setCallingState(CallingState.False)
}, 30000)
}
confirmAnswer.then(agreed => {
@ -296,13 +286,17 @@ export default class Assist {
return
}
let callUI = new CallWindow()
const callUI = new CallWindow()
annot = new AnnotationCanvas()
annot.mount()
callUI.setAssistentName(agentName)
const onCallEnd = this.options.onCallStart()
const handleCallEnd = () => {
call.close()
callUI.remove()
annot && annot.remove()
annot = null
setCallingState(CallingState.False)
onCallEnd && onCallEnd()
}
@ -350,6 +344,16 @@ export default class Assist {
});
}
private playNotificationSound() {
if ('Audio' in window) {
new Audio("https://static.openreplay.com/tracker-assist/notification.mp3")
.play()
.catch(e => {
this.app.debug.warn(e)
})
}
}
private clean() {
if (this.peer) {
this.peer.destroy()

View file

@ -2,75 +2,94 @@ import type { Properties } from 'csstype';
import { declineCall, acceptCall, cross, remoteControl } from './icons.js'
type ButtonOptions = HTMLButtonElement | string | {
innerHTML: string,
style?: Properties,
}
const TEXT_GRANT_REMORTE_ACCESS = "Grant Remote Access";
const TEXT_REJECT = "Reject";
const TEXT_ANSWER_CALL = `${acceptCall} &#xa0 Answer`;
type ButtonOptions =
| HTMLButtonElement
| string
| {
innerHTML: string;
style?: Properties;
};
// TODO: common strategy for InputOptions/defaultOptions merging
interface ConfirmWindowOptions {
text: string,
style?: Properties,
confirmBtn: ButtonOptions,
declineBtn: ButtonOptions,
text: string;
style?: Properties;
confirmBtn: ButtonOptions;
declineBtn: ButtonOptions;
}
export type Options = string | Partial<ConfirmWindowOptions>
export type Options = string | Partial<ConfirmWindowOptions>;
function confirmDefault(
opts: Options,
confirmBtn: ButtonOptions,
declineBtn: ButtonOptions,
text: string,
text: string
): ConfirmWindowOptions {
const isStr = typeof opts === "string"
return Object.assign({
text: isStr ? opts : text,
confirmBtn,
declineBtn,
}, isStr ? undefined : opts)
const isStr = typeof opts === "string";
return Object.assign(
{
text: isStr ? opts : text,
confirmBtn,
declineBtn
},
isStr ? undefined : opts
);
}
export const callConfirmDefault = (opts: Options) =>
confirmDefault(opts, acceptCall, declineCall, "You have an incoming call. Do you want to answer?")
export const controlConfirmDefault = (opts: Options) =>
confirmDefault(opts, remoteControl, cross, "Allow remote control?")
export const callConfirmDefault = (opts: Options) =>
confirmDefault(
opts,
TEXT_ANSWER_CALL,
TEXT_REJECT,
"You have an incoming call. Do you want to answer?"
);
export const controlConfirmDefault = (opts: Options) =>
confirmDefault(
opts,
TEXT_GRANT_REMORTE_ACCESS,
TEXT_REJECT,
"Allow remote control?"
);
function makeButton(options: ButtonOptions): HTMLButtonElement {
if (options instanceof HTMLButtonElement) {
return options
return options;
}
const btn = document.createElement('button')
const btn = document.createElement("button");
Object.assign(btn.style, {
background: "transparent",
padding: 0,
margin: 0,
border: 0,
padding: "10px 14px",
fontSize: "14px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
borderRadius: "50%",
width: "22px",
height: "22px",
color: "white", // TODO: nice text button in case when only text is passed
})
display: "flex",
alignItems: "center",
textTransform: "uppercase",
marginRight: "10px"
});
if (typeof options === "string") {
btn.innerHTML = options
btn.innerHTML = options;
} else {
btn.innerHTML = options.innerHTML
Object.assign(btn.style, options.style)
btn.innerHTML = options.innerHTML;
Object.assign(btn.style, options.style);
}
return btn
return btn;
}
export default class ConfirmWindow {
private wrapper: HTMLDivElement;
constructor(options: ConfirmWindowOptions) {
const wrapper = document.createElement('div');
const popup = document.createElement('div');
const p = document.createElement('p');
const wrapper = document.createElement("div");
const popup = document.createElement("div");
const p = document.createElement("p");
p.innerText = options.text;
const buttons = document.createElement('div');
const buttons = document.createElement("div");
const confirmBtn = makeButton(options.confirmBtn);
const declineBtn = makeButton(options.declineBtn);
buttons.appendChild(confirmBtn);
@ -78,27 +97,45 @@ export default class ConfirmWindow {
popup.appendChild(p);
popup.appendChild(buttons);
Object.assign(confirmBtn.style, {
background: "rgba(0, 167, 47, 1)",
color: "white"
});
Object.assign(declineBtn.style, {
background: "#FFE9E9",
color: "#CC0000"
});
Object.assign(buttons.style, {
marginTop: "10px",
display: "flex",
alignItems: "center",
justifyContent: "space-evenly",
// justifyContent: "space-evenly",
backgroundColor: "white",
padding: "10px",
boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)",
borderRadius: "6px"
});
Object.assign(popup.style, {
position: "relative",
pointerEvents: "auto",
margin: "4em auto",
width: "90%",
maxWidth: "400px",
padding: "25px 30px",
background: "black",
opacity: ".75",
color: "white",
textAlign: "center",
borderRadius: ".25em .25em .4em .4em",
boxShadow: "0 0 20px rgb(0 0 0 / 20%)",
}, options.style);
Object.assign(
popup.style,
{
font: "14px 'Roboto', sans-serif",
position: "relative",
pointerEvents: "auto",
margin: "4em auto",
width: "90%",
maxWidth: "fit-content",
padding: "20px",
background: "#F3F3F3",
//opacity: ".75",
color: "black",
borderRadius: "3px",
boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)"
},
options.style
);
Object.assign(wrapper.style, {
position: "fixed",
@ -107,8 +144,8 @@ export default class ConfirmWindow {
height: "100%",
width: "100%",
pointerEvents: "none",
zIndex: 2147483647 - 1,
})
zIndex: 2147483647 - 1
});
wrapper.appendChild(popup);
this.wrapper = wrapper;
@ -116,18 +153,19 @@ export default class ConfirmWindow {
confirmBtn.onclick = () => {
this._remove();
this.resolve(true);
}
};
declineBtn.onclick = () => {
this._remove();
this.resolve(false);
}
};
}
private resolve: (result: boolean) => void = ()=>{};
private reject: ()=>void = ()=>{};
private resolve: (result: boolean) => void = () => {};
private reject: () => void = () => {};
mount(): Promise<boolean> {
document.body.appendChild(this.wrapper);
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
@ -135,7 +173,9 @@ export default class ConfirmWindow {
}
private _remove() {
if (!this.wrapper.parentElement) { return; }
if (!this.wrapper.parentElement) {
return;
}
document.body.removeChild(this.wrapper);
}
remove() {

View file

@ -45,7 +45,9 @@ export default class Mouse {
if (el instanceof HTMLElement) {
el.click()
el.focus()
return el
}
return null
}
private readonly pScrEl = document.scrollingElement || document.documentElement // Is it always correct

View file

@ -0,0 +1,88 @@
import Mouse from './Mouse.js';
import ConfirmWindow, { controlConfirmDefault } from './ConfirmWindow.js';
import type { Options as AssistOptions } from './Assist'
enum RCStatus {
Disabled,
Requesting,
Enabled,
}
export default class RemoteControl {
private mouse: Mouse | null
private status: RCStatus = RCStatus.Disabled
private agentID: string | null = null
constructor(
private options: AssistOptions,
private onGrand: (sting?) => void,
private onRelease: (sting?) => void) {}
reconnect(ids: string[]) {
const storedID = sessionStorage.getItem(this.options.session_control_peer_key)
if (storedID !== null && ids.includes(storedID)) {
this.grantControl(storedID)
} else {
sessionStorage.removeItem(this.options.session_control_peer_key)
}
}
private confirm: ConfirmWindow | null = null
requestControl = (id: string) => {
if (this.agentID !== null) {
this.releaseControl(id)
return
}
setTimeout(() =>{
if (this.status === RCStatus.Requesting) {
this.releaseControl(id)
}
}, 30000)
this.agentID = id
this.status = RCStatus.Requesting
this.confirm = new ConfirmWindow(controlConfirmDefault(this.options.controlConfirm))
this.confirm.mount().then(allowed => {
if (allowed) {
this.grantControl(id)
} else {
this.releaseControl(id)
}
}).catch()
}
grantControl = (id: string) => {
this.agentID = id
this.status = RCStatus.Enabled
this.mouse = new Mouse()
this.mouse.mount()
sessionStorage.setItem(this.options.session_control_peer_key, id)
this.onGrand(id)
}
releaseControl = (id: string) => {
if (this.agentID !== id) { return }
this.confirm?.remove()
this.mouse?.remove()
this.mouse = null
this.status = RCStatus.Disabled
this.agentID = null
sessionStorage.removeItem(this.options.session_control_peer_key)
this.onRelease(id)
}
scroll = (id, d) => { id === this.agentID && this.mouse?.scroll(d) }
move = (id, xy) => { id === this.agentID && this.mouse?.move(xy) }
private focused: HTMLElement | null = null
click = (id, xy) => {
if (id !== this.agentID || !this.mouse) { return }
this.focused = this.mouse.click(xy)
}
input = (id, value) => {
if (id !== this.agentID || !this.mouse || !this.focused) { return }
if (this.focused instanceof HTMLTextAreaElement
|| this.focused instanceof HTMLInputElement) {
this.focused.value = value
} else if (this.focused.isContentEditable) {
this.focused.innerText = value
}
}
}

View file

@ -2,7 +2,9 @@
// TODO: something with these big strings in bundle?
export const declineCall = `<svg xmlns="http://www.w3.org/2000/svg" height="22" width="22" viewBox="0 0 128 128" ><g id="Circle_Grid" data-name="Circle Grid"><circle cx="64" cy="64" fill="#ef5261" r="64"/></g><g id="icon"><path d="m57.831 70.1c8.79 8.79 17.405 12.356 20.508 9.253l4.261-4.26a7.516 7.516 0 0 1 10.629 0l9.566 9.566a7.516 7.516 0 0 1 0 10.629l-7.453 7.453c-7.042 7.042-27.87-2.358-47.832-22.319-9.976-9.981-16.519-19.382-20.748-28.222s-5.086-16.091-1.567-19.61l7.453-7.453a7.516 7.516 0 0 1 10.629 0l9.566 9.563a7.516 7.516 0 0 1 0 10.629l-4.264 4.271c-3.103 3.1.462 11.714 9.252 20.5z" fill="#eeefee"/></g></svg>`;
export const declineCall = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-telephone" viewBox="0 0 16 16">
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
</svg>`;
export const acceptCall = declineCall.replace('fill="#ef5261"', 'fill="green"')