dskvr
2 years ago
24 changed files with 5113 additions and 204 deletions
@ -0,0 +1,29 @@ |
|||||
|
<template> |
||||
|
<section id="footer"> |
||||
|
<span>Updated {{ refreshData.sinceLast }} ago</span> |
||||
|
<span><button @click="invalidate(true)">Update Now</button></span> |
||||
|
<span><input type="checkbox" id="checkbox" v-model="preferences.refresh" /><label for="">Refresh Automatically</label></span> |
||||
|
<span v-if="preferences.refresh"> Next refresh in: {{ refreshData.untilNext }}</span> |
||||
|
<span v-if="preferences.refresh"> |
||||
|
Refresh Every |
||||
|
|
||||
|
<input type="radio" id="1w" :value="1000*60*60*24*7" v-model="cacheExpiration" /> |
||||
|
<label for="1w">1 Week</label> |
||||
|
|
||||
|
<input type="radio" id="1d" :value="1000*60*60*24" v-model="cacheExpiration" /> |
||||
|
<label for="1d">1 day</label> |
||||
|
|
||||
|
<input type="radio" id="30m" :value="1000*60*30" v-model="cacheExpiration" /> |
||||
|
<label for="30m">30 minutes</label> |
||||
|
|
||||
|
<input type="radio" id="10m" :value="1000*60*10" v-model="cacheExpiration" /> |
||||
|
<label for="10m">10 minutes</label> |
||||
|
|
||||
|
<input type="radio" id="1m" :value="1000*60" v-model="cacheExpiration" /> |
||||
|
<label for="1m">1 Minute</label> |
||||
|
</span> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
</script> |
@ -0,0 +1,109 @@ |
|||||
|
<template> |
||||
|
|
||||
|
<l-map |
||||
|
ref="map" |
||||
|
v-model:zoom="zoom" |
||||
|
:center="[47.41322, -1.219482]" |
||||
|
:minZoom="zoom" |
||||
|
:maxZoom="zoom" |
||||
|
:zoomControl="false" |
||||
|
> |
||||
|
|
||||
|
<l-tile-layer |
||||
|
url="http://{s}.tile.osm.org/{z}/{x}/{y}.png" |
||||
|
layer-type="base" |
||||
|
name="OpenStreetMap" |
||||
|
/> |
||||
|
|
||||
|
<!-- <l-marker v-for="([relay, result]) in Object.entries(geo)" :lat-lng="getLatLng(result)" :key="relay"> |
||||
|
<l-popup> |
||||
|
{{ relay }} |
||||
|
</l-popup> |
||||
|
</l-marker> --> |
||||
|
|
||||
|
<l-circle-marker |
||||
|
:lat-lng="getLatLng()" |
||||
|
:radius="3" |
||||
|
:weight="6" |
||||
|
:color="getCircleColor(relay)" |
||||
|
:fillOpacity="1" |
||||
|
:class="relay" |
||||
|
> |
||||
|
<l-popup> |
||||
|
{{ relay }} |
||||
|
</l-popup> |
||||
|
</l-circle-marker> |
||||
|
</l-map> |
||||
|
|
||||
|
</template> |
||||
|
<script> |
||||
|
import "leaflet/dist/leaflet.css" |
||||
|
import { LMap, LTileLayer, LCircleMarker, LPopup } from "@vue-leaflet/vue-leaflet"; |
||||
|
export default { |
||||
|
components: { |
||||
|
LMap, |
||||
|
LTileLayer, |
||||
|
LCircleMarker, |
||||
|
LPopup |
||||
|
}, |
||||
|
methods: { |
||||
|
getLatLng(){ |
||||
|
console.log("geo", this.relay, this.geo[this.relay].lat, this.geo[this.relay].lon) |
||||
|
// if (!geo[this.relay]) console.log("no geo?", geo, this.relay, geo[this.relay]) |
||||
|
return [this.geo[this.relay].lat, this.geo[this.relay].lon] |
||||
|
}, |
||||
|
getCircleColor(relay){ |
||||
|
|
||||
|
if(this.result[relay]?.aggregate == 'public') { |
||||
|
return '#00AA00' |
||||
|
} |
||||
|
else if(this.result[relay]?.aggregate == 'restricted') { |
||||
|
return '#FFA500' |
||||
|
} |
||||
|
else if(this.result[relay]?.aggregate == 'offline') { |
||||
|
return '#FF0000' |
||||
|
} |
||||
|
return 'black' |
||||
|
} |
||||
|
}, |
||||
|
async mounted() { |
||||
|
console.log('GEO', this.geo[this.relay]) |
||||
|
}, |
||||
|
props: { |
||||
|
geo: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
result: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
relay: { |
||||
|
type: String, |
||||
|
default(){ |
||||
|
return "" |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
zoom: 2 |
||||
|
}; |
||||
|
}, |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.leaflet-container { |
||||
|
margin:0; |
||||
|
height:250px !important; |
||||
|
width:100%; |
||||
|
} |
||||
|
.leaflet-control-zoom { |
||||
|
display: none !important; |
||||
|
} |
||||
|
</style> |
@ -1,8 +1,53 @@ |
|||||
<template> |
<template> |
||||
<nav class="menu"> |
<nav class="menu"> |
||||
<ul> |
<ul> |
||||
<li><a href="#">add relay</a></li> |
<router-link :to="`/`" active-class="active">Home</router-link> |
||||
<li><a href="#">github</a></li> |
<router-link :to="`/status`" active-class="active">Grouped</router-link> |
||||
|
<a href="https://github.com/dskvr/nostr-watch/edit/main/relays.yaml" target="_blank">Add Relay</a> |
||||
|
<span> |
||||
|
<PreferencesComponent /> |
||||
|
</span> |
||||
</ul> |
</ul> |
||||
</nav> |
</nav> |
||||
</template> |
</template> |
||||
|
<style scoped> |
||||
|
nav.menu { |
||||
|
position:relative; |
||||
|
z-index:10; |
||||
|
} |
||||
|
nav span, |
||||
|
nav.menu a { |
||||
|
display: inline-block; |
||||
|
} |
||||
|
|
||||
|
nav.menu a { |
||||
|
text-decoration: none; |
||||
|
|
||||
|
margin: 0 22px 0 0; |
||||
|
padding:5px 10px; |
||||
|
color:#000; |
||||
|
border-bottom: 1px dotted #999; |
||||
|
} |
||||
|
|
||||
|
nav.menu a.active { |
||||
|
background:#000; |
||||
|
color: #fff; |
||||
|
border: none; |
||||
|
} |
||||
|
|
||||
|
nav.menu a:hover { |
||||
|
background: #f0f0f0; |
||||
|
} |
||||
|
|
||||
|
</style> |
||||
|
<script> |
||||
|
import { defineComponent } from 'vue' |
||||
|
import PreferencesComponent from '../components/PreferencesComponent.vue' |
||||
|
export default defineComponent({ |
||||
|
title: "nostr.watch registry & network status", |
||||
|
name: 'NavComponent', |
||||
|
components: { |
||||
|
PreferencesComponent, |
||||
|
} |
||||
|
}); |
||||
|
</script> |
||||
|
@ -0,0 +1,114 @@ |
|||||
|
<template> |
||||
|
<section id="preferences-toggle"> |
||||
|
<button @click="toggle">⚙️</button> |
||||
|
<section id="preferences" :class="{active:isActive}"> |
||||
|
<span><input type="checkbox" id="checkbox" v-model="preferences.refresh" /><label for="">Refresh Automatically</label></span> |
||||
|
<span v-if="preferences.refresh"> |
||||
|
Refresh Every |
||||
|
<ul> |
||||
|
<li> |
||||
|
<input type="radio" id="1w" :value="1000*60*60*24*7" v-model="preferences.cacheExpiration" /> |
||||
|
<label for="1w">1 Week</label> |
||||
|
</li> |
||||
|
<li> |
||||
|
<input type="radio" id="1d" :value="1000*60*60*24" v-model="preferences.cacheExpiration" /> |
||||
|
<label for="1d">1 day</label> |
||||
|
</li> |
||||
|
<li> |
||||
|
<input type="radio" id="30m" :value="1000*60*30" v-model="preferences.cacheExpiration" /> |
||||
|
<label for="30m">30 minutes</label> |
||||
|
</li> |
||||
|
<li> |
||||
|
<input type="radio" id="10m" :value="1000*60*10" v-model="preferences.cacheExpiration" /> |
||||
|
<label for="10m">10 minutes</label> |
||||
|
</li> |
||||
|
<li> |
||||
|
<input type="radio" id="1m" :value="1000*60" v-model="preferences.cacheExpiration" /> |
||||
|
<label for="1m">1 Minute</label> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</span> |
||||
|
</section> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
section#preferences { |
||||
|
display:none; |
||||
|
} |
||||
|
section#preferences.active { |
||||
|
display:block; |
||||
|
} |
||||
|
|
||||
|
section#preferences-toggle { |
||||
|
position:relative; |
||||
|
text-align:left; |
||||
|
} |
||||
|
|
||||
|
section#preferences { |
||||
|
position:absolute; |
||||
|
top:40px; |
||||
|
left:0px; |
||||
|
padding: 5px 10px; |
||||
|
background:#f5f5f5; |
||||
|
width:200px; |
||||
|
|
||||
|
} |
||||
|
section#preferences > span { |
||||
|
display:block; |
||||
|
margin: 5px 0 0; |
||||
|
} |
||||
|
|
||||
|
ul, li { |
||||
|
list-style:none; |
||||
|
padding:0; |
||||
|
margin:0; |
||||
|
} |
||||
|
|
||||
|
button { |
||||
|
cursor: pointer; |
||||
|
padding:0; |
||||
|
margin:0; |
||||
|
background: transparent; |
||||
|
border: none; |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
import { defineComponent } from 'vue' |
||||
|
import sharedMethods from '../shared' |
||||
|
import { useStorage } from "vue3-storage"; |
||||
|
|
||||
|
const localMethods = { |
||||
|
toggle() { |
||||
|
this.isActive = !this.isActive; |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export default defineComponent({ |
||||
|
name: 'PreferencesComponent', |
||||
|
components: {}, |
||||
|
mounted(){ |
||||
|
this.storage = useStorage() |
||||
|
this.preferences = this.getState('preferences') || this.preferences |
||||
|
}, |
||||
|
updated(){ |
||||
|
this.saveState('preferences') |
||||
|
}, |
||||
|
computed: {}, |
||||
|
methods: Object.assign(localMethods, sharedMethods), |
||||
|
props: {}, |
||||
|
data() { |
||||
|
return { |
||||
|
storage: null, |
||||
|
refresh: true, |
||||
|
preferences: { |
||||
|
refresh: true, |
||||
|
cacheExpiration: 30*60*1000 |
||||
|
}, |
||||
|
isActive: false, |
||||
|
} |
||||
|
}, |
||||
|
}) |
||||
|
</script> |
||||
|
|
@ -0,0 +1,105 @@ |
|||||
|
<template> |
||||
|
<section id="refresh"> |
||||
|
<span>Updated {{ refreshData?.sinceLast }} ago <button @click="invalidate(true)">Update Now</button></span> |
||||
|
<span v-if="preferences.refresh"> Next refresh in: {{ refreshData?.untilNext }}</span> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
|
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
import { defineComponent, reactive } from 'vue' |
||||
|
import sharedMethods from '../shared' |
||||
|
import { useStorage } from "vue3-storage"; |
||||
|
|
||||
|
const localMethods = { |
||||
|
timeUntilRefresh(){ |
||||
|
return this.timeSince(Date.now()-(this.lastUpdate+this.preferences.cacheExpiration-Date.now())) |
||||
|
}, |
||||
|
timeSinceRefresh(){ |
||||
|
return this.timeSince(this.lastUpdate) |
||||
|
}, |
||||
|
|
||||
|
nextRefresh: function(){ |
||||
|
return this.timeSince(Date.now()-(this.lastUpdate+this.preferences.cacheExpiration-Date.now())) |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export default defineComponent({ |
||||
|
name: 'RefreshComponent', |
||||
|
components: {}, |
||||
|
mounted(){ |
||||
|
this.storage = useStorage() |
||||
|
this.lastUpdate = this.getState('lastUpdate')|| this.lastUpdate |
||||
|
this.preferences = this.getState('preferences') || this.preferences |
||||
|
|
||||
|
this.refreshData = reactive({ |
||||
|
untilNext: this.timeUntilRefresh(), |
||||
|
sinceLast: this.timeSinceRefresh() |
||||
|
}) |
||||
|
|
||||
|
setInterval(() => { |
||||
|
this.preferences = this.getState('preferences') || this.preferences |
||||
|
|
||||
|
this.refreshData.untilNext = this.timeUntilRefresh() |
||||
|
this.refreshData.sinceLast = this.timeSinceRefresh() |
||||
|
|
||||
|
if(this.isExpired()) |
||||
|
this.invalidate() |
||||
|
|
||||
|
}, 1000) |
||||
|
}, |
||||
|
updated(){ |
||||
|
this.saveState('preferences') |
||||
|
|
||||
|
if(this.isDone()) { |
||||
|
this.saveState('lastUpdate') |
||||
|
console.log('isDone()', this.getState('lastUpdate') ) |
||||
|
} |
||||
|
|
||||
|
this.refreshData.untilNext = this.timeUntilRefresh() |
||||
|
this.refreshData.sinceLast = this.timeSinceRefresh() |
||||
|
}, |
||||
|
computed: {}, |
||||
|
methods: Object.assign(localMethods, sharedMethods), |
||||
|
props: { |
||||
|
relaysProp:{ |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
messagesProp:{ |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
resultProp: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
relays: this.relaysProp, |
||||
|
result: this.resultProp, |
||||
|
messages: this.messagesProp, |
||||
|
storage: null, |
||||
|
lastUpdate: null, |
||||
|
refresh: true, |
||||
|
refreshData: this.refreshDataProp, |
||||
|
interval: null, |
||||
|
preferences: { |
||||
|
refresh: true, |
||||
|
cacheExpiration: 30*60*1000 |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}) |
||||
|
</script> |
||||
|
|
@ -0,0 +1,274 @@ |
|||||
|
<template> |
||||
|
<tr :class="getHeadingClass()"> |
||||
|
<vue-final-modal v-model="showModal" classes="modal-container" content-class="modal-content"> |
||||
|
<div class="modal__content"> |
||||
|
<span> |
||||
|
{{ queryJson(section) }} |
||||
|
</span> |
||||
|
</div> |
||||
|
</vue-final-modal> |
||||
|
<td colspan="11"> |
||||
|
<h2><span class="indicator badge">{{ query(section).length }}</span>{{ section }} <a @click="showModal=true" class="section-json" v-if="showJson">{...}</a></h2> |
||||
|
</td> |
||||
|
</tr> |
||||
|
<tr :class="getHeadingClass()" v-if="query(section).length > 0"> |
||||
|
<th class="table-column status-indicator"></th> |
||||
|
|
||||
|
<th class="table-column relay"></th> |
||||
|
|
||||
|
<th class="table-column verified"> |
||||
|
<span class="verified-shape-wrapper"> |
||||
|
<span class="shape verified"></span> |
||||
|
</span> |
||||
|
</th> |
||||
|
<th class="table-column location" v-tooltip:top.tooltip="Ping"> |
||||
|
🌎 |
||||
|
</th> |
||||
|
<th class="table-column latency" v-tooltip:top.tooltip="'Relay Latency on Read'"> |
||||
|
⌛️ |
||||
|
</th> |
||||
|
<th class="table-column connect" v-tooltip:top.tooltip="'Relay connection status'"> |
||||
|
🔌 |
||||
|
</th> |
||||
|
<th class="table-column read" v-tooltip:top.tooltip="'Relay read status'"> |
||||
|
👁️🗨️ |
||||
|
</th> |
||||
|
<th class="table-column write" v-tooltip:top.tooltip="'Relay write status'"> |
||||
|
✏️ |
||||
|
</th> |
||||
|
<th class="table-column info" v-tooltip:top.tooltip="'Additional information detected regarding the relay during processing'"> |
||||
|
ℹ️ |
||||
|
</th> |
||||
|
<!-- <th class="table-column nip nip-20" v-tooltip:top.tooltip="'Does the relay support NIP-20'"> |
||||
|
<span>NIP-11</span> |
||||
|
</th> --> |
||||
|
</tr> |
||||
|
<tr v-for="relay in query(section)" :key="{relay}" :class="getResultClass(relay)" class="relay"> |
||||
|
<RelaySingleComponent |
||||
|
:relay="relay" |
||||
|
:result="result[relay]" |
||||
|
:geo="geo[relay]" |
||||
|
:showColumns="showColumns" |
||||
|
:connection="connections[relay]" |
||||
|
/> |
||||
|
</tr> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
|
||||
|
import { defineComponent} from 'vue' |
||||
|
import RelaySingleComponent from './RelaySingleComponent.vue' |
||||
|
import { VueFinalModal } from 'vue-final-modal' |
||||
|
|
||||
|
|
||||
|
export default defineComponent({ |
||||
|
name: 'RelayListComponent', |
||||
|
components: { |
||||
|
RelaySingleComponent, |
||||
|
VueFinalModal |
||||
|
}, |
||||
|
props: { |
||||
|
showJson: { |
||||
|
type: Boolean, |
||||
|
default(){ |
||||
|
return true |
||||
|
} |
||||
|
}, |
||||
|
section: { |
||||
|
type: String, |
||||
|
required: true, |
||||
|
default: "" |
||||
|
}, |
||||
|
relays:{ |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
result: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
geo: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
messages: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
alerts: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
connections: { |
||||
|
type: Object, |
||||
|
default(){ |
||||
|
return {} |
||||
|
} |
||||
|
}, |
||||
|
showColumns: { |
||||
|
type: Object, |
||||
|
default() { |
||||
|
return { |
||||
|
connectionStatuses: false, |
||||
|
nips: false, |
||||
|
geo: false, |
||||
|
additionalInfo: false |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
grouping: { |
||||
|
type: Boolean, |
||||
|
default(){ |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
showModal: false |
||||
|
} |
||||
|
}, |
||||
|
mounted(){ |
||||
|
console.log('') |
||||
|
}, |
||||
|
computed: {}, |
||||
|
methods: { |
||||
|
getHeadingClass(){ |
||||
|
return { |
||||
|
online: this.section != "offline", |
||||
|
public: this.section == "public", |
||||
|
offline: this.section == "offline", |
||||
|
restricted: this.section == "restricted" |
||||
|
} |
||||
|
}, |
||||
|
getResultClass (relay) { |
||||
|
return { |
||||
|
loaded: this.result?.[relay]?.state == 'complete', |
||||
|
online: this.section != "offline", |
||||
|
offline: this.section == "offline", |
||||
|
public: this.section == "public" |
||||
|
} |
||||
|
}, |
||||
|
query (aggregate) { |
||||
|
let unsorted, |
||||
|
sorted, |
||||
|
filterFn |
||||
|
|
||||
|
filterFn = (relay) => this.grouping ? this.result?.[relay]?.aggregate == aggregate : true |
||||
|
|
||||
|
unsorted = this.relays.filter(filterFn); |
||||
|
|
||||
|
console.log('unsorted', unsorted) |
||||
|
|
||||
|
console.log('isDone', this.isDone()) |
||||
|
|
||||
|
if(!this.isDone()) { |
||||
|
return unsorted |
||||
|
} |
||||
|
|
||||
|
if (unsorted.length) { |
||||
|
sorted = unsorted.sort((relay1, relay2) => { |
||||
|
return this.result?.[relay1]?.latency.final - this.result?.[relay2]?.latency.final |
||||
|
}) |
||||
|
console.log('sorted', sorted) |
||||
|
return sorted |
||||
|
} |
||||
|
|
||||
|
return [] |
||||
|
}, |
||||
|
queryJson(aggregate){ |
||||
|
const relays = this.query(aggregate) |
||||
|
const result = {} |
||||
|
result.relays = relays.map( relay => relay ) |
||||
|
return JSON.stringify(result,null,'\t') |
||||
|
}, |
||||
|
relaysTotal () { |
||||
|
return this.relays.length |
||||
|
}, |
||||
|
relaysConnected () { |
||||
|
return Object.keys(this.result).length |
||||
|
}, |
||||
|
relaysCompleted () { |
||||
|
let value = Object.entries(this.result).map((value) => { return value.state == 'complete' }).length |
||||
|
console.log('relaysCompleted', value) |
||||
|
return value |
||||
|
}, |
||||
|
isDone(){ |
||||
|
console.log('isDone()', this.relaysTotal(), '-', this.relaysCompleted(), '=', this.relaysTotal()-this.relaysCompleted() ) |
||||
|
return this.relaysTotal()-this.relaysCompleted() == 0 |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style lang='css' scoped> |
||||
|
.nip span { |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing:-1px; |
||||
|
font-size:12px; |
||||
|
} |
||||
|
|
||||
|
.section-json { |
||||
|
font-size:13px; |
||||
|
color: #555; |
||||
|
cursor:pointer; |
||||
|
} |
||||
|
|
||||
|
::v-deep(.modal-container) { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
} |
||||
|
::v-deep(.modal-content) { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
max-height: 90%; |
||||
|
max-width:400px; |
||||
|
margin: 0 1rem; |
||||
|
padding: 1rem; |
||||
|
border: 1px solid #e2e8f0; |
||||
|
border-radius: 0.25rem; |
||||
|
background: #fff; |
||||
|
} |
||||
|
.modal__title { |
||||
|
margin: 0 2rem 0 0; |
||||
|
font-size: 1.5rem; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
.modal__content { |
||||
|
flex-grow: 1; |
||||
|
overflow-y: auto; |
||||
|
} |
||||
|
.modal__action { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
flex-shrink: 0; |
||||
|
padding: 1rem 0 0; |
||||
|
} |
||||
|
.modal__close { |
||||
|
position: absolute; |
||||
|
top: 0.5rem; |
||||
|
right: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.nip-11 a { cursor: pointer } |
||||
|
</style> |
||||
|
|
||||
|
<style scoped> |
||||
|
.dark-mode div ::v-deep(.modal-content) { |
||||
|
border-color: #2d3748; |
||||
|
background-color: #1a202c; |
||||
|
} |
||||
|
</style> |
@ -1,14 +1,20 @@ |
|||||
import { createApp } from 'vue' |
import { createApp } from 'vue' |
||||
import App from './App.vue' |
import App from './App.vue' |
||||
|
import Vue3Storage from "vue3-storage"; |
||||
|
|
||||
|
import router from './router' |
||||
import "./styles/main.scss" |
import "./styles/main.scss" |
||||
import directives from "./directives/" |
import directives from "./directives/" |
||||
import titleMixin from './mixins/titleMixin' |
import titleMixin from './mixins/titleMixin' |
||||
|
import {Tabs, Tab} from 'vue3-tabs-component'; |
||||
|
|
||||
const app = createApp(App) |
const app = createApp(App) |
||||
app.mixin(titleMixin) |
.use(router) |
||||
|
.use(Vue3Storage, { namespace: "nostrwatch_" }) |
||||
|
.component('tabs', Tabs) |
||||
|
.component('tab', Tab) |
||||
|
.mixin(titleMixin) |
||||
|
|
||||
directives(app); |
directives(app); |
||||
|
|
||||
|
app.mount('#app') |
||||
app.mount('#app') |
|
@ -0,0 +1,324 @@ |
|||||
|
<template> |
||||
|
<!-- <NavComponent /> --> |
||||
|
<LeafletComponent |
||||
|
:geo="geo" |
||||
|
:result="result" |
||||
|
/> |
||||
|
|
||||
|
<div id="wrapper" :class="loadingComplete()"> |
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<h1>nostr.watch<sup>{{version}}</sup></h1> |
||||
|
</column> |
||||
|
</row> |
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<NavComponent /> |
||||
|
</column> |
||||
|
</row> |
||||
|
<div> |
||||
|
<table> |
||||
|
<RelayGroupedListComponent |
||||
|
section="public" |
||||
|
:relays="relays" |
||||
|
:result="result" |
||||
|
:geo="geo" |
||||
|
:messages="messages" |
||||
|
:alerts="alerts" |
||||
|
:connections="connections" |
||||
|
/> |
||||
|
|
||||
|
<RelayGroupedListComponent |
||||
|
section="restricted" |
||||
|
:relays="relays" |
||||
|
:result="result" |
||||
|
:geo="geo" |
||||
|
:messages="messages" |
||||
|
:alerts="alerts" |
||||
|
:connections="connections" |
||||
|
/> |
||||
|
|
||||
|
<RelayGroupedListComponent |
||||
|
section="offline" |
||||
|
:relays="relays" |
||||
|
:result="result" |
||||
|
:geo="geo" |
||||
|
:messages="messages" |
||||
|
:alerts="alerts" |
||||
|
:connections="connections" |
||||
|
/> |
||||
|
|
||||
|
<RelayGroupedListComponent |
||||
|
section="processing" |
||||
|
:relays="relays" |
||||
|
:result="result" |
||||
|
:messages="messages" |
||||
|
:alerts="alerts" |
||||
|
:connections="connections" |
||||
|
:showJson="false" |
||||
|
/> |
||||
|
|
||||
|
</table> |
||||
|
</div> |
||||
|
|
||||
|
|
||||
|
<section id="footer"> |
||||
|
<RefreshComponent |
||||
|
:relaysProp="relays" |
||||
|
v-bind:resultProp="result" |
||||
|
:messagesProp="messages" |
||||
|
/> |
||||
|
|
||||
|
<span class="credit"><a href="http://sandwich.farm">Another 🥪 by sandwich.farm</a>, built with <a href="https://github.com/jb55/nostr-js">nostr-js</a> and <a href="https://github.com/dskvr/nostr-relay-inspector">nostr-relay-inspector</a>, inspired by <a href="https://github.com/fiatjaf/nostr-relay-registry">nostr-relay-registry</a></span> |
||||
|
</section> |
||||
|
|
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
|
||||
|
|
||||
|
import { defineComponent} from 'vue' |
||||
|
import { useStorage } from "vue3-storage"; |
||||
|
// import { CallbackResult } from "vue3-storage/dist/lib/types"; |
||||
|
|
||||
|
import crypto from "crypto" |
||||
|
import { Row, Column } from 'vue-grid-responsive'; |
||||
|
import { Inspector, InspectorObservation, InspectorResult } from 'nostr-relay-inspector' |
||||
|
|
||||
|
// import RelayListComponent from '../components/RelayListComponent.vue' |
||||
|
import RelayGroupedListComponent from '../components/RelayGroupedListComponent.vue' |
||||
|
import LeafletComponent from '../components/LeafletComponent.vue' |
||||
|
import NavComponent from '../components/NavComponent.vue' |
||||
|
import RefreshComponent from '../components/RefreshComponent.vue' |
||||
|
|
||||
|
import { version } from '../../package.json' |
||||
|
import { relays } from '../../relays.yaml' |
||||
|
import { geo } from '../../geo.yaml' |
||||
|
import { messages as RELAY_MESSAGES, codes as RELAY_CODES } from '../../codes.yaml' |
||||
|
|
||||
|
export default defineComponent({ |
||||
|
title: "nostr.watch registry & network status", |
||||
|
name: 'RelayTableComponent', |
||||
|
components: { |
||||
|
Row, |
||||
|
Column, |
||||
|
// RelayListComponent, |
||||
|
RelayGroupedListComponent, |
||||
|
LeafletComponent, |
||||
|
NavComponent, |
||||
|
RefreshComponent |
||||
|
}, |
||||
|
|
||||
|
data() { |
||||
|
return { |
||||
|
relays, |
||||
|
result: {}, |
||||
|
messages: {}, |
||||
|
connections: {}, |
||||
|
nips: {}, |
||||
|
alerts: {}, |
||||
|
timeouts: {}, |
||||
|
intervals: {}, |
||||
|
lastPing: Date.now(), |
||||
|
nextPing: Date.now() + (30*60*1000), |
||||
|
count: 0, |
||||
|
storage: null, |
||||
|
geo, |
||||
|
version: version, |
||||
|
hasStorage: false, |
||||
|
lastUpdate: null, |
||||
|
cacheExpiration: (30*60*1000), |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
updated() { |
||||
|
Object.keys(this.timeouts).forEach(timeout => clearTimeout(this.timeouts[timeout])) |
||||
|
Object.keys(this.intervals).forEach(interval => clearInterval(this.intervals[interval])) |
||||
|
}, |
||||
|
|
||||
|
async mounted() { |
||||
|
this.relays.forEach(relay => { |
||||
|
this.result[relay] = structuredClone(InspectorResult) |
||||
|
}) |
||||
|
|
||||
|
this.storage = useStorage() |
||||
|
this.storage.setStorageSync('relays', relays) |
||||
|
this.lastUpdate = this.storage.getStorageSync('lastUpdate') |
||||
|
|
||||
|
this.relays.forEach(async relay => { |
||||
|
this.result[relay] = this.storage.getStorageSync(relay) |
||||
|
}) |
||||
|
|
||||
|
console.log('meow', this.result) |
||||
|
|
||||
|
if(Object.keys(this.result).length) |
||||
|
this.hasStorage = true |
||||
|
|
||||
|
if(this.isExpired()) |
||||
|
this.relays.forEach(async relay => await this.check(relay) ) |
||||
|
|
||||
|
console.log('zzz', this.result) |
||||
|
|
||||
|
// this.relays.forEach( relay => { |
||||
|
// // this.result[relay].state = 'complete' |
||||
|
// // this.setAggregateResult(relay) |
||||
|
// // this.adjustResult(relay) |
||||
|
// console.log('boom', relay, this.result[relay]) |
||||
|
// }) |
||||
|
// } |
||||
|
// console.log(`zing ${Date.now()} - ${this.lastUpdate} = ${Date.now()-this.lastUpdate} > ${60*1000}`) |
||||
|
return true |
||||
|
}, |
||||
|
|
||||
|
// head: { |
||||
|
// // creates a title tag in header. |
||||
|
// base () { |
||||
|
// return { |
||||
|
// href: "/" |
||||
|
// } |
||||
|
// } |
||||
|
// }, |
||||
|
|
||||
|
computed: {}, |
||||
|
|
||||
|
methods: { |
||||
|
|
||||
|
isExpired(){ |
||||
|
return typeof this.lastUpdate === 'undefined' || Date.now() - this.lastUpdate > this.cacheExpiration |
||||
|
}, |
||||
|
|
||||
|
saveState(relay){ |
||||
|
this.storage |
||||
|
.setStorage({ |
||||
|
key: relay, |
||||
|
data: this.result[relay] |
||||
|
}) |
||||
|
.then(successCallback => { |
||||
|
console.log(successCallback.errMsg); |
||||
|
}) |
||||
|
.catch(failCallback => { |
||||
|
console.log(failCallback.errMsg); |
||||
|
}) |
||||
|
|
||||
|
this.storage |
||||
|
.setStorage({ |
||||
|
key: "lastUpdate", |
||||
|
data: Date.now() |
||||
|
}) |
||||
|
.then(successCallback => { |
||||
|
console.log(successCallback.errMsg); |
||||
|
this.lastUpdate = Date.now() |
||||
|
}) |
||||
|
.catch(failCallback => { |
||||
|
console.log(failCallback.errMsg); |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
resetState(){ |
||||
|
this.relays.forEach(relay=>{ |
||||
|
this.storage.removeStorage(relay) |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
async check(relay){ |
||||
|
return new Promise( (resolve, reject) => { |
||||
|
const opts = { |
||||
|
checkLatency: true, |
||||
|
setIP: false, |
||||
|
setGeo: false, |
||||
|
debug: true, |
||||
|
} |
||||
|
|
||||
|
let inspect = new Inspector(relay, opts) |
||||
|
.on('run', (result) => { |
||||
|
result |
||||
|
// result.aggregate = 'processing' |
||||
|
}) |
||||
|
.on('open', (e, result) => { |
||||
|
this.result[relay] = result |
||||
|
}) |
||||
|
.on('complete', (instance) => { |
||||
|
this.result[relay] = Object.assign(this.result[relay], instance.result) |
||||
|
this.messages[relay] = instance.inbox |
||||
|
// this.setFlag(relay) |
||||
|
this.setAggregateResult(relay) |
||||
|
// this.adjustResult(relay) |
||||
|
this.saveState(relay) |
||||
|
resolve(this.result[relay]) |
||||
|
|
||||
|
}) |
||||
|
.on('notice', (notice) => { |
||||
|
const hash = this.sha1(notice) |
||||
|
let message_obj = RELAY_MESSAGES[hash] |
||||
|
let code_obj = RELAY_CODES[message_obj.code] |
||||
|
|
||||
|
let response_obj = {...message_obj, ...code_obj} |
||||
|
|
||||
|
this.result[relay].observations.push( new InspectorObservation('notice', response_obj.code, response_obj.description, response_obj.relates_to) ) |
||||
|
}) |
||||
|
.on('close', () => {}) |
||||
|
.on('error', () => { |
||||
|
reject(this.result[relay]) |
||||
|
}) |
||||
|
.run() |
||||
|
|
||||
|
this.connections[relay] = inspect |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
recheck(relay){ |
||||
|
const inspect = this.connections[relay] |
||||
|
inspect.checkLatency() |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
// adjustResult (relay) { |
||||
|
// this.result[relay].observations.forEach( observation => { |
||||
|
// if (observation.code == "BLOCKS_WRITE_STATUS_CHECK") { |
||||
|
// this.result[relay].check.write = false |
||||
|
// this.result[relay].aggregate = 'public' |
||||
|
// } |
||||
|
// }) |
||||
|
// }, |
||||
|
|
||||
|
setAggregateResult (relay) { |
||||
|
let aggregateTally = 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.connect ? 1 : 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.read ? 1 : 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.write ? 1 : 0 |
||||
|
if (aggregateTally == 3) { |
||||
|
this.result[relay].aggregate = 'public' |
||||
|
} |
||||
|
else if (aggregateTally == 0) { |
||||
|
this.result[relay].aggregate = 'offline' |
||||
|
} |
||||
|
else { |
||||
|
this.result[relay].aggregate = 'restricted' |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
relaysTotal () { |
||||
|
return this.relays.length //TODO: Figure out WHY? |
||||
|
}, |
||||
|
|
||||
|
relaysConnected () { |
||||
|
return Object.entries(this.result).filter(result => result.state == 'complete').length |
||||
|
}, |
||||
|
|
||||
|
sha1 (message) { |
||||
|
const hash = crypto.createHash('sha1').update(JSON.stringify(message)).digest('hex') |
||||
|
return hash |
||||
|
}, |
||||
|
|
||||
|
isDone(){ |
||||
|
return this.relaysTotal()-this.relaysConnected() <= 0 |
||||
|
}, |
||||
|
|
||||
|
loadingComplete(){ |
||||
|
return this.isDone() ? 'loaded' : '' |
||||
|
}, |
||||
|
}, |
||||
|
|
||||
|
}) |
||||
|
</script> |
@ -0,0 +1,139 @@ |
|||||
|
<template> |
||||
|
<!-- <NavComponent /> --> |
||||
|
<LeafletComponent |
||||
|
:geo="geo" |
||||
|
:result="result" |
||||
|
/> |
||||
|
|
||||
|
<div id="wrapper" :class="loadingComplete()"> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<h1>nostr.watch<sup>{{version}}</sup></h1> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<NavComponent /> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="list"> |
||||
|
<table> |
||||
|
<RelayListComponent |
||||
|
:relays="relays" |
||||
|
:result="result" |
||||
|
:geo="geo" |
||||
|
:messages="messages" |
||||
|
:alerts="alerts" |
||||
|
:connections="connections" |
||||
|
/> |
||||
|
</table> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<row container :gutter="12" v-if="(relaysTotal()-relaysConnected()>0)"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="processing-card loading"> |
||||
|
<span>Processing {{ relaysConnected() }}/{{ relaysTotal() }}</span> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
|
||||
|
<section id="footer"> |
||||
|
<RefreshComponent |
||||
|
:relaysProp="relays" |
||||
|
v-bind:resultProp="result" |
||||
|
:messagesProp="messages" |
||||
|
/> |
||||
|
|
||||
|
<span class="credit"><a href="http://sandwich.farm">Another 🥪 by sandwich.farm</a>, built with <a href="https://github.com/jb55/nostr-js">nostr-js</a> and <a href="https://github.com/dskvr/nostr-relay-inspector">nostr-relay-inspector</a>, inspired by <a href="https://github.com/fiatjaf/nostr-relay-registry">nostr-relay-registry</a></span> |
||||
|
</section> |
||||
|
</div> |
||||
|
</template> |
||||
|
<script> |
||||
|
|
||||
|
|
||||
|
import { defineComponent } from 'vue' |
||||
|
import { useStorage } from "vue3-storage"; |
||||
|
// import { CallbackResult } from "vue3-storage/dist/lib/types"; |
||||
|
|
||||
|
import { Row, Column } from 'vue-grid-responsive'; |
||||
|
// import { Inspector, InspectorObservation } from '../../lib/nostr-relay-inspector' |
||||
|
|
||||
|
import sharedMethods from '../shared' |
||||
|
|
||||
|
import RelayListComponent from '../components/RelayListComponent.vue' |
||||
|
// import RelayGroupedListComponent from '../components/RelayGroupedListComponent.vue' |
||||
|
import LeafletComponent from '../components/LeafletComponent.vue' |
||||
|
import NavComponent from '../components/NavComponent.vue' |
||||
|
import RefreshComponent from '../components/RefreshComponent.vue' |
||||
|
|
||||
|
import { version } from '../../package.json' |
||||
|
import { relays } from '../../relays.yaml' |
||||
|
import { geo } from '../../geo.yaml' |
||||
|
|
||||
|
export default defineComponent({ |
||||
|
title: "nostr.watch registry & network status", |
||||
|
name: 'RelayTableComponent', |
||||
|
components: { |
||||
|
Row, |
||||
|
Column, |
||||
|
RelayListComponent, |
||||
|
// RelayGroupedListComponent, |
||||
|
LeafletComponent, |
||||
|
NavComponent, |
||||
|
RefreshComponent, |
||||
|
}, |
||||
|
|
||||
|
data() { |
||||
|
return { |
||||
|
relays: relays, |
||||
|
result: {}, |
||||
|
messages: {}, |
||||
|
connections: {}, |
||||
|
alerts: {}, |
||||
|
timeouts: {}, |
||||
|
intervals: {}, |
||||
|
count: 0, |
||||
|
storage: null, |
||||
|
geo, |
||||
|
version: version, |
||||
|
hasStorage: false, |
||||
|
// cacheExpiration: 10*60*1000, //10 minutes |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
updated(){}, |
||||
|
|
||||
|
async mounted() { |
||||
|
this.storage = useStorage() |
||||
|
this.lastUpdate = this.getState('lastUpdate')|| this.lastUpdate |
||||
|
this.preferences = this.getState('preferences') || this.preferences |
||||
|
|
||||
|
this.relays.forEach(async relay => { |
||||
|
this.result[relay] = this.getState(relay) |
||||
|
this.messages[relay] = this.getState(`${relay}_inbox`) |
||||
|
}) |
||||
|
|
||||
|
this.invalidate() |
||||
|
|
||||
|
console.log('last update',-1*(Date.now()-(this.lastUpdate+this.preferences.cacheExpiration))) |
||||
|
}, |
||||
|
|
||||
|
computed: { |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
methods: sharedMethods |
||||
|
|
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.list { |
||||
|
position:relative; |
||||
|
z-index:1; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,429 @@ |
|||||
|
<template> |
||||
|
<LeafletSingleComponent |
||||
|
:geo="geo" |
||||
|
:relay="relay" |
||||
|
:result="result" |
||||
|
/> |
||||
|
<!-- <NavComponent /> --> |
||||
|
<div id="wrapper"> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<h1>{{ relayUrl() }}</h1> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<NavComponent /> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="12" :lg="12" class="title-card"> |
||||
|
<div style="display: none">{{result}}</div> <!-- ? --> |
||||
|
<br > |
||||
|
|
||||
|
<span class="badges"> |
||||
|
<span><img :src="badgeCheck('connect')" /></span> |
||||
|
<span><img :src="badgeCheck('read')" /></span> |
||||
|
<span><img :src="badgeCheck('write')" /></span> |
||||
|
</span> |
||||
|
|
||||
|
<br /> |
||||
|
|
||||
|
<span v-if="result.info?.supported_nips" class="badges"> |
||||
|
<span v-for="(nip) in result.info.supported_nips" :key="`${relay}_${nip}`"> |
||||
|
<a :href="nipLink(nip)" target="_blank"><img :src="badgeLink(nip)" /></a> |
||||
|
</span> |
||||
|
</span> |
||||
|
|
||||
|
<!--table> |
||||
|
<tr> |
||||
|
<th colspan="3"><h4>Status</h4></th> |
||||
|
</tr> |
||||
|
<tr v-if="result.checkClass"> |
||||
|
<td :class="result.checkClass.connect" class="connect indicator">Connected</td> |
||||
|
<td :class="result.checkClass.read" class="read indicator">Read</td> |
||||
|
<td :class="result.checkClass.write" class="write indicator">Write</td> |
||||
|
</tr> |
||||
|
</table--> |
||||
|
|
||||
|
|
||||
|
<table v-if="result.info"> |
||||
|
<tr> |
||||
|
<th colspan="2"><h4>Info</h4></th> |
||||
|
</tr> |
||||
|
<tr v-for="(value, key) in Object.entries(result.info).filter(value => value[0] != 'id' && value[0] != 'supported_nips')" :key="`${value}_${key}`"> |
||||
|
<td>{{ value[0] }}</td> |
||||
|
<td v-if="value[0]!='contact' && value[0]!='pubkey' && value[0]!='software' && value[0]!='version'">{{ value[1] }} </td> |
||||
|
<td v-if="value[0]=='contact'"><SafeMail :email="value[1]" /></td> |
||||
|
<td v-if="value[0]=='pubkey' || value[0]=='version'"><code>{{ value[1] }}</code></td> |
||||
|
<td v-if="value[0]=='software'"><a href="{{ value[1] }}">{{ value[1] }}</a></td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
|
||||
|
<h4>Identities</h4> |
||||
|
<table v-if="result.identities"> |
||||
|
|
||||
|
<tr v-for="(value, key) in Object.entries(result?.identities)" :key="`${value}_${key}`"> |
||||
|
|
||||
|
<td>{{ value[0] }}</td> |
||||
|
<td><code>{{ value[1] }}</code></td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
|
||||
|
<div style="display: none">{{result}}</div> <!-- ? --> |
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<row container :gutter="12"> |
||||
|
<column :xs="12" :md="6" :lg="6" class="title-card"> |
||||
|
|
||||
|
<h4>GEO {{geo?.countryCode ? getFlag() : ''}}</h4> |
||||
|
<table v-if="geo[relay]"> |
||||
|
<tr v-for="(value, key) in Object.entries(geo[relay])" :key="`${value}_${key}`"> |
||||
|
<td>{{ value[0] }}</td> |
||||
|
<td>{{ value[1] }} </td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
|
||||
|
</column> |
||||
|
<column :xs="12" :md="6" :lg="6" class="title-card"> |
||||
|
<h4>DNS</h4> |
||||
|
<table v-if="geo[relay]"> |
||||
|
<tr v-for="(value, key) in Object.entries(geo[relay].dns)" :key="`${value}_${key}`"> |
||||
|
<td>{{ value[0] }}</td> |
||||
|
<td>{{ value[1] }} </td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
|
||||
|
<div style="display: none">{{result}}</div> <!-- ? --> |
||||
|
|
||||
|
</column> |
||||
|
</row> |
||||
|
|
||||
|
<span class="credit"><a href="http://sandwich.farm">Another 🥪 by sandwich.farm</a>, built with <a href="https://github.com/jb55/nostr-js">nostr-js</a> and <a href="https://github.com/dskvr/nostr-relay-inspector">nostr-relay-inspector</a>, inspired by <a href="https://github.com/fiatjaf/nostr-relay-registry">nostr-relay-registry</a></span> |
||||
|
|
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
|
||||
|
import { defineComponent} from 'vue' |
||||
|
import { useStorage } from "vue3-storage"; |
||||
|
|
||||
|
import LeafletSingleComponent from '../components/LeafletSingleComponent.vue' |
||||
|
import NavComponent from '../components/NavComponent.vue' |
||||
|
|
||||
|
import { Row, Column } from 'vue-grid-responsive'; |
||||
|
import SafeMail from "@2alheure/vue-safe-mail"; |
||||
|
import emoji from 'node-emoji'; |
||||
|
import { countryCodeEmoji } from 'country-code-emoji'; |
||||
|
|
||||
|
import { Inspector, InspectorObservation } from 'nostr-relay-inspector' |
||||
|
// import { Inspector, InspectorObservation } from '../../lib/nostr-relay-inspector' |
||||
|
// import { Inspector, InspectorObservation } from '../../lib/nostr-relay-inspector' |
||||
|
|
||||
|
import { version } from '../../package.json' |
||||
|
import { relays } from '../../relays.yaml' |
||||
|
import { geo } from '../../geo.yaml' |
||||
|
import { messages as RELAY_MESSAGES, codes as RELAY_CODES } from '../../codes.yaml' |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
import crypto from "crypto" |
||||
|
|
||||
|
export default defineComponent({ |
||||
|
title: "nostr.watch registry & network status", |
||||
|
name: 'SingleRelay', |
||||
|
components: { |
||||
|
Row, |
||||
|
Column, |
||||
|
LeafletSingleComponent, |
||||
|
NavComponent, |
||||
|
SafeMail, |
||||
|
}, |
||||
|
|
||||
|
data() { |
||||
|
return { |
||||
|
relays, |
||||
|
result: {}, |
||||
|
messages: {}, |
||||
|
nips: {}, |
||||
|
alerts: {}, |
||||
|
timeouts: {}, |
||||
|
intervals: {}, |
||||
|
lastPing: Date.now(), |
||||
|
nextPing: Date.now() + (60*1000), |
||||
|
count: 0, |
||||
|
geo, |
||||
|
relay: "", |
||||
|
version: version, |
||||
|
storage: null, |
||||
|
lastUpdate: null, |
||||
|
cacheExpiration: 10*60*1000 //10 minutes |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
async mounted() { |
||||
|
this.relay = this.relayUrl() |
||||
|
|
||||
|
this.storage = useStorage() |
||||
|
this.lastUpdate = this.storage.getStorageSync('lastUpdate') |
||||
|
this.result = this.storage.getStorageSync(this.relay) |
||||
|
|
||||
|
if(this.isExpired()) |
||||
|
this.check(this.relay) |
||||
|
|
||||
|
// console.log('zing ', (Date.now() - this.lastUpdate) /1000) |
||||
|
}, |
||||
|
|
||||
|
computed: { |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
updated() { |
||||
|
Object.keys(this.timeouts).forEach(timeout => clearTimeout(this.timeouts[timeout])) |
||||
|
Object.keys(this.intervals).forEach(interval => clearInterval(this.intervals[interval])) |
||||
|
}, |
||||
|
|
||||
|
methods: { |
||||
|
isExpired(){ |
||||
|
return typeof this.lastUpdate === 'undefined' || Date.now() - this.lastUpdate > this.preferences.cacheExpiration |
||||
|
}, |
||||
|
|
||||
|
saveState(relay){ |
||||
|
this.storage |
||||
|
.setStorage({ |
||||
|
key: relay, |
||||
|
data: this.result |
||||
|
}) |
||||
|
.then(successCallback => { |
||||
|
console.log(successCallback.errMsg); |
||||
|
}) |
||||
|
.catch(failCallback => { |
||||
|
console.log(failCallback.errMsg); |
||||
|
}) |
||||
|
|
||||
|
this.storage |
||||
|
.setStorage({ |
||||
|
key: "lastUpdate", |
||||
|
data: Date.now() |
||||
|
}) |
||||
|
.then(successCallback => { |
||||
|
console.log(successCallback.errMsg); |
||||
|
this.lastUpdate = Date.now() |
||||
|
}) |
||||
|
.catch(failCallback => { |
||||
|
console.log(failCallback.errMsg); |
||||
|
}) |
||||
|
}, |
||||
|
relayUrl() { |
||||
|
// We will see what `params` is shortly |
||||
|
return `wss://${this.$route.params.relayUrl}` |
||||
|
}, |
||||
|
async check(relay){ |
||||
|
//const self = this |
||||
|
/* return new Promise(function(resolve, reject) { */ |
||||
|
/* let nip = new Array(99).fill(false); |
||||
|
nip[5] = true |
||||
|
nip[11] = true */ |
||||
|
|
||||
|
const opts = { |
||||
|
checkLatency: true, |
||||
|
checkNips: true, |
||||
|
/* checkNip: nip, */ |
||||
|
/* debug: true */ |
||||
|
} |
||||
|
|
||||
|
let inspect = new Inspector(relay, opts) |
||||
|
.on('run', (result) => { |
||||
|
result.aggregate = 'processing' |
||||
|
}) |
||||
|
.on('open', (e, result) => { |
||||
|
this.result = result |
||||
|
this.result.checkClass = {read: null, write: null, connect: null} |
||||
|
this.setResultClass('connect') |
||||
|
/* console.log('result on open', this.result) */ |
||||
|
}) |
||||
|
.on('complete', (instance) => { |
||||
|
/* console.log('on_complete', instance.result.aggregate) */ |
||||
|
this.result = instance.result |
||||
|
this.messages[this.relay] = instance.inbox |
||||
|
|
||||
|
/* this.setFlag(relay) */ |
||||
|
this.setAggregateResult() |
||||
|
/* this.adjustResult(relay) */ |
||||
|
this.setResultClass('read') |
||||
|
this.setResultClass('write') |
||||
|
this.saveState(relay) |
||||
|
/* console.log(this.result) |
||||
|
console.log(this.result.info.supported_nips) */ |
||||
|
/* resolve(this.result) */ |
||||
|
}) |
||||
|
.on('notice', (notice) => { |
||||
|
const hash = this.sha1(notice) |
||||
|
let message_obj = RELAY_MESSAGES[hash] |
||||
|
let code_obj = RELAY_CODES[message_obj.code] |
||||
|
|
||||
|
let response_obj = {...message_obj, ...code_obj} |
||||
|
|
||||
|
this.result.observations.push( new InspectorObservation('notice', response_obj.code, response_obj.description, response_obj.relates_to) ) |
||||
|
|
||||
|
/* console.log(this.result.observations) */ |
||||
|
}) |
||||
|
.on('close', (msg) => { |
||||
|
console.warn("CAUTION", msg) |
||||
|
/* console.log('supported_nips', inspect.result.info) */ |
||||
|
}) |
||||
|
.on('error', (err) => { |
||||
|
console.error("ERROR", err) |
||||
|
/* reject(err) */ |
||||
|
}) |
||||
|
.run() |
||||
|
|
||||
|
return inspect; |
||||
|
/* }) */ |
||||
|
|
||||
|
}, |
||||
|
setResultClass (key) { |
||||
|
let result = this.result?.check?.[key] === true |
||||
|
? 'success' |
||||
|
: this.result?.check?.[key] === false |
||||
|
? 'failure' |
||||
|
: 'pending' |
||||
|
|
||||
|
/* console.log('result class', result) */ |
||||
|
this.result.checkClass[key] = result |
||||
|
}, |
||||
|
getLoadingClass () { |
||||
|
return this.result?.state == 'complete' ? "relay loaded" : "relay" |
||||
|
}, |
||||
|
generateKey (url, key) { |
||||
|
return `${url}_${key}` |
||||
|
}, |
||||
|
|
||||
|
getFlag () { |
||||
|
return this.geo?.countryCode ? countryCodeEmoji(this.geo.countryCode) : emoji.get('shrug'); |
||||
|
}, |
||||
|
|
||||
|
setCheck (bool) { |
||||
|
return bool ? '✅ ' : '' |
||||
|
}, |
||||
|
|
||||
|
badgeLink(nip){ |
||||
|
return `https://img.shields.io/static/v1?style=for-the-badge&label=NIP&message=${this.nipSignature(nip)}&color=black` |
||||
|
}, |
||||
|
|
||||
|
badgeCheck(which){ |
||||
|
return `https://img.shields.io/static/v1?style=for-the-badge&label=&message=${which}&color=${this.result?.check?.[which] ? 'green' : 'red'}` |
||||
|
}, |
||||
|
|
||||
|
setCross (bool) { |
||||
|
return !bool ? '❌' : '' |
||||
|
}, |
||||
|
setCaution (bool) { |
||||
|
return !bool ? '⚠️' : '' |
||||
|
}, |
||||
|
identityList () { |
||||
|
let string = '', |
||||
|
extraString = '', |
||||
|
users = Object.entries(this.result.identities), |
||||
|
count = 0 |
||||
|
|
||||
|
// console.log(this.result.uri, 'admin', this.result.identities.serverAdmin) |
||||
|
|
||||
|
if(this.result.identities) { |
||||
|
if(this.result.identities.serverAdmin) { |
||||
|
string = `Relay has registered an administrator pubkey: ${this.result.identities.serverAdmin}. ` |
||||
|
extraString = "Additionally, " |
||||
|
} |
||||
|
|
||||
|
const total = users.filter(([key]) => key!='serverAdmin').length, |
||||
|
isOne = total==1 |
||||
|
|
||||
|
if(total) { |
||||
|
string = `${string}${extraString}Relay domain contains NIP-05 verification data for:` |
||||
|
users.forEach( ([key]) => { |
||||
|
if(key == "serverAdmin") return |
||||
|
count++ |
||||
|
string = `${string} ${(count==total && !isOne) ? 'and' : ''} @${key}${(count!=total && !isOne) ? ', ' : ''}` |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
return string |
||||
|
}, |
||||
|
nipSignature(key){ |
||||
|
return key.toString().length == 1 ? `0${key}` : key |
||||
|
}, |
||||
|
nipFormatted(key){ |
||||
|
return `NIP-${this.nipSignature(key)}` |
||||
|
}, |
||||
|
nipLink(key){ |
||||
|
return `https://github.com/nostr-protocol/nips/blob/master/${this.nipSignature(key)}.md` |
||||
|
}, |
||||
|
async copy(text) { |
||||
|
// console.log('copy', text) |
||||
|
try { |
||||
|
await navigator.clipboard.writeText(text); |
||||
|
} catch($e) { |
||||
|
//console.log('Cannot copy'); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
// adjustResult (relay) { |
||||
|
// this.result.observations.forEach( observation => { |
||||
|
// if (observation.code == "BLOCKS_WRITE_STATUS_CHECK") { |
||||
|
// this.result.check.write = false |
||||
|
// this.result.aggregate = 'public' |
||||
|
// } |
||||
|
// }) |
||||
|
// }, |
||||
|
|
||||
|
setAggregateResult () { |
||||
|
if(!this.result) return |
||||
|
|
||||
|
let aggregateTally = 0 |
||||
|
aggregateTally += this.result?.check.connect ? 1 : 0 |
||||
|
aggregateTally += this.result?.check.read ? 1 : 0 |
||||
|
aggregateTally += this.result?.check.write ? 1 : 0 |
||||
|
if (aggregateTally == 3) { |
||||
|
this.result.aggregate = 'public' |
||||
|
} |
||||
|
else if (aggregateTally == 0) { |
||||
|
this.result.aggregate = 'offline' |
||||
|
} |
||||
|
else { |
||||
|
this.result.aggregate = 'restricted' |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
|
||||
|
|
||||
|
sha1 (message) { |
||||
|
const hash = crypto.createHash('sha1').update(JSON.stringify(message)).digest('hex') |
||||
|
// //console.log(message, ':', hash) |
||||
|
return hash |
||||
|
}, |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
}) |
||||
|
</script> |
||||
|
<style scoped> |
||||
|
ul, ul li { padding:0; margin:0; list-style:none; } |
||||
|
td { padding:5px 10px; } |
||||
|
th h4 { text-align:center; padding:5px 10px; margin:0 0 6px; background:#f0f0f0; } |
||||
|
table {width:90%; max-width:90%; margin:0 auto 20px ; border: 2px solid #f5f5f5; padding:20px} |
||||
|
tr td:first-child { text-align:right } |
||||
|
tr td:last-child { text-align:left } |
||||
|
.indicator { display: table-cell; width:33% ; font-weight:bold; text-align: center !important; color: white; text-transform: uppercase; font-size:0.8em} |
||||
|
body, .grid-column { padding:0; margin:0; } |
||||
|
.badges { display:block; margin: 10px 0 0} |
||||
|
.badges > span {margin-right:5px} |
||||
|
#wrapper {max-width:800px} |
||||
|
h1 {margin: 25px 0 15px; padding:0 0 10px; border-bottom:3px solid #e9e9e9} |
||||
|
</style> |
@ -0,0 +1,34 @@ |
|||||
|
// /router/index.js
|
||||
|
import { createRouter, createWebHistory } from 'vue-router' |
||||
|
import HomePage from '../pages/HomePage.vue' |
||||
|
import ByStatus from '../pages/ByStatus.vue' |
||||
|
import SingleRelay from '../pages/SingleRelay.vue' |
||||
|
|
||||
|
const routes = [ |
||||
|
{ |
||||
|
path: '/relay/:relayUrl(.*)', |
||||
|
// name: 'nostr.watch - :relayUrl',
|
||||
|
component: SingleRelay |
||||
|
}, |
||||
|
{ |
||||
|
path: '/status', |
||||
|
// name: 'nostr.watch',
|
||||
|
component: ByStatus |
||||
|
}, |
||||
|
// Added our new route file named profile.vue
|
||||
|
{ |
||||
|
path: '/', |
||||
|
// name: 'nostr.watch',
|
||||
|
component: HomePage |
||||
|
}, |
||||
|
|
||||
|
] |
||||
|
|
||||
|
// Create Vue Router Object
|
||||
|
const router = createRouter({ |
||||
|
history: createWebHistory(), |
||||
|
// base: process.env.BASE_URL,
|
||||
|
routes: routes |
||||
|
}) |
||||
|
|
||||
|
export default router |
@ -0,0 +1,235 @@ |
|||||
|
import { Inspector, InspectorObservation } from 'nostr-relay-inspector' |
||||
|
import { messages as RELAY_MESSAGES, codes as RELAY_CODES } from '../codes.yaml' |
||||
|
|
||||
|
import crypto from "crypto" |
||||
|
|
||||
|
export default { |
||||
|
invalidate: function(force){ |
||||
|
if(!this.isExpired() && !force) |
||||
|
return |
||||
|
|
||||
|
this.relays.forEach(async relay => { |
||||
|
await this.check(relay) |
||||
|
this.relays[relay] = this.getState(relay) |
||||
|
this.messages[relay] = this.getState(`${relay}_inbox`) |
||||
|
}) |
||||
|
|
||||
|
// if(this.preferences.refresh)
|
||||
|
// this.timeouts.invalidate = setTimeout(()=> this.invalidate(), 1000)
|
||||
|
|
||||
|
}, |
||||
|
|
||||
|
isExpired: function(){ |
||||
|
return typeof this.lastUpdate === 'undefined' || Date.now() - this.lastUpdate > this.preferences.cacheExpiration |
||||
|
}, |
||||
|
|
||||
|
getState: function(key){ |
||||
|
return this.storage.getStorageSync(key) |
||||
|
}, |
||||
|
|
||||
|
check: async function(relay){ |
||||
|
return new Promise( (resolve, reject) => { |
||||
|
// if(!this.isExpired())
|
||||
|
// return reject(relay)
|
||||
|
|
||||
|
const opts = { |
||||
|
checkLatency: true, |
||||
|
setIP: false, |
||||
|
setGeo: false, |
||||
|
getInfo: true, |
||||
|
debug: true, |
||||
|
// data: { result: this.result[relay] }
|
||||
|
} |
||||
|
|
||||
|
let inspect = new Inspector(relay, opts) |
||||
|
// .on('run', (result) => {
|
||||
|
// result.aggregate = 'processing'
|
||||
|
// })
|
||||
|
// .on('open', (e, result) => {
|
||||
|
// this.result[relay] = result
|
||||
|
// })
|
||||
|
.on('complete', (instance) => { |
||||
|
// console.log('getinfo()', instance.result.info)
|
||||
|
|
||||
|
this.result[relay] = instance.result |
||||
|
|
||||
|
// this.setFlag(relay)
|
||||
|
// this.adjustResult(relay)
|
||||
|
|
||||
|
this.result[relay].aggregate = this.getAggregate(relay) |
||||
|
|
||||
|
this.saveState('relay', relay) |
||||
|
this.saveState('messages', relay, instance.inbox) |
||||
|
this.saveState('lastUpdate') |
||||
|
|
||||
|
resolve(this.result[relay]) |
||||
|
}) |
||||
|
.on('notice', (notice) => { |
||||
|
const hash = this.sha1(notice) |
||||
|
let message_obj = RELAY_MESSAGES[hash] |
||||
|
let code_obj = RELAY_CODES[message_obj.code] |
||||
|
|
||||
|
let response_obj = {...message_obj, ...code_obj} |
||||
|
|
||||
|
this.result[relay].observations.push( new InspectorObservation('notice', response_obj.code, response_obj.description, response_obj.relates_to) ) |
||||
|
}) |
||||
|
.on('close', () => {}) |
||||
|
.on('error', () => { |
||||
|
reject(this.result[relay]) |
||||
|
}) |
||||
|
.run() |
||||
|
inspect |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
saveState: function(type, key, data){ |
||||
|
|
||||
|
const now = Date.now() |
||||
|
|
||||
|
let store, success, error, instance |
||||
|
|
||||
|
switch(type){ |
||||
|
case 'relay': |
||||
|
console.log('savestate', 'relay', data || this.result[data]) |
||||
|
if(data) |
||||
|
data.aggregate = this.getAggregate(key) |
||||
|
store = { |
||||
|
key: key, |
||||
|
data: data || this.result[key], |
||||
|
// expire: Date.now()+1000*60*60*24*180,
|
||||
|
} |
||||
|
success = () => { |
||||
|
if(data) |
||||
|
this.result[key] = data |
||||
|
} |
||||
|
break; |
||||
|
case 'messages': |
||||
|
console.log('savestate', 'messages', this.messages[data]) |
||||
|
store = { |
||||
|
key: `${key}_inbox`, |
||||
|
data: data || this.messages[key], |
||||
|
// expire: Date.now()+1000*60*60*24*180,
|
||||
|
} |
||||
|
success = () => { |
||||
|
if(data) |
||||
|
this.messages[key] = data |
||||
|
} |
||||
|
break; |
||||
|
case 'lastUpdate': |
||||
|
console.log('savestate', 'lastUpdate', now) |
||||
|
store = { |
||||
|
key: "lastUpdate", |
||||
|
data: now |
||||
|
} |
||||
|
success = () => { |
||||
|
// console.log('lastupdate success', successCallback.msg)
|
||||
|
this.lastUpdate = now |
||||
|
} |
||||
|
break; |
||||
|
case 'preferences': |
||||
|
console.log('savestate', 'preferences', this.preferences) |
||||
|
store = { |
||||
|
key: "preferences", |
||||
|
data: this.preferences |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if(store) |
||||
|
instance = this.storage.setStorage(store) |
||||
|
|
||||
|
if(success && store) |
||||
|
instance.then(success) |
||||
|
|
||||
|
if(error && store) |
||||
|
instance.catch(error) |
||||
|
}, |
||||
|
|
||||
|
resetState: function(){ |
||||
|
this.relays.forEach(relay=>{ |
||||
|
this.storage.removeStorage(relay) |
||||
|
}) |
||||
|
}, |
||||
|
recheck: function(relay){ |
||||
|
const inspect = this.connections[relay] |
||||
|
inspect.checkLatency() |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
adjustResult: function(relay) { |
||||
|
this.result[relay].observations.forEach( observation => { |
||||
|
if (observation.code == "BLOCKS_WRITE_STATUS_CHECK") { |
||||
|
this.result[relay].check.write = false |
||||
|
this.result[relay].aggregate = 'public' |
||||
|
} |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
getAggregate: function(relay) { |
||||
|
console.log('getAggregate()', this.result?.[relay]?.check.connect, this.result?.[relay]?.check.read, this.result?.[relay]?.check.write) |
||||
|
let aggregateTally = 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.connect ? 1 : 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.read ? 1 : 0 |
||||
|
aggregateTally += this.result?.[relay]?.check.write ? 1 : 0 |
||||
|
if (aggregateTally == 3) { |
||||
|
return 'public' |
||||
|
} |
||||
|
else if (aggregateTally == 0) { |
||||
|
return 'offline' |
||||
|
} |
||||
|
else { |
||||
|
return 'restricted' |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
relaysTotal: function() { |
||||
|
return this.relays.length //TODO: Figure out WHY?
|
||||
|
}, |
||||
|
|
||||
|
relaysConnected: function() { |
||||
|
return Object.entries(this.result).length |
||||
|
}, |
||||
|
|
||||
|
relaysComplete: function() { |
||||
|
return this.relays.filter(relay => this.results?.[relay]?.state == 'complete').length |
||||
|
}, |
||||
|
|
||||
|
sha1: function(message) { |
||||
|
const hash = crypto.createHash('sha1').update(JSON.stringify(message)).digest('hex') |
||||
|
return hash |
||||
|
}, |
||||
|
|
||||
|
isDone: function(){ |
||||
|
console.log('is done', this.relaysTotal(), '-', this.relaysConnected(), '<=', 0, this.relaysTotal()-this.relaysConnected() <= 0) |
||||
|
return this.relaysTotal()-this.relaysComplete() <= 0 |
||||
|
}, |
||||
|
|
||||
|
loadingComplete: function(){ |
||||
|
return this.isDone() ? 'loaded' : '' |
||||
|
}, |
||||
|
|
||||
|
timeSince: function(date) { |
||||
|
var seconds = Math.floor((new Date() - date) / 1000); |
||||
|
var interval = seconds / 31536000; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " years"; |
||||
|
} |
||||
|
interval = seconds / 2592000; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " months"; |
||||
|
} |
||||
|
interval = seconds / 86400; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " days"; |
||||
|
} |
||||
|
interval = seconds / 3600; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " hours"; |
||||
|
} |
||||
|
interval = seconds / 60; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " minutes"; |
||||
|
} |
||||
|
return Math.floor(seconds) + " seconds"; |
||||
|
}, |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
export const timeSince = function(date) { |
||||
|
var seconds = Math.floor((new Date() - date) / 1000); |
||||
|
var interval = seconds / 31536000; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " years"; |
||||
|
} |
||||
|
interval = seconds / 2592000; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " months"; |
||||
|
} |
||||
|
interval = seconds / 86400; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " days"; |
||||
|
} |
||||
|
interval = seconds / 3600; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " hours"; |
||||
|
} |
||||
|
interval = seconds / 60; |
||||
|
if (interval > 1) { |
||||
|
return Math.floor(interval) + " minutes"; |
||||
|
} |
||||
|
return Math.floor(seconds) + " seconds"; |
||||
|
} |
File diff suppressed because it is too large
Loading…
Reference in new issue