diff --git a/docker-compose.yml b/docker-compose.yml index e6437db..08b7b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: build: ./web volumes: - ./web:/var/www/html/ + - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini ports: - 8000:80 diff --git a/station/__pycache__/config.cpython-311.pyc b/station/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..b9c79a7 Binary files /dev/null and b/station/__pycache__/config.cpython-311.pyc differ diff --git a/station/__pycache__/puller.cpython-311.pyc b/station/__pycache__/puller.cpython-311.pyc new file mode 100644 index 0000000..0d36b15 Binary files /dev/null and b/station/__pycache__/puller.cpython-311.pyc differ diff --git a/station/config.py b/station/config.py new file mode 100644 index 0000000..6cd335a --- /dev/null +++ b/station/config.py @@ -0,0 +1,8 @@ +masterUrl = "http://10.0.3.41:8000" +pullInterval = 10 # in sec +apiKey = "7b105947-65d6-40ba-bb4c-50b95a3ec1c8" +station = { + "lat": 49.2397383, + "lon": 16.5684175, + "alt": 0.277 #KM +} \ No newline at end of file diff --git a/station/main.py b/station/main.py new file mode 100644 index 0000000..cf20c69 --- /dev/null +++ b/station/main.py @@ -0,0 +1,36 @@ +import config +import puller +import time +from datetime import datetime, timedelta +from recorder import recorder + +def onRecorded(info): + pass + +while True: + try: + puller.pull() + + for job in puller.watingJobs: + print(f"Job {job['target']['name']} starts at {job['start']}") + + if job["start"] <= datetime.utcnow() + timedelta(seconds=60): + if job["end"] <= datetime.utcnow(): + puller.setFail(job["id"]) + puller.watingJobs.remove(job) + + # start record + puller.setRecording(job["id"]) + + curRecorder = recorder(job) + curRecorder.start() + + puller.watingJobs.remove(job) + + except: + print("[ERROR] main script fail restarting") + + + + + time.sleep(config.pullInterval) \ No newline at end of file diff --git a/station/puller.py b/station/puller.py new file mode 100644 index 0000000..f5fc6aa --- /dev/null +++ b/station/puller.py @@ -0,0 +1,70 @@ +import config +from datetime import datetime +from urllib.request import urlopen +import requests +import json +import os + +import pathlib + +watingJobs = [] + +def getNewJobs(): + response = urlopen(config.masterUrl + "/api/observation/record?key=" + config.apiKey) + data_json = json.loads(response.read()) + return data_json + +def apiSend(url, data, files=None): + r = requests.post(url=config.masterUrl + url, data=data, files=files) + return r.text + +def setFail(observation): + apiSend("/api/observation/fail", {"id": observation}) + +def setAssigned(observation): + apiSend("/api/observation/assigned", {"id": observation}) + +def setRecording(observation): + apiSend("/api/observation/recording", {"id": observation}) + +def setRecorded(observation): + apiSend("/api/observation/recorded", {"id": observation}) + +def setDecoding(observation): + apiSend("/api/observation/decoding", {"id": observation}) + +def setSuccess(observation): + apiSend("/api/observation/success", {"id": observation}) + +def setArtefacts(adir, observation): + ufiles = {} # open('file.txt','rb') + + print("Uploading artefacts") + + for path, subdirs, files in os.walk(adir): + for name in files: + afile = os.path.join(path, name) + fileName = str(afile).replace(str(adir), "").replace("/", "\\") + print(fileName) + ufiles[fileName] = open(afile, 'rb') + + + apiSend("/api/observation/addArtefacts", {"id": observation}, ufiles) + + +def parseNewJobs(jobs): + for job in jobs: + job["start"] = datetime.strptime(job["start"], '%Y-%m-%d %H:%M:%S') + job["end"] = datetime.strptime(job["end"], '%Y-%m-%d %H:%M:%S') + + if job["start"] < datetime.utcnow(): + setFail(job["id"]) + continue + + setAssigned(job["id"]) + + watingJobs.append(job) + +def pull(): + jobs = getNewJobs() + parseNewJobs(jobs) \ No newline at end of file diff --git a/station/recorder.py b/station/recorder.py new file mode 100644 index 0000000..aa70192 --- /dev/null +++ b/station/recorder.py @@ -0,0 +1,88 @@ +import os +import puller +import threading +from rotator import rotator +from simplecom import simplecom +from pathlib import Path +import config +import time + +# A recursive function to remove the folder +def del_folder(path): + for sub in path.iterdir(): + if sub.is_dir(): + # Delete directory if it's a subdirectory + del_folder(sub) + else : + # Delete file if it is a file: + sub.unlink() + + # This removes the top-level folder: + path.rmdir() + +class recorder(threading.Thread): + def __init__(self, job): + threading.Thread.__init__(self) + self.job = job + + def run(self): + print(f"Recorder for job {self.job['target']['name']} started") + + recordTime = (self.job["end"] - self.job["start"]).total_seconds() + + #init rotator + rotatorDriver = simplecom("/dev/ttyUSB0") + rotatorCTR = rotator(rotatorDriver, self.job, config.station) + rotatorCTR.start() + + baseband = f"records/{self.job['id']}" + + fs = max(self.job["receiver"]["params"]["fs"]) + + # find supported FS + for sample in self.job["receiver"]["params"]["fs"]: + if (sample > (int(self.job['transmitter']['bandwidth']) * 2)) and (sample < fs): + fs = sample + + time.sleep(50) + + os.system(f"satdump record {baseband} --source {self.job['receiver']['params']['radio']} --samplerate {fs} --frequency {self.job['transmitter']['centerFrequency']} --gain {self.job['receiver']['params']['gain']} --baseband_format s8 --timeout {recordTime}") + + + print(f"Recorder for job {self.job['target']['name']} stoped") + + puller.setRecorded(self.job["id"]) + rotatorCTR.kill() + + if self.job["proccessPipe"] == []: + return + + puller.setDecoding(self.job["id"]) + + pipe = " && ".join(self.job["proccessPipe"]) + + #create artecats dir + adir = f"artefacts/{self.job['id']}" + os.makedirs(adir) + + #ok now replace + pipe = pipe.replace("{baseband}", str(baseband) + ".s8").replace("{fs}", str(fs)).replace("{artefactDir}", str(adir)) + + os.system(pipe) + + puller.setSuccess(self.job["id"]) + + puller.setArtefacts(adir, self.job["id"]) + + # remove basband record + os.remove(str(baseband) + ".s8") + + # remove artefacts + path = Path(adir) + try: + del_folder(path) + print("Directory removed successfully") + except OSError as o: + print(f"Error, {o.strerror}: {path}") + + diff --git a/station/rotator.py b/station/rotator.py new file mode 100644 index 0000000..d2f2a0d --- /dev/null +++ b/station/rotator.py @@ -0,0 +1,49 @@ +import threading +from pyorbital.orbital import Orbital +from datetime import datetime, timedelta +import time + +class rotator(threading.Thread): + def __init__(self, driver, job, station): + threading.Thread.__init__(self) + self.driver = driver + self.job = job + self.station = station + self.killed = False + + def run(self): + print("[INFO] Starting rotator service") + + self.driver.reset() + time.sleep(30) + + #init pyorbytal + orb = Orbital(self.job["target"]["name"], line1=self.job["target"]["locator"]["tle"]["line1"], line2=self.job["target"]["locator"]["tle"]["line2"]) + + while (True): + az, el = orb.get_observer_look( + utc_time=datetime.utcnow() + timedelta(seconds=5), + lon=self.station["lon"], + lat=self.station["lat"], + alt=self.station["alt"] + ) + az, el = round(az), round(el) + + print(f"[INFO] rotator az: {az}, el: {el}") + + self.driver.set_azel(az, el) + + if (self.killed): + break + + time.sleep(10) + + # home the rotator on end + self.driver.reset() + + time.sleep(60) + + + + def kill(self): + self.killed = True \ No newline at end of file diff --git a/station/simplecom.py b/station/simplecom.py new file mode 100644 index 0000000..a7c5286 --- /dev/null +++ b/station/simplecom.py @@ -0,0 +1,43 @@ +import serial +import time + +class simplecom(object): + def __init__(self, port): + self.port = port + self.serial = serial.Serial(self.port, 9600, timeout=60) + + def send(self, cmd): + try: + self.serial.write(cmd.encode("ASCII")) + self.serial.flush() + except: + print("[ERROR] fail to write to serial") + + def reset(self): + self.send("RESET\n") + + def set_azel(self, az, el): + self.set_az(az) + self.set_el(el) + + def set_az(self, az): + while (az < 0): + az += 360 + + az = round(az % 360) + + self.send(f"AZ{az}\n") + #readout target + self.send(f"TAR\n") + + def set_el(self, el): + if (el < 0): + el = 0 + elif (el > 90): + el = 90 + + el = round(el) + + self.send(f"EL{el}\n") + #readout target + self.send(f"TAR\n") \ No newline at end of file diff --git a/uploads.ini b/uploads.ini new file mode 100644 index 0000000..be50262 --- /dev/null +++ b/uploads.ini @@ -0,0 +1,2 @@ +upload_max_filesize = 10G +post_max_size = 10G \ No newline at end of file diff --git a/web/API/main.php b/web/API/main.php new file mode 100644 index 0000000..5a098f1 --- /dev/null +++ b/web/API/main.php @@ -0,0 +1,17 @@ +get("templateLoader"); + $context = $container->get("context"); + $auth = $container->get("auth"); + $router = $container->get("router"); + + // to show this page user must be logined + //$auth->requireLogin(); + + //register API functions + include_once(__DIR__ . "/observations.php"); + + //init API + $api->serve($router->getArgs()); \ No newline at end of file diff --git a/web/API/observations.php b/web/API/observations.php new file mode 100644 index 0000000..cdc959b --- /dev/null +++ b/web/API/observations.php @@ -0,0 +1,145 @@ +fetch(); + $transmitter->fetch(); + + $plan = new \DAL\observation(); + $plan->status ->set("planed"); + $plan->locator ->set($transmitter->target->get()->locator->get()); + $plan->transmitter->set($transmitter); + $plan->receiver ->set($receiver); + $plan->start ->set($params["start"]); + $plan->end ->set($params["end"]); + + if ($plan->start >= $plan->end) return ["status" => false]; + + $plan->commit(); + + return ["status" => true, "id" => $plan->id->get()]; + } + + function record($params) { + //get GS and set last seen + $station = new \DAL\station(); + if (!$station->find("apiKey", $params["key"])) return ["status" => "bad api key"]; + + $station->lastSeen->now(); + $station->commit(); + + //get all jobs for ground station + $table = new \wsos\database\core\table(\DAL\observation::class); + + $dummyObservation = new \DAL\observation(); + $dummyObservation->status->set("planed"); + //$dummyObservation->station->set($params["station"]); + + $planed = $table->query("(status == ?) && (receiver.station.id == ?)", [$dummyObservation->status->value, $station->id->get()]); + + $jobs = new \wsos\structs\vector(); + foreach ($planed->values as $plan) { + $jobs->append([ + "id" => $plan->id->get(), + "target" => [ + "id" => $plan->transmitter->get()->target->get()->id->get(), + "name" => $plan->transmitter->get()->target->get()->name->get(), + "locator" => $plan->locator->get() + ], + + "transmitter" => [ + "centerFrequency" => $plan->transmitter->get()->centerFrequency->get(), + "bandwidth" => $plan->transmitter->get()->bandwidth->get() + ], + + "receiver" => [ + "params" => $plan->receiver->get()->params->get() + ], + + "proccessPipe" => $plan->transmitter->get()->processPipe->get()->pipe->get(), + + "start" => $plan->start->get(), + "end" => $plan->end->get() + ]); + } + + return $jobs->values; + } + + function fail($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("fail"); + $obs->commit(); + } + + function assigned($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("assigned"); + $obs->commit(); + } + + function recording($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("recording"); + $obs->commit(); + } + + function recorded($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("recorded"); + $obs->commit(); + } + + function decoding($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("decoding"); + $obs->commit(); + } + + function success($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $obs->status->set("success"); + $obs->commit(); + } + + function addArtefacts($params) { + $obs = new \DAL\observation(); + $obs->id->set($params["id"]); + $obs->fetch(); + + $adir = __DIR__ . "/../ARTEFACTS/" . $params["id"]; + + mkdir($adir, 0777, true); + + $artefacts = $obs->artefacts->get(); + foreach ($_FILES as $file) { + move_uploaded_file($file["tmp_name"], $adir . "/" . $file["name"]); + $artefacts[] = "/ARTEFACTS/{$params['id']}/{$file['name']}"; + } + + $obs->artefacts->set($artefacts); + $obs->commit(); + } diff --git a/web/ARTEFACTS/.gitkeep b/web/ARTEFACTS/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/CONTROLLERS/dashboard.php b/web/CONTROLLERS/dashboard.php index a6fdb94..948fb68 100644 --- a/web/CONTROLLERS/dashboard.php +++ b/web/CONTROLLERS/dashboard.php @@ -8,26 +8,98 @@ // to show this page user must be logined $auth->requireLogin(); - $context["successCount"] = 75; - $context["planedCount"] = 15; - $context["lastPlaned"] = 2; - $context["failCount"] = 5; - $context["observationsCount"] = 5; - // create planed template observations - $planed = new \DAL\observation(); - $planed->status->set("planed"); + $ob = new \DAL\observation(); + + /** + * Get the directory size + * @param string $directory + * @return integer + */ + function foldersize($path, $extension = null) { + $total_size = 0; + $files = scandir($path); + $cleanPath = rtrim($path, '/'). '/'; + + foreach($files as $t) { + if ($t <> "." && $t <> "..") { + $currentFile = $cleanPath . $t; + if (is_dir($currentFile)) { + $size = foldersize($currentFile, $extension); + $total_size += $size; + } else { + if (is_null($extension) || (strtolower(pathinfo($currentFile)['extension']) == $extension)) { + $size = filesize($currentFile); + $total_size += $size; + } + } + } + } + + return $total_size; + } $observationsTable = new \wsos\database\core\table(\DAL\observation::class); - $planedTable = $observationsTable->query("status=?", [$planed->status->value]); + $maxSize = 8000; //8GB + + $context["artefactsSpace"] = $maxSize; + + /** + * For carts + */ + $context["successCount"] = $observationsTable->count("status==?", [$ob->status->getVal("success")]); + $context["planedCount"] = $observationsTable->count("status==?", [$ob->status->getVal("planed")]); + $context["lastPlaned"] = $observationsTable->count("", []); + $context["failCount"] = $observationsTable->count("status==?", [$ob->status->getVal("fail")]); + $context["observationsCount"] = $observationsTable->count("", []); + + /** + * Get used size + */ + $context["usedSize"] = round(foldersize(__DIR__ . "/../artefacts/") / 1000000); + $context["imagesSize"] = round(foldersize(__DIR__ . "/../artefacts/", "png") / 1000000); + $context["basebandSize"] = round(foldersize(__DIR__ . "/../artefacts/", "s8") / 1000000); + $context["otherSize"] = $context["usedSize"] - $context["imagesSize"] + $context["basebandSize"]; + $context["freeSize"] = $context["artefactsSpace"] - $context["usedSize"]; + + /** + * Get observations + */ + $observationTable = new \wsos\database\core\table(\DAL\observation::class); + $context["observations"] = $observationTable->query("", [], "DESC start", 10)->values; + + /** + * Get stattions + */ + $context["stations"] = []; + $stations = (new \wsos\database\core\table(\DAL\station::class))->getAll()->values; + foreach ($stations as $station) { + $context["stations"][] = [ + "name" => $station->name->get(), + "observations" => $observationTable->count("receiver.station.id == ?", [$station->id->get()]), + "lastSeen" => $station->lastSeen->strDelta() + ]; + } + + /** + * Get planed observations + */ + $planedTable = $observationsTable->query("(status == ?) || (status == ?)", [$ob->status->getVal("assigned"), $ob->status->getVal("planed")]); $observationsLocators = new \wsos\structs\vector(); foreach($planedTable->values as $obs) { - $observationsLocators->append($obs->locator->get()); + $locator = $obs->locator->get(); + $locator["start"] = $obs->start->get() . " UTC"; + $locator["end"] = $obs->end->get() . " UTC"; + + $observationsLocators->append($locator); } - $context["planedLocators"] = json_encode($observationsLocators->values); + $context["planedObservations"] = json_encode($observationsLocators->values); + /** + * Render view + */ $templates->load("dashboard.html"); $templates->render($context); $templates->show(); \ No newline at end of file diff --git a/web/CONTROLLERS/observation.php b/web/CONTROLLERS/observation.php new file mode 100644 index 0000000..903410a --- /dev/null +++ b/web/CONTROLLERS/observation.php @@ -0,0 +1,49 @@ +get("templateLoader"); + $router = $container->get("router"); + $context = $container->get("context"); + $auth = $container->get("auth"); + + // to show this page user must be logined + $auth->requireLogin(); + + //get observation ID + $obId = $router->getArgs()[0]; + + //$obId = new \DAL\observation(); + //$obId->find("status", 4); + //$obId = $obId->id; + + //get correct observation + $context["observation"] = new \DAL\observation(new \wsos\database\types\uuid($obId)); + $context["observation"]->fetch(); + + //generate artefacts + $context["artefacts"] = new \wsos\structs\vector(); + + //get observations whats from same satellite and in 24h interval + $context["observations"] = new \wsos\database\core\table(\DAL\observation::class); + $context["observations"] = $context["observations"]->query( + "(transmitter.target.id == ?) && (start > ?) && (end < ?)", + [ + $context["observation"]->transmitter->get()->target->get()->id->get(), + $context["observation"]->start->value - (60 * 60 * 12), + $context["observation"]->end->value + (60 * 60 * 12) + ], + "DESC start" + )->values; + + foreach ($context["observation"]->artefacts->get() as $art) { + $context["artefacts"]->append([ + "name" => basename($art), + "url" => $art + ]); + } + + $context["artefacts"] = $context["artefacts"]->values; + + $templates->load("observation.html"); + $templates->render($context); + $templates->show(); \ No newline at end of file diff --git a/web/CONTROLLERS/observations.php b/web/CONTROLLERS/observations.php index d6b216f..58061f4 100644 --- a/web/CONTROLLERS/observations.php +++ b/web/CONTROLLERS/observations.php @@ -8,7 +8,9 @@ // to show this page user must be logined $auth->requireLogin(); - $context["observations"] = new \wsos\database\core\table(\DAL\observation::class); + $context["observations"] = (new \wsos\database\core\table(\DAL\observation::class))->query("", [], "DESC start")->values; + $context["receivers"] = new \wsos\database\core\table(\DAL\receiver::class); + $context["transmitters"] = new \wsos\database\core\table(\DAL\transmitter::class); $templates->load("observations.html"); $templates->render($context); diff --git a/web/DAL/observation.php b/web/DAL/observation.php index 5a14f92..9bc401a 100644 --- a/web/DAL/observation.php +++ b/web/DAL/observation.php @@ -8,6 +8,8 @@ public \wsos\database\types\text $record; // path to record public \wsos\database\types\json $artefacts; // JSON array of artefacts public \wsos\database\types\json $locator; // TLE, GPS or URL locator if avaible + public \wsos\database\types\timestamp $start; // start datetime + public \wsos\database\types\timestamp $end; // end datetimr function __construct( $id = null, @@ -16,7 +18,9 @@ $status = "", $record = "", $artefacts = [], - $locator = ["tle" => null, "gps" => null, "url" => null] + $locator = ["tle" => null, "gps" => null, "url" => null], + $start = "2000-01-01 00:00:00", + $end = "2000-01-01 00:10:00" ) { parent::__construct($id); $this->transmitter = new \wsos\database\types\reference($transmitter, \DAL\transmitter::class); @@ -25,14 +29,18 @@ "fail", "success", "recording", + "recorded", "decoding", "planed", + "assigned", "unknow" ], "unknow"); $this->record = new \wsos\database\types\text($record); $this->artefacts = new \wsos\database\types\json($artefacts); $this->locator = new \wsos\database\types\json($locator); + $this->start = new \wsos\database\types\timestamp($start); + $this->end = new \wsos\database\types\timestamp($end); } } ?> \ No newline at end of file diff --git a/web/DAL/receiver.php b/web/DAL/receiver.php index 07b0d64..6a9d286 100644 --- a/web/DAL/receiver.php +++ b/web/DAL/receiver.php @@ -6,6 +6,7 @@ public \wsos\database\types\reference $antenna; // YAGI, DISH, .... public \wsos\database\types\integer $centerFrequency; // in Hz public \wsos\database\types\integer $bandwidth; // in Hz + public \wsos\database\types\json $params; // params for use public \wsos\database\types\integer $gain; // gain of reciver setup function __construct( @@ -14,6 +15,7 @@ $antenna = null, $centerFrequency = 0, $bandwidth = 0, + $params = [], $gain = 0 ) { parent::__construct($id); @@ -22,6 +24,7 @@ $this->centerFrequency = new \wsos\database\types\integer($centerFrequency); $this->bandwidth = new \wsos\database\types\integer($bandwidth); + $this->params = new \wsos\database\types\json($params); $this->gain = new \wsos\database\types\integer($gain); } } diff --git a/web/DAL/station.php b/web/DAL/station.php index 77026fd..86b361f 100644 --- a/web/DAL/station.php +++ b/web/DAL/station.php @@ -2,15 +2,18 @@ namespace DAL; class station extends \wsos\database\core\row { - public \wsos\database\types\text $name; // Satellite, ... + public \wsos\database\types\text $name; // Satellite, ... + public \wsos\database\types\uuid $apiKey; // access key + public \wsos\database\types\timestamp $lastSeen; public \wsos\database\types\text $description; public \wsos\database\types\json $locator; - function __construct($id = null, $name = "", $description = "", $locator = []) { + function __construct($id = null, $name = "", $apiKey = null, $lastSeen = 0, $description = "", $locator = []) { parent::__construct($id); $this->name = new \wsos\database\types\text($name); $this->description = new \wsos\database\types\text($description); - + $this->apiKey = new \wsos\database\types\uuid($apiKey); + $this->lastSeen = new \wsos\database\types\timestamp($lastSeen); $this->locator = new \wsos\database\types\json($locator); } } diff --git a/web/DB/.gitkeep b/web/DB/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/VIEWS/blocks/observation-item.html b/web/VIEWS/blocks/observation-item.html index 319245a..a25cbb9 100644 --- a/web/VIEWS/blocks/observation-item.html +++ b/web/VIEWS/blocks/observation-item.html @@ -11,6 +11,8 @@ {% IF item.status==recording USE bg-info %} {% IF item.status==decoding USE bg-warning %} {% IF item.status==planed USE bg-primary %} + {% IF item.status==assigned USE bg-secondary %} + {% IF item.status==recorded USE bg-light %} me-1"> {% BIND item.status %}
Using Storage 6854.45 MB of 8 GB
+Using Storage {% BIND usedSize %} MB of {% BIND artefactsSpace %} MB
Name | +Observations | +Last seen | +
---|
Network | -Visitors | -|
---|---|---|
3,550 | -
-
-
-
- |
- |
1,798 | -
-
-
-
- |
- |
1,245 | -
-
-
-
- |
- |
TikTok | -986 | -
-
-
-
- |
-
854 | -
-
-
-
- |
- |
VK | -650 | -
-
-
-
- |
-
420 | -
-
-
-
- |
-
- | No. - - | -Invoice Subject | -Client | -VAT No. | -Created | +Station | +Target | +Modulation | +Type | +Frequency | Status | -Price | -+ | Start | +End |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
- | 001401 | -Design Works | -- - Carlson Limited - | -- 87956621 - | -- 15 Dec 2017 - | -- Paid - | -$887 | -- - - - - | -|||||||
- | 001402 | -UX Wireframes | -- - Adobe - | -- 87956421 - | -- 12 Apr 2017 - | -- Pending - | -$1200 | -- - - - - | -|||||||
- | 001403 | -New Dashboard | -- - Bluewolf - | -- 87952621 - | -- 23 Oct 2017 - | -- Pending - | -$534 | -- - - - - | -|||||||
- | 001404 | -Landing Page | -- - Salesforce - | -- 87953421 - | -- 2 Sep 2017 - | -- Due in 2 Weeks - | -$1500 | -- - - - - | -|||||||
- | 001405 | -Marketing Templates | -- - Printic - | -- 87956621 - | -- 29 Jan 2018 - | -- Paid Today - | -$648 | -- - - - - | -|||||||
- | 001406 | -Sales Presentation | -- - Tabdaq - | -- 87956621 - | -- 4 Feb 2018 - | -- Due in 3 Weeks - | -$300 | -- - - - - | -|||||||
- | 001407 | -Logo & Print | -- - Apple - | -- 87956621 - | -- 22 Mar 2018 - | -- Paid Today - | -$2500 | -- - - - - | -|||||||
- | 001408 | -Icons | -- - Tookapic - | -- 87956621 - | -- 13 May 2018 - | -- Paid Today - | -$940 | -- - - - - | -