Compare commits

...

5 Commits

Author SHA1 Message Date
Ian Renton
3964134db9 Add dx_call_includes filter input on web UI 2025-10-31 17:52:29 +00:00
Ian Renton
04435e770a Add dx_call_includes filter 2025-10-31 17:33:27 +00:00
Ian Renton
a4645171e4 Thanks 2025-10-31 14:24:04 +00:00
Ian Renton
65d546ef7e Support BOTA alerts. Closes #58 2025-10-31 14:06:22 +00:00
Ian Renton
193838b9d3 Fix colours of table rows and JS exception on sig_refs being null. 2025-10-31 10:50:49 +00:00
18 changed files with 178 additions and 48 deletions

View File

@@ -10,7 +10,7 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, the UK Packet Repeater Network, and NG3K.
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, and NG3K.
![Screenshot](/images/screenshot2.png)
@@ -198,6 +198,8 @@ As well as being my work, I have also gratefully received feature patches from S
The project contains a self-hosted copy of Font Awesome's free library, in the `/webasset/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery and moment.js. This project would not have been possible without these libraries, so many thanks to their developers.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery, Leaflet and Bootstrap. This project would not have been possible without these libraries, so many thanks to their developers.
Particular thanks go to QRZCQ country-files.com for providing country lookup data for amateur radio, and to the developers of `pyhamtools` for making it easy to use this data as well as QRZ.com and Clublog lookup.
The project's name was suggested by Harm, DK4HAA. Thanks!

48
alertproviders/bota.py Normal file
View File

@@ -0,0 +1,48 @@
from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Beaches on the Air
class BOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600
ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_alerts(self, http_response):
new_alerts = []
# Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
tbody = bs.body.find('div', attrs={'class': 'view-activations-public'}).find('table', attrs={'class': 'views-table'}).find('tbody')
for row in tbody.find_all('tr'):
cells = row.find_all('td')
first_cell_text = str(cells[0].find('a').contents[0]).strip()
ref_name = first_cell_text.split(" by ")[0]
dx_call = str(cells[1].find('a').contents[0]).strip().upper()
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
date_text = str(cells[2].find('span').contents[0]).strip()
date_time = datetime.strptime(date_text,"%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
# If this was more than a day ago, activation is actually next year
if date_time < datetime.now(pytz.UTC) - timedelta(days=1):
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
# Convert to our alert format
alert = Alert(source=self.name,
dx_calls=[dx_call],
sig="BOTA",
sig_refs=[SIGRef(id=ref_name, name=ref_name, url="https://www.beachesontheair.com/beaches/" + ref_name.lower().replace(" ", "-"))],
icon=get_icon_for_sig("BOTA"),
start_time=date_time.timestamp(),
is_dxpedition=False)
new_alerts.append(alert)
return new_alerts

View File

@@ -104,6 +104,10 @@ alert-providers:
class: "WOTA"
name: "WOTA"
enabled: true
-
class: "BOTA"
name: "BOTA"
enabled: true
-
class: "NG3K"
name: "NG3K"

View File

@@ -27,7 +27,8 @@ SIGS = [
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""),
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}")
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air", icon="water")
]
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
@@ -216,7 +217,7 @@ DXCC_FLAGS = {
146: "\U0001F1F1\U0001F1F9", # LITHUANIA
147: "", # LORD HOWE ISLAND
148: "\U0001F1FB\U0001F1EA", # VENEZUELA
149: "", # AZORES
149: "\U0001F1F5\U0001F1F9", # AZORES
150: "\U0001F1E6\U0001F1FA", # AUSTRALIA
151: "", # MALYJ VYSOTSKIJ ISLAND
152: "\U0001F1F2\U0001F1F4", # MACAO

View File

@@ -11,4 +11,4 @@ class SIG:
# and Field Spotter. Does not include the "fa-" prefix.
icon: str
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str
ref_regex: str = None

View File

@@ -11,4 +11,5 @@ psutil~=7.1.0
requests-sse~=0.5.2
rss-parser~=2.1.1
pyproj~=3.7.2
prometheus_client~=0.23.1
prometheus_client~=0.23.1
beautifulsoup4~=4.14.2

View File

@@ -246,6 +246,9 @@ class WebServer:
case "comment_includes":
comment_includes = query.get(k).strip()
spots = [s for s in spots if s.comment and comment_includes.upper() in s.comment.upper()]
case "dx_call_includes":
dx_call_includes = query.get(k).strip()
spots = [s for s in spots if s.dx_call and dx_call_includes.upper() in s.dx_call.upper()]
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE"
@@ -320,6 +323,9 @@ class WebServer:
case "dx_continent":
dxconts = query.get(k).split(",")
alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts]
case "dx_call_includes":
dx_call_includes = query.get(k).strip()
spots = [a for a in alerts if a.dx_call and dx_call_includes.upper() in a.dx_call.upper()]
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
if "limit" in query.keys():
alerts = alerts[:int(query.get("limit"))]

View File

