Skip to content

Commit

Permalink
Add initial web service (#3)
Browse files Browse the repository at this point in the history
* Initial service scaffolding
* Enumerate nodes in background
* Get latest timestamps
* Get manifest updated timestamps
* Add a derived Status per node
* Add initial dashboard web page
* Add copyright notice and SPDX to cs files
* Add initial API controller
* Track recent events
* Fix offline timestamp
* Add IFTTT diagram to Design page
* Add CI/CD workflow

Signed-off-by: Dave Thaler <[email protected]>
  • Loading branch information
dthaler authored Jun 4, 2024
1 parent 387a161 commit 3e2c276
Show file tree
Hide file tree
Showing 91 changed files with 75,542 additions and 5 deletions.
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# For documentation on the format of this file, see
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:

- package-ecosystem: "github-actions"
# Workflow files stored in the
# default location of `.github/workflows`
directory: "/"
schedule:
interval: "weekly"
day: "saturday"
42 changes: 42 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CPP CI

on:
pull_request:

concurrency:
# Cancel any CI/CD workflow currently in progress for the same PR.
# Allow running concurrently with any other commits.
group: build-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
build_windows:
strategy:
matrix:
configurations: [Debug, Release]
runs-on: windows-latest
env:
# Configuration type to build. For documentation on how build matrices work, see
# https://docs.github.com/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
BUILD_CONFIGURATION: ${{matrix.configurations}}

steps:
- uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0
with:
egress-policy: audit

- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
cancel_others: 'false'
paths_ignore: '["**.md"]'

- uses: actions/checkout@v4
if: steps.skip_check.outputs.should_skip != 'true'
with:
submodules: 'recursive'

- name: Build
if: steps.skip_check.outputs.should_skip != 'true'
run: |
dotnet build OrcanodeMonitor.sln /p:Configuration=${{env.BUILD_CONFIGURATION}} /p:Platform="Any CPU"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/**/.vs/
/**/obj/
/**/bin/
25 changes: 25 additions & 0 deletions OrcanodeMonitor.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.34928.147
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrcanodeMonitor", "OrcanodeMonitor\OrcanodeMonitor.csproj", "{D45F546F-D66C-4FDC-B680-D457275221BD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D45F546F-D66C-4FDC-B680-D457275221BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D45F546F-D66C-4FDC-B680-D457275221BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D45F546F-D66C-4FDC-B680-D457275221BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D45F546F-D66C-4FDC-B680-D457275221BD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3612E55-DFF0-4939-9E5A-D7CFE36F536D}
EndGlobalSection
EndGlobal
18 changes: 18 additions & 0 deletions OrcanodeMonitor/Api/MonitorController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Orcanode Monitor contributors
// SPDX-License-Identifier: MIT
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace OrcanodeMonitor.Api
{
[Route("api/[controller]")]
[ApiController]
public class MonitorController : ControllerBase
{
public IActionResult Get()
{
// Logic for handling GET requests
return Ok("Hello from API!");
}
}
}
99 changes: 99 additions & 0 deletions OrcanodeMonitor/Api/TestController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
using System.Dynamic;
using System.Text.Json;

// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860

namespace OrcanodeMonitor.Api
{
[Route("api/ifttt/v1/triggers/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
#if false
// GET: api/ifttt/v1/triggers/test
[HttpGet]
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
#endif
private JsonResult GetEvents(int limit)
{
List<Core.OrcanodeEvent> latestEvents = Core.State.GetEvents(limit);
var dataResult = new { data = latestEvents };

var jsonString = JsonSerializer.Serialize(dataResult);
var jsonDocument = JsonDocument.Parse(jsonString);

// Get the JSON data as an array.
var jsonElement = jsonDocument.RootElement;

return new JsonResult(jsonElement);
}

// GET: api/ifttt/v1/triggers/<TestController>
[HttpGet]
public JsonResult Get()
{
return GetEvents(50);
}

// POST api/ifttt/v1/triggers/<TestController>
[HttpPost]
public IActionResult Post([FromBody] string value)
{
try
{
dynamic requestBody = JsonSerializer.Deserialize<ExpandoObject>(value);
if (!requestBody.TryGetProperty("limit", out JsonElement limitElement))
{
return BadRequest("Invalid JSON data.");
}
int limit = 50;
if (limitElement.TryGetInt32(out int explicitLimit))
{
limit = explicitLimit;
}
if (requestBody.TryGetProperty("triggerFields", out JsonElement triggerFields))
{
// TODO: use triggerFields to see if the caller only wants
// events for a given node.
}

return GetEvents(limit);
}
catch (JsonException)
{
return BadRequest("Invalid JSON data.");
}
}

#if false
// GET api/ifttt/v1/triggers/<TestController>/5
[HttpGet("{id}")]
public string Get(int id)
{
return "value";
}

// POST api/ifttt/v1/triggers/<TestController>
[HttpPost]
public void Post([FromBody] string value)
{
}

// PUT api/ifttt/v1/triggers/<TestController>/5
[HttpPut("{id}")]
public void Put(int id, [FromBody] string value)
{
}

// DELETE api/ifttt/v1/triggers/<TestController>/5
[HttpDelete("{id}")]
public void Delete(int id)
{
}
#endif
}
}
176 changes: 176 additions & 0 deletions OrcanodeMonitor/Core/Fetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) Orcanode Monitor contributors
// SPDX-License-Identifier: MIT
using System;
using System.Dynamic;
using System.Text.Json;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Xml.Linq;

namespace OrcanodeMonitor.Core
{
public class EnumerateNodesResult
{
public List<Orcanode> NodeList { get; private set; }
public bool Succeeded { get; set; }
public DateTime Timestamp { get; private set; }

public EnumerateNodesResult(DateTime timestamp)
{
NodeList = new List<Orcanode>();
Succeeded = false;
Timestamp = timestamp;
}
}
public class Fetcher
{
private static HttpClient _httpClient = new HttpClient();
private static string _orcasoundFeedsUrl = "https://live.orcasound.net/api/json/feeds";
private static DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);

/// <summary>
/// Get the current list of Orcanodes from orcasound.net.
/// </summary>
/// <returns>EnumerateNodesResult object</returns>
public async static Task<EnumerateNodesResult> EnumerateNodesAsync()
{
var result = new EnumerateNodesResult(DateTime.Now);
string json = "";
try
{
json = await _httpClient.GetStringAsync(_orcasoundFeedsUrl);
if (json == "")
{
return result;
}
dynamic response = JsonSerializer.Deserialize<ExpandoObject>(json);
if (response == null)
{
return result;
}
JsonElement dataArray = response.data;
if (dataArray.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (JsonElement feed in dataArray.EnumerateArray())
{
if (!feed.TryGetProperty("attributes", out JsonElement attributes))
{
continue;
}
if (!attributes.TryGetProperty("name", out var name))
{
continue;
}
if (!attributes.TryGetProperty("node_name", out var nodeName))
{
continue;
}
if (!attributes.TryGetProperty("bucket", out var bucket))
{
continue;
}
if (!attributes.TryGetProperty("slug", out var slug))
{
continue;
}
var node = new Orcanode(name.ToString(), nodeName.ToString(), bucket.ToString(), slug.ToString());
result.NodeList.Add(node);
}
result.Succeeded = true;
}
catch (Exception)
{
}
return result;
}

/// <summary>
/// Convert a unix timestamp in integer form to a DateTime value.
/// </summary>
/// <param name="unixTimeStamp">Unix timestamp</param>
/// <returns>DateTime value or null on failure</returns>
public static DateTime? UnixTimeStampToDateTime(long unixTimeStamp)
{
// A Unix timestamp is a count of seconds past the Unix epoch.
DateTime dateTime = _unixEpoch.AddSeconds(unixTimeStamp);
return dateTime;
}

/// <summary>
/// Convert a unix timestamp in string form to a DateTime value.
/// </summary>
/// <param name="unixTimeStampString">Unix timestamp string to parse</param>
/// <returns>DateTime value or null on failure</returns>
private static DateTime? UnixTimeStampStringToDateTime(string unixTimeStampString)
{
if (!long.TryParse(unixTimeStampString, out var unixTimeStamp))
{
return null;
}

return UnixTimeStampToDateTime(unixTimeStamp);
}

public static long DateTimeToUnixTimeStamp(DateTime dateTime)
{
DateTime utcTime = dateTime.ToUniversalTime();
long unixTime = (long)(utcTime - _unixEpoch).TotalSeconds;
return unixTime;
}

/// <summary>
/// Update the timestamps for a given Orcanode by querying files on S3.
/// </summary>
/// <param name="node">Orcanode to update</param>
/// <returns></returns>
public async static Task UpdateLatestTimestampAsync(Orcanode node)
{
string url = "https://" + node.Bucket + ".s3.amazonaws.com/" + node.NodeName + "/latest.txt";
HttpResponseMessage response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return;
}

string content = await response.Content.ReadAsStringAsync();
string unixTimestampString = content.TrimEnd();
DateTime? latestRecorded = UnixTimeStampStringToDateTime(unixTimestampString);
if (latestRecorded.HasValue)
{
node.LatestRecorded = latestRecorded;

DateTimeOffset? offset = response.Content.Headers.LastModified;
if (offset.HasValue)
{
node.LatestUploaded = offset.Value.UtcDateTime;
}
}

await UpdateManifestTimestampAsync(node, unixTimestampString);
}

/// <summary>
/// Update the ManifestUpdated timestamp for a given Orcanode by querying S3.
/// </summary>
/// <param name="node">Orcanode to update</param>
/// <param name="unixTimestampString">Value in the latest.txt file</param>
/// <returns></returns>
public async static Task UpdateManifestTimestampAsync(Orcanode node, string unixTimestampString)
{
string url = "https://" + node.Bucket + ".s3.amazonaws.com/" + node.NodeName + "/hls/" + unixTimestampString + "/live.m3u8";
HttpResponseMessage response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return;
}

DateTimeOffset? offset = response.Content.Headers.LastModified;
if (offset.HasValue)
{
node.ManifestUpdated = offset.Value.UtcDateTime;
}
}
}
}
Loading

0 comments on commit 3e2c276

Please sign in to comment.