@@ -1,5 +1,5 @@
import logging
from datetime import datetime, timezone
from datetime import datetime
from threading import Thread
import aprslib
@@ -58,5 +58,5 @@ class APRSIS(SpotProvider):
self.submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(timezone.utc)
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from APRS-IS.")

View File

@@ -1,17 +1,16 @@
import logging
import re
from datetime import datetime, timezone
from datetime import datetime
from threading import Thread
from time import sleep
import pytz
import telnetlib3
from core.constants import SIGS
from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig
from core.config import SERVER_OWNER_CALLSIGN
from core.sig_utils import ANY_SIG_REGEX, get_icon_for_sig, get_ref_regex_for_sig
from data.sig_ref import SIGRef
from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN
from spotproviders.spot_provider import SpotProvider
@@ -97,7 +96,7 @@ class DXCluster(SpotProvider):
self.submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(timezone.utc)
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from DX Cluster " + self.hostname + ".")
except Exception as e:

View File

@@ -1,14 +1,14 @@
import logging
import re
from datetime import datetime, timezone
from datetime import datetime
from threading import Thread
from time import sleep
import pytz
import telnetlib3
from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider
@@ -77,7 +77,7 @@ class RBN(SpotProvider):
self.submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(timezone.utc)
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from RBN on port " + str(self.port) + ".")
except Exception as e:

View File

@@ -74,14 +74,15 @@ class WOTA(HTTPSpotProvider):
time=time.timestamp())
# WOTA name/grid/lat/lon lookup
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
for feature in wota_data["features"]:
if feature["properties"]["wotaId"] == spot.sig_refs[0]:
spot.sig_refs[0].name = feature["properties"]["title"]
spot.dx_latitude = feature["geometry"]["coordinates"][1]
spot.dx_longitude = feature["geometry"]["coordinates"][0]
spot.dx_grid = feature["properties"]["qthLocator"]
break
if ref:
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
for feature in wota_data["features"]:
if feature["properties"]["wotaId"] == ref:
spot.sig_refs[0].name = feature["properties"]["title"]
spot.dx_latitude = feature["geometry"]["coordinates"][1]
spot.dx_longitude = feature["geometry"]["coordinates"][0]
spot.dx_grid = feature["properties"]["qthLocator"]
break
new_spots.append(spot)
return new_spots

View File

@@ -17,8 +17,9 @@
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
<h4 class="mt-4">What data sources are supported?</h4>
<p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, and the UK Packet Repeater Network.</p>
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, and WOTA.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTS, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, WAB & WAI.</p>
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.</p>
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTS, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, BOTA, WAB & WAI.</p>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got two key advantages over those sites:</p>
@@ -36,6 +37,9 @@
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
<p>There are no trackers, no ads, and no cookies.</p>
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
<h2 class="mt-4">Thanks</h2>
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, and other online tools on which Spothole's data is based.</p>
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set.</p>
</div>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>

View File

@@ -14,6 +14,10 @@
</div>
<div class="col-auto">
<p class="d-inline-flex gap-1">
<div style="position: relative; display: inline-block; top: 2px;">
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0px; padding: 10px; pointer-events: none;"></i>
<input id="filter-dx-call" type="text" class="form-control hideonmobile me-3" oninput="filtersUpdated();" placeholder="Search for call" style="max-width: 10em; padding-left: 2em;">
</div>
<button id="add-spot-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleAddSpotPanel();"><i class="fa-solid fa-comment"></i> Add Spot</button>
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>

View File

@@ -62,6 +62,7 @@ paths:
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS
@@ -87,6 +88,7 @@ paths:
- ZLOTA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- name: needs_sig
@@ -205,6 +207,12 @@ paths:
schema:
type: boolean
default: false
- name: dx_call_includes
in: query
description: "Limit the alerts to only ones where the DX callsign includes the supplied string (case-insensitive). Generally a complete callsign, but you can supply a shorter string for partial matches."
required: false
schema:
type: string
- name: comment_includes
in: query
description: "Return only spots where the comment includes the provided string (case-insensitive)."
@@ -284,6 +292,7 @@ paths:
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS
@@ -309,11 +318,12 @@ paths:
- ZLOTA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- name: dx_continent
in: query
description: "Limit the alerts to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list."
description: "Limit the alerts to only ones where the DX operator is on the given continent(s). To select more than one continent, supply a comma-separated list."
required: false
schema:
type: string
@@ -325,6 +335,12 @@ paths:
- AF
- OC
- AN
- name: dx_call_includes
in: query
description: "Limit the alerts to only ones where the DX callsign includes the supplied string (case-insensitive). Generally a complete callsign, but you can supply a shorter string for partial matches."
required: false
schema:
type: string
responses:
'200':
description: Success
@@ -768,6 +784,7 @@ components:
- ZLOTA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
example: POTA
@@ -921,6 +938,7 @@ components:
- ZLOTA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
example: POTA
@@ -950,6 +968,7 @@ components:
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS

View File

@@ -59,14 +59,6 @@ button#add-spot-button {
/* SPOTS/ALERTS PAGES, MAIN TABLE */
/* Custom version of Bootstrap table colouring to colour 2 in every 4 rows, because of our second row per spot that
appears on mobile */
.table-striped-custom > tbody > tr:nth-of-type(4n+3) > *,
.table-striped-custom > tbody > tr:nth-of-type(4n+4) > * {
--bs-table-color-type: var(--bs-table-striped-color);
--bs-table-bg-type: var(--bs-table-striped-bg);
}
td.nowrap, span.nowrap {
text-wrap: nowrap;
}

View File

@@ -51,7 +51,7 @@ function updateTable() {
var showRef = $("#tableShowRef")[0].checked;
// Populate table with headers
let table = $('<table class="table table-striped-custom table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
if (showStartTime) {
table.find('thead tr').append(`<th>${useLocalTime ? "Start&nbsp;(Local)" : "Start&nbsp;UTC"}</th>`);
}
@@ -107,10 +107,18 @@ function updateTable() {
// Add a row to tbody for each alert in the provided list
function addAlertRowsToTable(tbody, alerts) {
var count = 0;
alerts.forEach(a => {
// Create row
let $tr = $('<tr>');
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
// which cause the table-striped colouring to go awry.
if (count % 2 == 1) {
$tr.addClass("table-active");
}
// Use local time instead of UTC?
var useLocalTime = $("#timeZone")[0].value == "local";
@@ -205,9 +213,17 @@ function addAlertRowsToTable(tbody, alerts) {
}
// Format sig_refs
var sig_refs = ""
if (a["sig_refs"]) {
sig_refs = a["sig_refs"].map(a => `<span class='nowrap'>${a}</span>`).join(", ");
var sig_refs = "";
if (a["sig_refs"] != null) {
var items = []
for (var i = 0; i < a["sig_refs"].length; i++) {
if (a["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${a["sig_refs"][i]["url"]}' title='${a["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${a["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${a["sig_refs"][i]["id"]}`
}
}
sig_refs = items.join(", ");
}
// Populate the row
@@ -236,6 +252,9 @@ function addAlertRowsToTable(tbody, alerts) {
// Second row for mobile view only, containing source, ref, freqs/modes & comment
$tr2 = $("<tr class='hidenotonmobile'>");
if (count % 2 == 1) {
$tr2.addClass("table-active");
}
$td2 = $("<td colspan='100'>");
if (showSource) {
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> `);
@@ -251,6 +270,8 @@ function addAlertRowsToTable(tbody, alerts) {
}
$tr2.append($td2);
tbody.append($tr2);
count++;
});
}

View File

@@ -106,11 +106,17 @@ function getTooltipText(s) {
// Format sig_refs
var sig_refs = "";
var items = []
for (var i = 0; i < s["sig_refs"].length; i++) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
if (s["sig_refs"] != null) {
var items = []
for (var i = 0; i < s["sig_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
}
}
sig_refs = items.join(", ");
}
sig_refs = items.join(", ");
// DX
ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${dx_call}' target='_blank' class="dx-link">${dx_call}</a></span><br/>`;

View File

@@ -20,6 +20,9 @@ function buildQueryString() {
}
});
str = str + "limit=" + $("#spots-to-fetch option:selected").val();
if ($("#filter-dx-call").val() != "") {
str = str + "&dx_call_includes=" + encodeURIComponent($("#filter-dx-call").val());
}
return str;
}
@@ -43,7 +46,7 @@ function updateTable() {
var showDE = $("#tableShowDE")[0].checked;
// Populate table with headers
let table = $('<table class="table table-striped-custom table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
if (showTime) {
table.find('thead tr').append(`<th>${useLocalTime ? "Local" : "UTC"}</th>`);
}
@@ -76,10 +79,18 @@ function updateTable() {
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
}
var count = 0;
spots.forEach(s => {
// Create row
let $tr = $('<tr>');
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
// which cause the table-striped colouring to go awry.
if (count % 2 == 1) {
$tr.addClass("table-active");
}
// Show faded out if QRT
if (s["qrt"] == true) {
$tr.addClass("table-faded");
@@ -161,11 +172,17 @@ function updateTable() {
// Format sig_refs
var sig_refs = "";
var items = []
for (var i = 0; i < s["sig_refs"].length; i++) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
if (s["sig_refs"] != null) {
var items = []
for (var i = 0; i < s["sig_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
}
}
sig_refs = items.join(", ");
}
sig_refs = items.join(", ");
// Format DE flag
var de_flag = "<i class='fa-solid fa-circle-question'></i>";
@@ -224,6 +241,9 @@ function updateTable() {
// Second row for mobile view only, containing type, ref & comment
$tr2 = $("<tr class='hidenotonmobile'>");
if (count % 2 == 1) {
$tr2.addClass("table-active");
}
if (s["qrt"] == true) {
$tr2.addClass("table-faded");
}
@@ -242,6 +262,8 @@ function updateTable() {
}
$tr2.append($td2);
table.find('tbody').append($tr2);
count++;
});
// Update DOM