Compare commits
36 Commits
7a6e4d17ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
e1bf53bd70
|
|||
|
94bcfe915d
|
|||
|
ec771edc01
|
|||
|
8a9a361a4b
|
|||
|
c0a8e3ced8
|
|||
|
e9c1dfa2e5
|
|||
|
ad81392dc7
|
|||
|
500f4746e8
|
|||
|
9159ecc834
|
|||
|
def75c1e41
|
|||
|
88c6bd4891
|
|||
|
a26e239291
|
|||
|
590a3d360f
|
|||
|
0f709b0a98
|
|||
|
bc87cef815
|
|||
|
f294a065e2
|
|||
|
5ce67a697b
|
|||
|
1878032ec4
|
|||
|
b986fe41c3
|
|||
|
f43a191908
|
|||
|
a53e808aec
|
|||
|
d0383b0d4e
|
|||
|
da27216fc1
|
|||
|
f7d0018314
|
|||
|
f05b96e0bf
|
|||
|
522fac7b01
|
|||
|
9730c5b129
|
|||
|
8cda888a87
|
|||
|
cdd1dca030
|
|||
|
1a88883bad
|
|||
|
2fa26ef552
|
|||
|
9d380ad300
|
|||
|
1b29e8d833
|
|||
|
b3a2400bc2
|
|||
|
573e70a8d2
|
|||
|
6457674de7
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,7 +9,7 @@ public/*
|
|||||||
.hugo_build.lock
|
.hugo_build.lock
|
||||||
deploy
|
deploy
|
||||||
node_modules
|
node_modules
|
||||||
env
|
.env
|
||||||
Package.resolved
|
Package.resolved
|
||||||
|
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
|
|||||||
52
Caddyfile
Normal file
52
Caddyfile
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
# Configure caddy-security.
|
||||||
|
order authenticate before respond
|
||||||
|
|
||||||
|
security {
|
||||||
|
oauth identity provider generic {
|
||||||
|
delay_start 3
|
||||||
|
realm generic
|
||||||
|
driver generic
|
||||||
|
client_id {env.OAUTH_CLIENT_ID} # Replace with your own client ID
|
||||||
|
client_secret {env.OAUTH_CLIENT_SECRET} # Replace with your own client secret
|
||||||
|
scopes openid email profile
|
||||||
|
base_auth_url http://pocket-id
|
||||||
|
metadata_url http://pocket-id/.well-known/openid-configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
authentication portal myportal {
|
||||||
|
crypto default token lifetime 3600 # Seconds until you have to re-authenticate
|
||||||
|
enable identity provider generic
|
||||||
|
cookie insecure off # Set to "on" if you're not using HTTPS
|
||||||
|
|
||||||
|
transform user {
|
||||||
|
match realm generic
|
||||||
|
action add role user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authorization policy mypolicy {
|
||||||
|
set auth url /caddy-security/oauth2/generic
|
||||||
|
allow roles user
|
||||||
|
inject headers with claims
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
@auth {
|
||||||
|
path /caddy-security/*
|
||||||
|
}
|
||||||
|
|
||||||
|
route @auth {
|
||||||
|
authenticate with myportal
|
||||||
|
}
|
||||||
|
|
||||||
|
route /* {
|
||||||
|
authorize with mypolicy
|
||||||
|
root * /app
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
13
Dockerfile
13
Dockerfile
@@ -30,19 +30,26 @@ WORKDIR /build
|
|||||||
RUN npm install -g pnpm@latest-10
|
RUN npm install -g pnpm@latest-10
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=build /build/deploy ./deploy
|
||||||
|
|
||||||
RUN pnpm install && pnpm run css-build
|
RUN pnpm install && pnpm run css-build
|
||||||
|
RUN npx -y pagefind --site deploy
|
||||||
|
|
||||||
# ==================================================
|
# ==================================================
|
||||||
# Run Image
|
# Run Image
|
||||||
# ==================================================
|
# ==================================================
|
||||||
FROM caddy:2.9.1-alpine
|
#FROM caddy:2.9.1-alpine
|
||||||
|
FROM ghcr.io/authcrunch/authcrunch:latest
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /build/deploy .
|
COPY --from=css /build/deploy .
|
||||||
COPY --from=css /build/content/static/output.css ./static/output.css
|
COPY --from=css /build/content/static/output.css ./static/output.css
|
||||||
|
COPY --from=css /build/deploy/pagefind ./pagefind
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
RUN /usr/bin/caddy fmt --overwrite /etc/caddy/Caddyfile
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["/usr/bin/caddy", "file-server", "--root", "/app", "--listen", ":80"]
|
CMD ["/usr/bin/caddy", "run", "--config", "/etc/caddy/Caddyfile"]
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -1,32 +1,10 @@
|
|||||||
---
|
|
||||||
date: 2025-4-02
|
|
||||||
updated: 2025-04-02
|
|
||||||
author: "Michael Housh"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Homelab Documentation
|
# Homelab Documentation
|
||||||
|
|
||||||
Documentation about how the homelab is setup.
|
A static website that holds documentation related to the company.
|
||||||
|
|
||||||
## Containers
|
## Usage
|
||||||
|
|
||||||
Services run inside of docker containers that are spread between several
|
1. Install dependencies `pnpm install`
|
||||||
servers, which run them. The containers are deployed using a container
|
1. Run & watch for css changes, this requires two terminal processes.
|
||||||
orchestrator, currently using [komo](https://komo.housh.dev).
|
1. `pnpm run css-watch`
|
||||||
|
1. `just run`
|
||||||
All of the services have a corresponding repository for their configuration that
|
|
||||||
is hosted on an [internal git server](https://git.housh.dev/homelab). The
|
|
||||||
configuration will consist of a docker compose file (generally named
|
|
||||||
`compose.yaml`). There is often an `example.env` file for the service, these are
|
|
||||||
examples for documentation and variable naming purposes. The environment
|
|
||||||
variables themselves are setup in the container orchestrator for the service.
|
|
||||||
|
|
||||||
### Container orchestrator
|
|
||||||
|
|
||||||
The container orchestrator is where the actual configuration for the service is
|
|
||||||
done. It configures which physical server that the service will run on, it is
|
|
||||||
responsible for pulling the proper container images, pulls the configuration /
|
|
||||||
`compoose.yaml` file from the repository, sets up environment variables, and
|
|
||||||
deploys the service onto the server.
|
|
||||||
|
|
||||||
It also has some features for monitoring CPU and Memory usage of the servers.
|
|
||||||
|
|||||||
@@ -54,6 +54,40 @@ extension Item where M == ArticleMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Item<ArticleMetadata> {
|
||||||
|
|
||||||
|
/// Iterate through the aritcles, getting all the years that articles
|
||||||
|
/// have been written or updated.
|
||||||
|
func years() -> Set<String> {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "yyyy"
|
||||||
|
return reduce(into: Set()) { set, item in
|
||||||
|
let date = dateFormatter.string(from: item.getDate())
|
||||||
|
set.insert(date)
|
||||||
|
if let updatedDate = item.getUpdatedDate() {
|
||||||
|
set.insert(dateFormatter.string(from: updatedDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate through the articles and get the unique tags.
|
||||||
|
func uniqueTags() -> Set<String> {
|
||||||
|
reduce(into: Set()) { set, item in
|
||||||
|
for tag in item.metadata.tags {
|
||||||
|
set.insert(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate through the articles and get the unique tags along with the count of
|
||||||
|
/// how many times the tag is used.
|
||||||
|
func uniqueTagsWithCount() -> [(String, Int)] {
|
||||||
|
let tags = flatMap { $0.metadata.tags }
|
||||||
|
let tagsWithCounts = tags.reduce(into: [:]) { $0[$1, default: 0] += 1 }
|
||||||
|
return tagsWithCounts.sorted { $0.1 > $1.1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Most of these are taken from https://github.com/loopwerk/loopwerk.io
|
// NOTE: Most of these are taken from https://github.com/loopwerk/loopwerk.io
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|||||||
@@ -72,5 +72,18 @@ struct Run {
|
|||||||
// All the remaining files that were not parsed to markdown, so for example images, raw html files and css,
|
// All the remaining files that were not parsed to markdown, so for example images, raw html files and css,
|
||||||
// are copied as-is to the output folder.
|
// are copied as-is to the output folder.
|
||||||
.staticFiles()
|
.staticFiles()
|
||||||
|
|
||||||
|
// Run saga again on articles, to collect search index.
|
||||||
|
// try await Saga(input: "content", output: "deploy")
|
||||||
|
// .register(
|
||||||
|
// folder: "articles",
|
||||||
|
// metadata: ArticleMetadata.self,
|
||||||
|
// readers: [.plainReader],
|
||||||
|
// filter: \.public,
|
||||||
|
// writers: [
|
||||||
|
// .listWriter(renderJson, output: "../search.json")
|
||||||
|
// ]
|
||||||
|
// )
|
||||||
|
// .run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
/// This is used to render base layouts appropriately for the given section.
|
/// This is used to render base layouts appropriately for the given section.
|
||||||
enum Section: String {
|
enum Section: String {
|
||||||
case home
|
case home
|
||||||
case about
|
|
||||||
case articles
|
case articles
|
||||||
case notFound
|
case notFound
|
||||||
}
|
}
|
||||||
|
|||||||
110
Sources/Docs/Templates/ArticleGrid.swift
Normal file
110
Sources/Docs/Templates/ArticleGrid.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
import HTML
|
||||||
|
import Saga
|
||||||
|
|
||||||
|
/// Displays lists of articles sectioned by a key.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
struct ArticleGrid: NodeConvertible {
|
||||||
|
|
||||||
|
let articles: [(key: String, value: [Item<ArticleMetadata>])]
|
||||||
|
let canocicalURL: String
|
||||||
|
let title: String
|
||||||
|
let rssLink: String
|
||||||
|
let extraHeader: NodeConvertible
|
||||||
|
let renderArticle: (Item<ArticleMetadata>) -> Node
|
||||||
|
let header: (String) -> Node
|
||||||
|
|
||||||
|
init(
|
||||||
|
articles: [(key: String, value: [Item<ArticleMetadata>])],
|
||||||
|
canocicalURL: String,
|
||||||
|
title: String,
|
||||||
|
rssLink: String,
|
||||||
|
extraHeader: any NodeConvertible = Node.fragment([]),
|
||||||
|
renderArticle: @escaping (Item<ArticleMetadata>) -> Node = { renderArticleForGrid(article: $0, border: false) },
|
||||||
|
header: @escaping (String) -> Node
|
||||||
|
) {
|
||||||
|
self.articles = articles
|
||||||
|
self.canocicalURL = canocicalURL
|
||||||
|
self.title = title
|
||||||
|
self.rssLink = rssLink
|
||||||
|
self.extraHeader = extraHeader
|
||||||
|
self.renderArticle = renderArticle
|
||||||
|
self.header = header
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allItems: [Item<ArticleMetadata>] {
|
||||||
|
articles.reduce(into: [Item<ArticleMetadata>]()) { $0 += $1.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortedYears: [String] {
|
||||||
|
allItems.years().sorted { $0 > $1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortedTags: [(String, Int)] {
|
||||||
|
allItems.uniqueTagsWithCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarLink(
|
||||||
|
_ label: String,
|
||||||
|
href: String
|
||||||
|
) -> Node {
|
||||||
|
a(
|
||||||
|
class: "text-slate-300 font-semibold [&:hover]:text-slate-200",
|
||||||
|
href: href
|
||||||
|
) {
|
||||||
|
div(class: "flex w-full p-2 [&:hover]:border-b border-orange-400\(href == canocicalURL ? " active" : "")") {
|
||||||
|
span(class: "mx-8") { label }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asNode() -> Node {
|
||||||
|
baseLayout(
|
||||||
|
canocicalURL: canocicalURL,
|
||||||
|
section: .articles,
|
||||||
|
title: title,
|
||||||
|
rssLink: rssLink,
|
||||||
|
extraHeader: extraHeader
|
||||||
|
) {
|
||||||
|
div(class: "grid grid-cols-4") {
|
||||||
|
// Sidebar
|
||||||
|
div(class: "overflow-auto border-r border-slate-200") {
|
||||||
|
section(class: "pt-2") {
|
||||||
|
sidebarLink("All Articles", href: "/articles/")
|
||||||
|
}
|
||||||
|
// Years
|
||||||
|
section(class: "pt-2") {
|
||||||
|
div(class: "flex ps-2") {
|
||||||
|
span(class: "mt-2 ps-2 font-extrabold text-slate-400") { "YEARS" }
|
||||||
|
}
|
||||||
|
sortedYears.map { year in
|
||||||
|
sidebarLink(year, href: "/articles/\(year)/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tags
|
||||||
|
section(class: "pt-2") {
|
||||||
|
div(class: "flex ps-2 pt-2") {
|
||||||
|
span(class: "mt-2 ps-2 font-extrabold text-slate-400") { "TAGS" }
|
||||||
|
}
|
||||||
|
sortedTags.map { tag, count in
|
||||||
|
sidebarLink("\(tag) (\(count))", href: "/articles/tag/\(tag)/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles
|
||||||
|
div(class: "col-span-3") {
|
||||||
|
articles.map { key, articles in
|
||||||
|
section {
|
||||||
|
header(key)
|
||||||
|
div(class: "grid gap-10 mx-6 mb-16") {
|
||||||
|
articles.map(renderArticle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,16 +20,26 @@ func baseLayout(
|
|||||||
return [
|
return [
|
||||||
.documentType("html"),
|
.documentType("html"),
|
||||||
html(lang: "en-US") {
|
html(lang: "en-US") {
|
||||||
generateHeader(pageTitle, extraHeader)
|
generateHead(pageTitle, extraHeader)
|
||||||
body(class: "text-white text-lg pb-5 font-avenir \(section.rawValue)") {
|
body(class: "text-white text-lg font-avenir \(section.rawValue)") {
|
||||||
siteHeader(section)
|
siteHeader(section)
|
||||||
|
|
||||||
div(class: "container") {
|
// mx-10
|
||||||
|
div(class: "mb-auto") {
|
||||||
children()
|
children()
|
||||||
}
|
}
|
||||||
|
if section == .articles {
|
||||||
footer(rssLink)
|
footer(rssLink)
|
||||||
}
|
}
|
||||||
|
// NOTE: These need to stay at / near bottom of page, so that icons are
|
||||||
|
// generated properly.
|
||||||
|
script(src: "https://unpkg.com/lucide@latest")
|
||||||
|
Node.raw("""
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,24 +56,16 @@ private func siteHeader(_ section: Section) -> Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nav(class: "menu") {
|
|
||||||
ul(class: "flex flex-wrap gap-x-2 lg:gap-x-5") {
|
// TODO: Explore search being hidden / triggered by a button and hover above
|
||||||
li {
|
// the page content.
|
||||||
a(class: section == .articles ? "active" : "", href: "/articles/") { "Articles" }
|
div(class: "font-avenir w-full p-4 px-8", id: "search") {}
|
||||||
}
|
div(class: "mt-2 mb-0 w-full border-b border-slate-200")
|
||||||
li {
|
|
||||||
a(href: "https://uptime.housh.dev/status/housh-dev", rel: "nofollow", target: "_blank") { "Server-Monitor" }
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
a(class: section == .about ? "active" : "", href: "/about.html") { "About" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func footer(_ rssLink: String) -> Node {
|
private func footer(_ rssLink: String) -> Node {
|
||||||
div(class: "site-footer text-slate-400 border-t border-light text-center pt-2 text-sm") {
|
div(class: "text-slate-400 border-t border-light text-center pt-2 text-sm") {
|
||||||
div {
|
div {
|
||||||
"Copyright © Michael Housh \(Date().description.prefix(4))."
|
"Copyright © Michael Housh \(Date().description.prefix(4))."
|
||||||
}
|
}
|
||||||
@@ -78,19 +80,16 @@ private func footer(_ rssLink: String) -> Node {
|
|||||||
"("
|
"("
|
||||||
%a(
|
%a(
|
||||||
class: "[&:hover]:border-b border-green-400",
|
class: "[&:hover]:border-b border-green-400",
|
||||||
href: "https://github.com/m-housh/mhoush.com",
|
href: "https://git.housh.dev/homelab/docs",
|
||||||
rel: "nofollow",
|
rel: "nofollow",
|
||||||
target: "_blank"
|
target: "_blank"
|
||||||
) { "source" }
|
) { "source" }
|
||||||
%")."
|
%")."
|
||||||
}
|
}
|
||||||
script(src: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js")
|
|
||||||
script(src: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/keep-markup/prism-keep-markup.min.js")
|
|
||||||
script(src: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func generateHeader(_ pageTitle: String, _ extraHeader: NodeConvertible) -> Node {
|
private func generateHead(_ pageTitle: String, _ extraHeader: NodeConvertible) -> Node {
|
||||||
head {
|
head {
|
||||||
meta(charset: "utf-8")
|
meta(charset: "utf-8")
|
||||||
meta(content: "#0e1112", name: "theme-color", customAttributes: ["media": "(prefers-color-scheme: dark)"])
|
meta(content: "#0e1112", name: "theme-color", customAttributes: ["media": "(prefers-color-scheme: dark)"])
|
||||||
@@ -122,11 +121,20 @@ private func generateHeader(_ pageTitle: String, _ extraHeader: NodeConvertible)
|
|||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="/static/ms-icon-144x144.png">
|
<meta name="msapplication-TileImage" content="/static/ms-icon-144x144.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<script defer data-domain="housh.dev" src="https://plausible.housh.dev/js/script.outbound-links.js"></script>
|
||||||
""")
|
""")
|
||||||
link(href: "/static/output.css", rel: "stylesheet")
|
link(href: "/static/output.css", rel: "stylesheet")
|
||||||
link(href: "/static/style.css", rel: "stylesheet")
|
|
||||||
link(href: "/articles/feed.xml", rel: "alternate", title: SiteMetadata.name, type: "application/rss+xml")
|
link(href: "/articles/feed.xml", rel: "alternate", title: SiteMetadata.name, type: "application/rss+xml")
|
||||||
extraHeader
|
extraHeader
|
||||||
script(src: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js")
|
Node.raw("""
|
||||||
|
<script src="/pagefind/pagefind-ui.js"></script>
|
||||||
|
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
window.addEventListener('DOMContentLoaded', (event) => {
|
||||||
|
new PagefindUI({ element: "#search", showSubResults: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
link(href: "/static/style.css", rel: "stylesheet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,19 +40,7 @@ func generateHeader(
|
|||||||
meta(content: "1014", name: "og:image:width"),
|
meta(content: "1014", name: "og:image:width"),
|
||||||
meta(content: "530", name: "og:image:height"),
|
meta(content: "530", name: "og:image:height"),
|
||||||
script(crossorigin: "anonymous", src: "https://kit.fontawesome.com/f209982030.js"),
|
script(crossorigin: "anonymous", src: "https://kit.fontawesome.com/f209982030.js"),
|
||||||
Node.raw("""
|
script(src: "https://cdn.jsdelivr.net/npm/minisearch@7.1.2/dist/umd/index.min.js")
|
||||||
<script>
|
|
||||||
MathJax = {
|
|
||||||
tex: {
|
|
||||||
inlineMath: [['$', '$']]
|
|
||||||
},
|
|
||||||
svg: {
|
|
||||||
fontCache: 'global'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
"""),
|
|
||||||
script(defer: true, src: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js")
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
Sources/Docs/Templates/HomeLink.swift
Normal file
75
Sources/Docs/Templates/HomeLink.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import HTML
|
||||||
|
|
||||||
|
/// Represents homepage link configuration.
|
||||||
|
struct HomeLink {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
let href: String
|
||||||
|
let linkType: LinkType
|
||||||
|
|
||||||
|
enum LinkType {
|
||||||
|
case `internal`
|
||||||
|
case external
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeLink {
|
||||||
|
/// Create an internal link (opens in the same tab).
|
||||||
|
static func `internal`(
|
||||||
|
_ title: String,
|
||||||
|
icon: String,
|
||||||
|
href: String,
|
||||||
|
description: String
|
||||||
|
) -> Self {
|
||||||
|
self.init(icon: icon, title: title, description: description, href: href, linkType: .internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an external link (opens in a different tab).
|
||||||
|
static func external(
|
||||||
|
_ title: String,
|
||||||
|
icon: String,
|
||||||
|
href: String,
|
||||||
|
description: String
|
||||||
|
) -> Self {
|
||||||
|
self.init(icon: icon, title: title, description: description, href: href, linkType: .external)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeLink: NodeConvertible {
|
||||||
|
|
||||||
|
func asNode() -> Node {
|
||||||
|
switch linkType {
|
||||||
|
case .internal: return internalLink()
|
||||||
|
case .external: return externalLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func internalLink() -> Node {
|
||||||
|
a(
|
||||||
|
class: "bg-orange-400 border-2 border-green-600 p-4 rounded-lg [&:hover]:bg-orange-500",
|
||||||
|
href: href
|
||||||
|
) {
|
||||||
|
div(class: "flex text-3xl") {
|
||||||
|
i(class: "mt-1", customAttributes: ["data-lucide": icon])
|
||||||
|
span(class: "ps-2") { title }
|
||||||
|
}
|
||||||
|
span(class: "text-sm") { description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func externalLink() -> Node {
|
||||||
|
a(
|
||||||
|
class: "bg-orange-400 border-2 border-green-600 p-4 rounded-lg [&:hover]:bg-orange-500",
|
||||||
|
href: href,
|
||||||
|
rel: "nofollow",
|
||||||
|
target: "_blank'"
|
||||||
|
) {
|
||||||
|
div(class: "flex text-3xl") {
|
||||||
|
i(class: "mt-1", customAttributes: ["data-lucide": icon])
|
||||||
|
span(class: "ps-2") { title }
|
||||||
|
}
|
||||||
|
span(class: "text-sm") { description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,19 @@ func tagPrefix(index: Int, totalTags: Int) -> Node {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderArticleInfo(_ article: Item<ArticleMetadata>) -> Node {
|
enum RenderArticleInfoContext {
|
||||||
div(class: "text-slate-400 gray-links text-sm mb-8") {
|
case article
|
||||||
|
case preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderArticleInfo(_ article: Item<ArticleMetadata>, context: RenderArticleInfoContext = .article) -> Node {
|
||||||
|
div(class: "text-slate-400 text-sm mb-8\(context == .preview ? " -mt-2" : "")") {
|
||||||
span(class: "border-r border-gray pr-2 mr-2") {
|
span(class: "border-r border-gray pr-2 mr-2") {
|
||||||
article.getDate().formatted("MMMM dd, yyyy")
|
article.getDate().formatted("MMMM dd, yyyy")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let updated = article.getUpdatedDate() {
|
if let updated = article.getUpdatedDate() {
|
||||||
span(class: "border-r border-gray pr-2 mr-2") {
|
span(class: "border-r border-slate-400 pr-2 mr-2") {
|
||||||
"Updated: \(updated.formatted("MMMM dd, yyyy"))"
|
"Updated: \(updated.formatted("MMMM dd, yyyy"))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,9 +35,6 @@ func renderArticleInfo(_ article: Item<ArticleMetadata>) -> Node {
|
|||||||
article.metadata.tags.sorted().enumerated().map { index, tag in
|
article.metadata.tags.sorted().enumerated().map { index, tag in
|
||||||
Node.fragment([
|
Node.fragment([
|
||||||
%tagPrefix(index: index, totalTags: article.metadata.tags.count),
|
%tagPrefix(index: index, totalTags: article.metadata.tags.count),
|
||||||
Node.raw("""
|
|
||||||
<i class="fa fa-home"></i>
|
|
||||||
"""),
|
|
||||||
%a(class: "text-orange-400 [&:hover]:border-b border-green-400", href: "/articles/tag/\(tag.slugified)/") {
|
%a(class: "text-orange-400 [&:hover]:border-b border-green-400", href: "/articles/tag/\(tag.slugified)/") {
|
||||||
tag
|
tag
|
||||||
}
|
}
|
||||||
@@ -100,22 +102,43 @@ func renderArticle(context: ItemRenderingContext<ArticleMetadata>) -> Node {
|
|||||||
title: context.item.title,
|
title: context.item.title,
|
||||||
extraHeader: generateHeader(.article(context.item))
|
extraHeader: generateHeader(.article(context.item))
|
||||||
) {
|
) {
|
||||||
article(class: "pt-8") {
|
article(class: "pt-8 mx-10") {
|
||||||
|
div(class: "relative") {
|
||||||
|
a(class: "absolute top-4 right-4", href: "/articles", id: "close") {
|
||||||
|
i(customAttributes: ["data-lucide": "x"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(class: "bg-slate-800 py-10") {
|
||||||
|
div(class: "mx-10") {
|
||||||
h1 { context.item.title }
|
h1 { context.item.title }
|
||||||
div {
|
div {
|
||||||
renderArticleInfo(context.item)
|
renderArticleInfo(context.item)
|
||||||
}
|
}
|
||||||
|
// Only index the body of the articles for search.
|
||||||
|
div(customAttributes: ["data-pagefind-body": ""]) {
|
||||||
Node.raw(context.item.body)
|
Node.raw(context.item.body)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div(class: "border-t border-light pt-8 mt-16") {
|
div(class: "border-t border-light p-10 mt-16", id: "recents") {
|
||||||
div(class: "grid lg:grid-cols-2") {
|
div(class: "grid lg:grid-cols-2") {
|
||||||
h4(class: "text-3xl text-amber-500 font-extrabold mb-8") { otherArticles.title }
|
h4(class: "text-3xl text-amber-500 font-extrabold mb-8") { otherArticles.title }
|
||||||
if let tag = otherArticles.tag {
|
if let tag = otherArticles.tag {
|
||||||
a(href: "/articles/tag/\(tag)") {
|
a(href: "/articles/tag/\(tag)") {
|
||||||
div(class: " [&:hover]:border-b border-orange px-5 flex flex-row gap-5") {
|
div(class: " [&:hover]:border-b border-green-500 px-5 flex flex-row gap-5") {
|
||||||
img(src: "/static/img/tag.svg", width: "40")
|
img(class: "-mt-2", src: "/static/img/tag.svg", width: "40")
|
||||||
span(class: "text-4xl font-extrabold text-orange") { tag }
|
div(class: "block") {
|
||||||
|
div(class: "block") {
|
||||||
|
span(class: "mt-2 text-4xl font-extrabold text-orange") { tag }
|
||||||
|
}
|
||||||
|
div(class: "block") {
|
||||||
|
span(class: "text-sm text-orange-400") {
|
||||||
|
"View related articles with this tag."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,12 +160,14 @@ func renderArticle(context: ItemRenderingContext<ArticleMetadata>) -> Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderArticleForGrid(article: Item<ArticleMetadata>) -> Node {
|
func renderArticleForGrid(article: Item<ArticleMetadata>, border: Bool = true) -> Node {
|
||||||
|
// bg-slate-800
|
||||||
|
div(class: "p-4\(border ? " border border-slate-400 rounded-lg" : "")") {
|
||||||
section {
|
section {
|
||||||
h3(class: "post-title text-2xl font-bold mb-2") {
|
h3(class: "text-2xl font-bold") {
|
||||||
a(class: "[&:hover]:border-b border-orange-400", href: article.url) { article.title }
|
a(class: "[&:hover]:border-b border-green-500", href: article.url) { article.title }
|
||||||
}
|
}
|
||||||
renderArticleInfo(article)
|
renderArticleInfo(article, context: .preview)
|
||||||
p {
|
p {
|
||||||
a(href: article.url) {
|
a(href: article.url) {
|
||||||
div {
|
div {
|
||||||
@@ -151,4 +176,5 @@ func renderArticleForGrid(article: Item<ArticleMetadata>) -> Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,24 @@ import Foundation
|
|||||||
import HTML
|
import HTML
|
||||||
import Saga
|
import Saga
|
||||||
|
|
||||||
func uniqueTagsWithCount(_ articles: [Item<ArticleMetadata>]) -> [(String, Int)] {
|
func renderArticles(context: ItemsRenderingContext<ArticleMetadata>) -> NodeConvertible {
|
||||||
let tags = articles.flatMap { $0.metadata.tags }
|
|
||||||
let tagsWithCounts = tags.reduce(into: [:]) { $0[$1, default: 0] += 1 }
|
|
||||||
return tagsWithCounts.sorted { $0.1 > $1.1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderArticles(context: ItemsRenderingContext<ArticleMetadata>) -> Node {
|
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy"
|
dateFormatter.dateFormat = "yyyy"
|
||||||
|
|
||||||
let articlesPerYear = Dictionary(grouping: context.items, by: { dateFormatter.string(from: $0.date) })
|
let articlesPerYear = Dictionary(grouping: context.items, by: { dateFormatter.string(from: $0.date) })
|
||||||
let sortedByYearDescending = articlesPerYear.sorted { $0.key > $1.key }
|
let sortedByYearDescending = articlesPerYear.sorted { $0.key > $1.key }
|
||||||
|
|
||||||
return baseLayout(canocicalURL: "/articles/", section: .articles, title: "Articles", rssLink: "", extraHeader: "") {
|
return baseRenderArticles(
|
||||||
// TODO: Add list of tags here that can be navigated to.
|
sortedByYearDescending,
|
||||||
sortedByYearDescending.map { year, articles in
|
canocicalURL: "/articles/",
|
||||||
div {
|
title: "Articles",
|
||||||
div(class: "border-b border-light flex flex-row gap-4 mb-12") {
|
rssLink: "",
|
||||||
img(src: "/static/img/calendar.svg", width: "40")
|
extraHeader: "",
|
||||||
h1(class: "text-4xl font-extrabold pt-3") { year }
|
label: yearHeader(_:)
|
||||||
}
|
)
|
||||||
|
|
||||||
div(class: "grid gap-10 mb-16") {
|
|
||||||
articles.map { renderArticleForGrid(article: $0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTag<T>(context: PartitionedRenderingContext<T, ArticleMetadata>) -> Node {
|
func renderTag<T>(context: PartitionedRenderingContext<T, ArticleMetadata>) -> NodeConvertible {
|
||||||
let extraHeader = link(
|
let extraHeader = link(
|
||||||
href: "/articles/tag/\(context.key.slugified)/feed.xml",
|
href: "/articles/tag/\(context.key.slugified)/feed.xml",
|
||||||
rel: "alternate",
|
rel: "alternate",
|
||||||
@@ -41,54 +28,86 @@ func renderTag<T>(context: PartitionedRenderingContext<T, ArticleMetadata>) -> N
|
|||||||
)
|
)
|
||||||
|
|
||||||
return baseRenderArticles(
|
return baseRenderArticles(
|
||||||
context.items,
|
(context.key.slugified, context.items),
|
||||||
canocicalURL: "/articles/tag/\(context.key.slugified)/",
|
canocicalURL: "/articles/tag/\(context.key.slugified)/",
|
||||||
title: "Articles in \(context.key)",
|
title: "Articles in \(context.key)",
|
||||||
rssLink: "tag/\(context.key.slugified)/",
|
rssLink: "tag/\(context.key.slugified)/",
|
||||||
extraHeader: extraHeader
|
extraHeader: extraHeader
|
||||||
) {
|
) { tag in
|
||||||
div(class: "border-b border-light grid lg:grid-cols-2 mb-12") {
|
div(class: "mb-12 px-6 pt-6") {
|
||||||
a(href: "/articles") {
|
a(href: "/articles") {
|
||||||
div(class: "flex flex-row gap-2") {
|
div(class: "flex flex-row gap-4") {
|
||||||
img(src: "/static/img/tag.svg", width: "40")
|
img(src: "/static/img/tag.svg", width: "40")
|
||||||
h1(class: "text-4xl font-extrabold") { "\(context.key)" }
|
h1(class: "text-4xl font-extrabold") { tag }
|
||||||
h1(class: "[&:hover]:border-b border-green text-2xl font-extrabold text-orange px-4") { "«" }
|
h1(class: "text-2xl font-extrabold") { "«" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderYear<T>(context: PartitionedRenderingContext<T, ArticleMetadata>) -> Node {
|
func renderYear<T>(context: PartitionedRenderingContext<T, ArticleMetadata>) -> NodeConvertible {
|
||||||
baseRenderArticles(context.items, canocicalURL: "/articles/\(context.key)/", title: "Articles in \(context.key)")
|
baseRenderArticles(
|
||||||
|
(context.key.slugified, context.items),
|
||||||
|
canocicalURL: "/articles/\(context.key)/",
|
||||||
|
title: "Articles in \(context.key)",
|
||||||
|
label: { year in
|
||||||
|
div(class: "pt-6 w-full") {
|
||||||
|
a(href: "/articles/") {
|
||||||
|
div(class: "px-6 flex flex-row gap-4 ") {
|
||||||
|
img(src: "/static/img/calendar.svg", width: "40")
|
||||||
|
h1(class: "text-4xl font-extrabold pt-3") { year }
|
||||||
|
h1(class: "text-2xl font-extrabold") { "«" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func yearHeader(_ year: String) -> Node {
|
||||||
|
div(class: "pt-6 w-full") {
|
||||||
|
div(class: "px-6 flex flex-row gap-4 ") {
|
||||||
|
img(src: "/static/img/calendar.svg", width: "40")
|
||||||
|
h1(class: "text-4xl font-extrabold pt-3") { year }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func baseRenderArticles(
|
private func baseRenderArticles(
|
||||||
_ articles: [Item<ArticleMetadata>],
|
_ articles: [(key: String, value: [Item<ArticleMetadata>])],
|
||||||
canocicalURL: String,
|
canocicalURL: String,
|
||||||
title pageTitle: String,
|
title pageTitle: String,
|
||||||
rssLink: String = "",
|
rssLink: String = "",
|
||||||
extraHeader: NodeConvertible = Node.fragment([]),
|
extraHeader: NodeConvertible = Node.fragment([]),
|
||||||
@NodeBuilder label: () -> Node = { Node.fragment([]) }
|
@NodeBuilder label: @escaping (String) -> Node = { _ in Node.fragment([]) }
|
||||||
) -> Node {
|
) -> NodeConvertible {
|
||||||
return baseLayout(
|
ArticleGrid(
|
||||||
|
articles: articles,
|
||||||
canocicalURL: canocicalURL,
|
canocicalURL: canocicalURL,
|
||||||
section: .articles,
|
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
rssLink: rssLink,
|
rssLink: rssLink,
|
||||||
extraHeader: extraHeader
|
extraHeader: extraHeader
|
||||||
) {
|
) { key in
|
||||||
label()
|
label(key)
|
||||||
articles.map { article in
|
}
|
||||||
section(class: "mb-10") {
|
}
|
||||||
h1(class: "post-title text-2xl font-bold mb-2") {
|
|
||||||
a(class: "[&:hover]:border-b border-orange", href: article.url) { article.title }
|
private func baseRenderArticles(
|
||||||
}
|
_ articles: (key: String, value: [Item<ArticleMetadata>]),
|
||||||
renderArticleInfo(article)
|
canocicalURL: String,
|
||||||
p(class: "mt-4") {
|
title pageTitle: String,
|
||||||
a(href: article.url) { article.summary }
|
rssLink: String = "",
|
||||||
}
|
extraHeader: NodeConvertible = Node.fragment([]),
|
||||||
}
|
@NodeBuilder label: @escaping (String) -> Node = { _ in Node.fragment([]) }
|
||||||
}
|
) -> NodeConvertible {
|
||||||
|
ArticleGrid(
|
||||||
|
articles: [articles],
|
||||||
|
canocicalURL: canocicalURL,
|
||||||
|
title: pageTitle,
|
||||||
|
rssLink: rssLink,
|
||||||
|
extraHeader: extraHeader
|
||||||
|
) { key in
|
||||||
|
label(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,71 @@ func renderPage(context: ItemRenderingContext<PageMetadata>) -> Node {
|
|||||||
|
|
||||||
func renderHome(body: String) -> Node {
|
func renderHome(body: String) -> Node {
|
||||||
div {
|
div {
|
||||||
div(class: "my-24 uppercase font-avenir text-[40px] leading-[1.25] font-thin text-center [&>h1>strong]:font-bold") {
|
div(class: "my-24 font-avenir leading-[1.25] font-thin text-center [&>h1>strong]:font-bold") {
|
||||||
Node.raw(body)
|
Node.raw(body)
|
||||||
}
|
}
|
||||||
|
div(class: "px-10") {
|
||||||
|
div(class: "bg-slate-800 p-10 rounded-lg border border-slate-400") {
|
||||||
|
h2 { "Quick Links" }
|
||||||
|
div(class: "grid lg:grid-cols-2 gap-6") {
|
||||||
|
HomeLink.internal(
|
||||||
|
"Articles",
|
||||||
|
icon: "newspaper",
|
||||||
|
href: "/articles/",
|
||||||
|
description: "Click here to view all articles."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Purchase Orders",
|
||||||
|
icon: "calculator",
|
||||||
|
href: "https://po.housh.dev",
|
||||||
|
description: "Purchase orders application."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Service Monitor",
|
||||||
|
icon: "heart-pulse",
|
||||||
|
href: "https://uptime.housh.dev/status/housh-dev",
|
||||||
|
description: "Server and services uptime status page."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Unifi Console",
|
||||||
|
icon: "earth",
|
||||||
|
href: "https://unifi.ui.com",
|
||||||
|
description: "Network management."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Excalidraw",
|
||||||
|
icon: "pen-tool",
|
||||||
|
href: "https://draw.housh.dev",
|
||||||
|
description: "A drawing utility that runs locally in your browser."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Gitea",
|
||||||
|
icon: "git-branch",
|
||||||
|
href: "https://git.housh.dev/explore/repos",
|
||||||
|
description: "Explore source code."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"Legacy Purchase Orders",
|
||||||
|
icon: "file-archive",
|
||||||
|
href: "https://legach-po.housh.dev",
|
||||||
|
description: "Legacy purchase order application (pre-2025)."
|
||||||
|
)
|
||||||
|
|
||||||
|
HomeLink.external(
|
||||||
|
"HVAC Toolbox",
|
||||||
|
icon: "hammer",
|
||||||
|
href: "https://hvac-toolbox.com",
|
||||||
|
description: "A collection of HVAC calculators."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
Sources/Docs/Templates/TagGrid.swift
Normal file
40
Sources/Docs/Templates/TagGrid.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import HTML
|
||||||
|
import Saga
|
||||||
|
|
||||||
|
// TODO: Currently not used, remove in the future.
|
||||||
|
struct TagGrid: NodeConvertible {
|
||||||
|
|
||||||
|
let items: [Item<ArticleMetadata>]
|
||||||
|
|
||||||
|
func asNode() -> Node {
|
||||||
|
div(class: "mt-1 bg-slate-950 rounded-ss-lg border-b border-slate-200") {
|
||||||
|
// Grid Header
|
||||||
|
div(class: "flex flex-row gap-4 px-4 pt-4") {
|
||||||
|
img(src: "/static/img/tag.svg", width: "40")
|
||||||
|
h1 { "Tags" }
|
||||||
|
}
|
||||||
|
div(class: "px-4 pb-8 -mt-2") {
|
||||||
|
span(class: "text-sm text-green-300") { "Click on a tag to view related articles." }
|
||||||
|
}
|
||||||
|
// Grid items.
|
||||||
|
div(class: "grid sm:grid-cols-2 lg:grid-cols-4 gap-4 px-6 pb-6") {
|
||||||
|
items.uniqueTagsWithCount().map { tag, count in
|
||||||
|
div {
|
||||||
|
a(class: "bg-slate-900 [&:hover]:bg-slate-800", href: "/articles/tag/\(tag)") {
|
||||||
|
div(class: "flex flex-row justify-between bg-slate-900 [&:hover]:bg-slate-800 py-2 px-4 border-2 border-orange-400 rounded-lg") {
|
||||||
|
div(class: "justify-items-start") {
|
||||||
|
span(class: "font-bold text-green-300") { tag }
|
||||||
|
div(class: "text-sm") {
|
||||||
|
span { "\(count) articles" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img(src: "/static/img/tag.svg", width: "30")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
compose.dev.yaml
Normal file
19
compose.dev.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
oauth2-proxy:
|
||||||
|
image: quay.io/oauth2-proxy/oauth2-proxy:latest
|
||||||
|
command: --config /oauth2-proxy/oauth2-proxy.cfg
|
||||||
|
volumes:
|
||||||
|
- ./oauth2-proxy:/oauth2-proxy
|
||||||
|
ports:
|
||||||
|
- 4180:4180
|
||||||
|
|
||||||
|
docs:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: docs
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- ${PORT:-8081}:80
|
||||||
|
depends_on:
|
||||||
|
- oauth2-proxy
|
||||||
@@ -3,6 +3,7 @@ services:
|
|||||||
image: git.housh.dev/homelab/docs:latest
|
image: git.housh.dev/homelab/docs:latest
|
||||||
container_name: docs
|
container_name: docs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- ${PORT:-8081}:80
|
- ${PORT:-8081}:80
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
section: about
|
|
||||||
---
|
|
||||||
|
|
||||||
# About
|
|
||||||
|
|
||||||
Internal documentation site for **Housh - The Home Energy Experts**
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
date: 2025-4-02
|
date: 2025-4-02
|
||||||
updated: 2025-4-03
|
updated: 2025-4-08
|
||||||
author: "Michael Housh"
|
author: "Michael Housh"
|
||||||
tags: network, infrastructure
|
tags: network, infrastructure
|
||||||
|
primaryTag: infrastructure
|
||||||
---
|
---
|
||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
@@ -10,9 +11,31 @@ tags: network, infrastructure
|
|||||||
All of the networking setup is done through [unifi](https://unifi.ui.com). The
|
All of the networking setup is done through [unifi](https://unifi.ui.com). The
|
||||||
network is segmented into several different networks to isolate communication.
|
network is segmented into several different networks to isolate communication.
|
||||||
|
|
||||||
|
> Note: If you are unable to connect to the unifi management console linked
|
||||||
|
> above or if the internet is down, you can connect directly with the management
|
||||||
|
> console at `http://192.168.1.1`.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
The network management console get's backed up automatically each week (Sundays
|
||||||
|
@2:30am), however you can manually backup the server by going to
|
||||||
|
`Settings -> Control Plane -> Backups`. This is where you can also restore from
|
||||||
|
a backup if needed.
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
An overview of the networks that are setup.
|
A brief overview of the networks that are setup, their uses, and why they are
|
||||||
|
needed.
|
||||||
|
|
||||||
|
| Network | VLAN ID | Subnet | Usable IP's |
|
||||||
|
| --------- | ------- | ---------------- | ----------- |
|
||||||
|
| Default | 1 | 192.168.1.0/24 | 249 |
|
||||||
|
| Main | 10 | 192.168.10.0/24 | 205 |
|
||||||
|
| Phones | 20 | 192.168.20.0/28 | 13 |
|
||||||
|
| IoT | 30 | 192.168.30.0/24 | 249 |
|
||||||
|
| housh.dev | 50 | 192.168.50.0/28 | 12 |
|
||||||
|
| Guest | 60 | 192.168.60.0/26 | 61 |
|
||||||
|
| Mangement | 254 | 192.168.254.0/24 | 249 |
|
||||||
|
|
||||||
### Default Network
|
### Default Network
|
||||||
|
|
||||||
@@ -21,8 +44,8 @@ unifi networking gear. It is also generally the network a new device will go if
|
|||||||
it is plugged into an ethernet cable / switch. For this reason this network is
|
it is plugged into an ethernet cable / switch. For this reason this network is
|
||||||
isolated from communicating with other networks.
|
isolated from communicating with other networks.
|
||||||
|
|
||||||
New devices that end up on this network should be configured to the appropriate
|
New devices that end up on this network should be configured / moved to the
|
||||||
network by a network administrator.
|
appropriate network by a network administrator.
|
||||||
|
|
||||||
### Management Network
|
### Management Network
|
||||||
|
|
||||||
@@ -33,26 +56,32 @@ someone gained access to the network.
|
|||||||
### Main Network
|
### Main Network
|
||||||
|
|
||||||
This is where the majority of "trusted" devices should be placed on the network,
|
This is where the majority of "trusted" devices should be placed on the network,
|
||||||
such as computers, phones, etc. This is also the network when people join the
|
such as computers, mobile phones, etc. This is also the network used when people
|
||||||
non-guest WiFi.
|
join the non-guest WiFi.
|
||||||
|
|
||||||
This network has the ability to communicate with most all other networks.
|
This network has the ability to communicate with most all other networks,
|
||||||
|
therefore only trusted devices should be allowed on this network.
|
||||||
|
|
||||||
### housh.dev Network
|
### housh.dev Network
|
||||||
|
|
||||||
This is the network where all the servers are placed. This network is primarily
|
This is the network where the majority of servers are placed. This network is
|
||||||
setup to allow "responses", but not initiate communication with other networks.
|
primarily setup to allow "responses", but not allowed to initiate communication
|
||||||
This is to help reduce the risk if one of the servers gets compromised, an
|
with other networks. This is to help reduce the risk if one of the servers gets
|
||||||
attacker should not easily be able to transition to another network.
|
compromised, an attacker should not easily be able to transition to another
|
||||||
|
network.
|
||||||
|
|
||||||
### Phones Network
|
### Phones Network
|
||||||
|
|
||||||
This is the network where all the VoIP phones are on. It is considered
|
This is the network where all the VoIP phones are on. It is considered
|
||||||
"untrusted" and should not be able to communicate with any other network.
|
"untrusted" and should not be able to communicate with any other network.
|
||||||
|
|
||||||
|
This is merely considered "untrusted" because there's no reason for anything on
|
||||||
|
this network to try and reach anything else. It should only handle phone
|
||||||
|
traffic.
|
||||||
|
|
||||||
### IoT Network
|
### IoT Network
|
||||||
|
|
||||||
This is the network where all IoT (internet of things) devices are. This is
|
This is the network where IoT (internet of things) devices are. This is
|
||||||
considered an "untrusted" network and communications with other networks are
|
considered an "untrusted" network and communications with other networks are
|
||||||
minimized to what is actually needed to work. This network is not able to
|
minimized to what is actually needed to work. This network is not able to
|
||||||
communicate with the internet, because these devices are made by so many
|
communicate with the internet, because these devices are made by so many
|
||||||
@@ -64,6 +93,24 @@ such as home-pods and apple-tv because there are network challenges with these
|
|||||||
devices operating properly when placed on the IoT network, such as airdrop and
|
devices operating properly when placed on the IoT network, such as airdrop and
|
||||||
screen casting (which may be resolved in the future).
|
screen casting (which may be resolved in the future).
|
||||||
|
|
||||||
|
### Guest Network
|
||||||
|
|
||||||
|
This is the network where guests are placed, it is considered "untrusted" and
|
||||||
|
should only be able to access the internet. Devices on this network are also not
|
||||||
|
able to communicate with other devices attached to the guest network.
|
||||||
|
|
||||||
|
## Wifi Networks
|
||||||
|
|
||||||
|
The following wifi networks are setup and broadcast via the access points. All
|
||||||
|
networks require a password to use. Ask Michael for passwords if you need them.
|
||||||
|
|
||||||
|
| Wifi SSID | Network |
|
||||||
|
| ------------------------ | ----------------------- |
|
||||||
|
| Center of Monroe | Main |
|
||||||
|
| Jarvis | IoT |
|
||||||
|
| Center of Monroe - Guest | Guest |
|
||||||
|
| Housh Home Energy | Main (VPN traffic only) |
|
||||||
|
|
||||||
## Firewall
|
## Firewall
|
||||||
|
|
||||||
The unifi management console is what handles firewall rules for the networks. It
|
The unifi management console is what handles firewall rules for the networks. It
|
||||||
@@ -74,6 +121,11 @@ is accessed via `Settings -> Security -> Firewall` on the management console.
|
|||||||
This is where settings are made to either allow or deny traffic on the networks
|
This is where settings are made to either allow or deny traffic on the networks
|
||||||
from communicating with other networks or the internet.
|
from communicating with other networks or the internet.
|
||||||
|
|
||||||
|
> Note: Be aware that making changes here may break things / render networks or
|
||||||
|
> services to be unusable. It is recommended to make a backup prior to making
|
||||||
|
> changes. One of the biggest things to _not_ do is block traffic from
|
||||||
|
> `Main -> Gateway`, most everything else done is recoverable.
|
||||||
|
|
||||||
## DNS
|
## DNS
|
||||||
|
|
||||||
DNS is what translates IP addresses to domain names (i.e.
|
DNS is what translates IP addresses to domain names (i.e.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
date: 2025-04-04
|
date: 2025-04-04
|
||||||
|
updated: 2025-04-08
|
||||||
tags: servers, infrastructure, homelab
|
tags: servers, infrastructure, homelab
|
||||||
|
primaryTag: infrastructure
|
||||||
---
|
---
|
||||||
|
|
||||||
# Servers
|
# Servers Overview
|
||||||
|
|
||||||
Documentation about how the servers are setup.
|
Documentation about how the servers are setup.
|
||||||
|
|
||||||
@@ -15,21 +17,28 @@ services based on that. Meaning services that I run primarily for personal items
|
|||||||
are running on servers that I own, while services that are supporting business
|
are running on servers that I own, while services that are supporting business
|
||||||
functionality run on the companies server.
|
functionality run on the companies server.
|
||||||
|
|
||||||
All of the servers run the services in `Docker Containers`.
|
All of the servers run the services in `Docker Containers`, which allows for
|
||||||
|
them to be isolated from the host system (server) and makes them more easily
|
||||||
|
portable between servers if needed.
|
||||||
|
|
||||||
There is also a `Raspberry-Pi` that runs `Home Assitant`, which is another one
|
There is also a `Raspberry-Pi` that runs `Home Assitant`, which is another one
|
||||||
of my personal devices.
|
of my personal devices.
|
||||||
|
|
||||||
| Server | DNS Name | IP Address |
|
| Server | DNS Name | IP Address |
|
||||||
| --------------------- | ---------------------- | ------------ |
|
| -------------- | ---------------------- | -------------- |
|
||||||
| mighty-mini (company) | mightymini.housh.dev | 192.168.50.6 |
|
| mighty-mini | mightymini.housh.dev | 192.168.50.6 |
|
||||||
| franken-mini (mine) | frankenmini.housh.dev | 192.168.50.5 |
|
| franken-mini | frankenmini.housh.dev | 192.168.50.5 |
|
||||||
| rogue-mini (mine) | roguemini.housh.dev | 192.168.50.4 |
|
| rogue-mini | roguemini.housh.dev | 192.168.50.4 |
|
||||||
| home-assistant (mine) | homeassitant.housh.dev | 192.168.30.5 |
|
| home-assistant | homeassitant.housh.dev | 192.168.30.5 |
|
||||||
|
| NAS | nas.housh.dev | 192.168.10.105 |
|
||||||
|
| Backup NAS | nas.hhe | 192.168.1.10 |
|
||||||
|
|
||||||
You can read more about the network setup
|
You can read more about the network setup
|
||||||
[here](https://docs.housh.dev/articles/2025/network/).
|
[here](https://docs.housh.dev/articles/2025/network/).
|
||||||
|
|
||||||
|
> Note: The backup NAS is used to backup our primary NAS, for now it is not easy
|
||||||
|
> to use, and will be used for camera / security footage in the future.
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|
||||||
Services run inside of docker containers that are spread between several
|
Services run inside of docker containers that are spread between several
|
||||||
@@ -43,7 +52,8 @@ is hosted on an [internal git server](https://git.housh.dev/homelab). The
|
|||||||
configuration will consist of a docker compose file (generally named
|
configuration will consist of a docker compose file (generally named
|
||||||
`compose.yaml`). There is often an `example.env` file for the service, these are
|
`compose.yaml`). There is often an `example.env` file for the service, these are
|
||||||
examples for documentation and variable naming purposes. The environment
|
examples for documentation and variable naming purposes. The environment
|
||||||
variables themselves are setup in the container orchestrator for the service.
|
variables themselves are setup in the container orchestrator for the service to
|
||||||
|
prevent sensitive data being "leaked".
|
||||||
|
|
||||||
### Container orchestrator
|
### Container orchestrator
|
||||||
|
|
||||||
@@ -98,7 +108,7 @@ access may be implemented in the future. If access is required outside of our
|
|||||||
network then using our VPN is required. The VPN setup is done automatically via
|
network then using our VPN is required. The VPN setup is done automatically via
|
||||||
unifi (our network router).
|
unifi (our network router).
|
||||||
|
|
||||||
`DNS` is what translates domain names to `IP` addresses, currently the public
|
`DNS` is what translates domain names to `IP addresses`, currently the public
|
||||||
DNS records are handled by cloudflare. Cloudflare is used to validate that we
|
DNS records are handled by cloudflare. Cloudflare is used to validate that we
|
||||||
own the `housh.dev` domain name in order for Let's Encrypt to issue free `TLS`
|
own the `housh.dev` domain name in order for Let's Encrypt to issue free `TLS`
|
||||||
certificates. TLS is used to encrypt traffic over the web (`https://`).
|
certificates. TLS is used to encrypt traffic over the web (`https://`).
|
||||||
@@ -106,4 +116,7 @@ certificates. TLS is used to encrypt traffic over the web (`https://`).
|
|||||||
Internal DNS records are setup in our unifi router `Settings -> Routing -> DNS`.
|
Internal DNS records are setup in our unifi router `Settings -> Routing -> DNS`.
|
||||||
The internal DNS is fairly simple and just needs to map to servers appropriately
|
The internal DNS is fairly simple and just needs to map to servers appropriately
|
||||||
(primarily just to the primary caddy instance, which then handles all the
|
(primarily just to the primary caddy instance, which then handles all the
|
||||||
routing to the individual service that is requested).
|
routing to the individual service that is requested). All devices that connect
|
||||||
|
to the network will be able to use the internal DNS to resolve host names
|
||||||
|
properly (meaning it all should just work automatically without any knowledge
|
||||||
|
from the user).
|
||||||
|
|||||||
74
content/articles/2025-04-07-PhoneSystem.md
Normal file
74
content/articles/2025-04-07-PhoneSystem.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
date: 2025-04-07
|
||||||
|
tags: phones, infrastructure, unifi
|
||||||
|
primaryTag: infrastructure
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phone System
|
||||||
|
|
||||||
|
This article is about the phone system at the office.
|
||||||
|
|
||||||
|
The phones are managed through the
|
||||||
|
[unifi management console](https://unifi.ui.com). You must have administrator
|
||||||
|
privileges in order to manage the phones.
|
||||||
|
|
||||||
|
Below is a list of our current phone numbers and the user's extensions.
|
||||||
|
|
||||||
|
| Name | Number | Extension |
|
||||||
|
| ------- | ------------ | --------- |
|
||||||
|
| Alicia | 513-252-2514 | 0100 |
|
||||||
|
| Andy | 513-463-1695 | 0102 |
|
||||||
|
| Michael | 513-953-4519 | 0101 |
|
||||||
|
|
||||||
|
[See network article for information about the phone network](/articles/2025/network/)
|
||||||
|
|
||||||
|
## Primary Numbers
|
||||||
|
|
||||||
|
Our primary numbers (`513-793-6374` & `800-793-6374`) forward to Alicia's
|
||||||
|
number, which is the primary entry to our phone system. The 800 number is manged
|
||||||
|
through [number-barn](https://www.numberbarn.com). The 513 number is managed
|
||||||
|
through Cincinnati-Bell.
|
||||||
|
|
||||||
|
## Unifi Talk Application
|
||||||
|
|
||||||
|
The unifi talk application is where all users, groups, and call handling is
|
||||||
|
managed.
|
||||||
|
|
||||||
|
### Assignments Section
|
||||||
|
|
||||||
|
The assignments section is where the devices, users, and groups are manged. The
|
||||||
|
devices section is where physical phones are assigned to users. The users
|
||||||
|
section is where user profiles and extensions are handled. The groups section is
|
||||||
|
where user are assigned to groups that are selected when client calls in. They
|
||||||
|
are used to direct calls to the appropriate people by ringing phones as a group
|
||||||
|
or sequentially.
|
||||||
|
|
||||||
|
### Engagement Section
|
||||||
|
|
||||||
|
The engagement section of the application is where the call handling is setup.
|
||||||
|
It is where the business hours are managed as well as setting up the menus a
|
||||||
|
client hears when they call into the main number.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Change Business Hours
|
||||||
|
|
||||||
|
One of the most common tasks that needs managed is changing the business hours
|
||||||
|
when there is a holiday during a weekday that we would typically be open. To
|
||||||
|
change the hours click on `Engagement -> Business Hours` and remove the day that
|
||||||
|
is a holiday.
|
||||||
|
|
||||||
|
> Note: When changing business hours for a holiday it is important to set them
|
||||||
|
> back once the holiday is finished, so create a reminder so that you remember
|
||||||
|
> to do that.
|
||||||
|
|
||||||
|
Once the day is removed then the `Non-Business Hours` flow will be used to route
|
||||||
|
calls.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Call Log
|
||||||
|
|
||||||
|
You can access history and phone recordings through the call log tab of the
|
||||||
|
unifi management console. The AI tab transcribes calls into text that can be
|
||||||
|
reviewed as well.
|
||||||
51
content/articles/2025-04-07-TimeMachine.md
Normal file
51
content/articles/2025-04-07-TimeMachine.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
date: 2025-04-07
|
||||||
|
tags: how-to, backups, nas
|
||||||
|
primaryTag: how-to
|
||||||
|
---
|
||||||
|
|
||||||
|
# Time Machine Backups
|
||||||
|
|
||||||
|
In this article we'll walk through how to setup time machine backups using the
|
||||||
|
on site NAS (network attached storage).
|
||||||
|
|
||||||
|
## NAS Access
|
||||||
|
|
||||||
|
You should have received an email when your account was setup on the NAS that
|
||||||
|
gives credentials to access the NAS, if you do not have access to that anymore
|
||||||
|
than let Michael know and he can send them again.
|
||||||
|
|
||||||
|
Once setup, this should mount the folders that you have access to automatically
|
||||||
|
when your computer is attached to the network and you have the
|
||||||
|
[unifi identity app installed](https://www.ui.com/download/app/identity-desktop).
|
||||||
|
|
||||||
|
### Manually Connecting to NAS
|
||||||
|
|
||||||
|
You can also manually mount NAS folders by using the `Finder` application if the
|
||||||
|
unifi identity application is not working for you.
|
||||||
|
|
||||||
|
1. Open the Finder application
|
||||||
|
1. Choose connect to server from `Go -> Connect to server...` (or type ⌘K)
|
||||||
|
1. In the server address field type: `smb://192.168.10.105`
|
||||||
|
1. Enter your credentials to login to the server (attain username and password
|
||||||
|
from Michael)
|
||||||
|
1. Choose the `Personal-Drive` folder
|
||||||
|
|
||||||
|
## Time Machine
|
||||||
|
|
||||||
|
After you are connected to the NAS you can then setup time machine backups for
|
||||||
|
your computer.
|
||||||
|
|
||||||
|
The time machine settings are found in the
|
||||||
|
`System Settings -> General -> Time Machine` section of your system settings
|
||||||
|
application.
|
||||||
|
|
||||||
|
1. Click the plus icon to add a new time machine backup location.
|
||||||
|
1. Select the drive named `Personal-Drive`
|
||||||
|
|
||||||
|
> Note: Any of the other drives that appear will not work, the drive that you
|
||||||
|
> select must be the Personal-Drive.
|
||||||
|
|
||||||
|
You should end up with something that looks similar to the image below.
|
||||||
|
|
||||||
|

|
||||||
40
content/articles/2025-04-08-LinkSharing.md
Normal file
40
content/articles/2025-04-08-LinkSharing.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
date: 2025-04-08
|
||||||
|
tags: how-to, nas
|
||||||
|
primaryTag: how-to
|
||||||
|
---
|
||||||
|
|
||||||
|
# Link Sharing
|
||||||
|
|
||||||
|
In this article, I'll share how you can share links from our internal NAS with
|
||||||
|
someone.
|
||||||
|
|
||||||
|
## 1. Open the Web Console
|
||||||
|
|
||||||
|
Sharing links is done through the web console of the NAS. You can access the web
|
||||||
|
console by clicking the `Your UniFI Drive` button in the `Unifi Identitiy`
|
||||||
|
application in your toolbar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 2. Select File to Share
|
||||||
|
|
||||||
|
Next, you will want to navigate to the file you would like to share and select
|
||||||
|
the three dots to the far right of the file and select `Share Link`.
|
||||||
|
|
||||||
|
This will open a menu that allows you to optionally set the expiration for the
|
||||||
|
link, a limit on how many times the file can be accessed, and a password
|
||||||
|
required to view the file. Once done you can click on `Create and Copy Link`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 3. Manage Shared Links
|
||||||
|
|
||||||
|
You can manage the shared link in the same way by navigating to the file you've
|
||||||
|
shared and selecting `Share Link`, as we did above. Or you can access from the
|
||||||
|
home dashboard by clicking `Manage Links` in the top right of the home page.
|
||||||
|
|
||||||
|
The `Manage Links` section gives you a list of all the files you've shared using
|
||||||
|
links. You can reconfigure the link there or delete it.
|
||||||
|
|
||||||
|

|
||||||
119
content/articles/2025-04-09-ServerManagementConsole.md
Normal file
119
content/articles/2025-04-09-ServerManagementConsole.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
date: 2025-04-09
|
||||||
|
tags: infrastructure, servers, homelab
|
||||||
|
primaryTag: infrastructure
|
||||||
|
---
|
||||||
|
|
||||||
|
# Server Management Console
|
||||||
|
|
||||||
|
This article I'll describe some steps to manage and / or trouble shoot the
|
||||||
|
servers.
|
||||||
|
|
||||||
|
## Management Console
|
||||||
|
|
||||||
|
The servers have a management console that is accessible from the internal
|
||||||
|
network. You will need to get the login name and password from Michael.
|
||||||
|
|
||||||
|
| Server | Link |
|
||||||
|
| ------------ | ---------------------------------------------------------------------- |
|
||||||
|
| mighty-mini | [console.mightymini.housh.dev](https://console.mightymini.housh.dev) |
|
||||||
|
| franken-mini | [console.frankenmini.housh.dev](https://console.frankenmini.housh.dev) |
|
||||||
|
| rogue-mini | [console.roguemini.housh.dev](https://console.roguemini.housh.dev) |
|
||||||
|
|
||||||
|
The management console allows you to update the server, check logs, and access a
|
||||||
|
terminal on the machine. If you are updating the server via the management
|
||||||
|
console, it is often required to reboot the server. All of the services are
|
||||||
|
setup to restart upon a reboot of the server, so that should not cause problems,
|
||||||
|
but you will be disconnected from the management console when the server shuts
|
||||||
|
down. It does take a few minutes generally for the servers to go through the
|
||||||
|
full boot process.
|
||||||
|
|
||||||
|
> Note: If something is not running the easiest thing to do would be to just
|
||||||
|
> reboot the servers and the services should restart.
|
||||||
|
|
||||||
|
[You can view the server and services status here.](https://uptime.housh.dev/status/housh-dev)
|
||||||
|
|
||||||
|
## Reboot the server
|
||||||
|
|
||||||
|
You can reboot the server from the management console in the `Overview` section
|
||||||
|
or by typing the following command in the terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo reboot --now
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Tips
|
||||||
|
|
||||||
|
There are several commands that may help trouble shoot the services on the
|
||||||
|
server. For these you will need to make sure to turn on administrative access by
|
||||||
|
clicking the button, if needed.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
All of the following commands can be entered into the `Terminal` section of the
|
||||||
|
console.
|
||||||
|
|
||||||
|
### Check the services are running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker ps --all
|
||||||
|
```
|
||||||
|
|
||||||
|
If working on a small screen or the output is bunched up then you can use the
|
||||||
|
following command to only reveal a smaller portion of the output.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker ps --format 'table {{.Names}}\t{{.Status}}'
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here you would look for services where the **_STATUS_** says `Exited` or if any
|
||||||
|
of the services say `unhealthy`.
|
||||||
|
|
||||||
|
### Service locations
|
||||||
|
|
||||||
|
The services are primary located in `/etc/komodo/stacks` or `~/containers`
|
||||||
|
directories. You can list the contents of those directories using the following
|
||||||
|
command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lah ~/containers
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lah /etc/komodo/stacks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Starting services from the terminal
|
||||||
|
|
||||||
|
If you would like to ensure a service is up and running from the terminal move
|
||||||
|
into the directory of the service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/containers/purchase-orders
|
||||||
|
```
|
||||||
|
|
||||||
|
And issue the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check the logs of a running container
|
||||||
|
|
||||||
|
You can check the logs of a container in several different ways. The easiest is
|
||||||
|
if you know the containers name.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker logs -f purchase_orders
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if you know the directory you can move into the directory using the `cd`
|
||||||
|
command and use the following.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
To stop viewing the logs hit `Ctrl-c`.
|
||||||
71
content/articles/2025-04-23-NonInvasiveTesting.md
Normal file
71
content/articles/2025-04-23-NonInvasiveTesting.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
date: 2025-04-23
|
||||||
|
tags: service, procedure, measureQuick
|
||||||
|
---
|
||||||
|
|
||||||
|
# Non-Invasive Testing
|
||||||
|
|
||||||
|
In this article we will walk through when and when not to use non-invasive
|
||||||
|
testing in measureQuick.
|
||||||
|
|
||||||
|
## The Challenge
|
||||||
|
|
||||||
|
Non-invasive testing is an awesome feature of measureQuick and something that
|
||||||
|
was extremely challenging prior to tools like measureQuick because a service
|
||||||
|
technician needed to know how to do all the math and know what the targets were.
|
||||||
|
|
||||||
|
However, for non-invasive testing to work properly in measureQuick, the system
|
||||||
|
needs to be benchmarked first using an invasive test to set the baseline of the
|
||||||
|
system. This is because measureQuick will compare the current conditions to the
|
||||||
|
benchmarked conditions.
|
||||||
|
|
||||||
|
When running a non-invasive test on a system that has **NOT** been benchmarked
|
||||||
|
then a "score" will not be generated and the reports look incomplete, such as
|
||||||
|
the below image.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Our Responsibility
|
||||||
|
|
||||||
|
It is our responsibility and goal during maintenance visits to truly assess the
|
||||||
|
system operation and performance, so that we can catch premature failures before
|
||||||
|
they occur as well as offer options to improve the system performance.
|
||||||
|
|
||||||
|
Tools like measureQuick and Bluetooth probes make this possible, but only when
|
||||||
|
the tools are used properly.
|
||||||
|
|
||||||
|
Flags and errors need to be individually assessed and not be glossed over. Any
|
||||||
|
flags that can be resolved with normal maintenance should be addressed at the
|
||||||
|
time of the service. A solution / reason should be documented as to why it
|
||||||
|
couldn't be, what is causing it, and options should be offered to resolve if the
|
||||||
|
customer would like to do so.
|
||||||
|
|
||||||
|
## Non-Benchmarked Systems
|
||||||
|
|
||||||
|
Non-benchmarked systems are indicated by a red thumbprint on the profile button,
|
||||||
|
and will say "Not Benchmarked" when clicking into the profile. If a system is
|
||||||
|
not benchmarked, then an invasive test should be performed, all errors should be
|
||||||
|
resolved (or as many as possible), then the system should be benchmarked so that
|
||||||
|
non-invasive tests can be performed in the future.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Invasive Testing
|
||||||
|
|
||||||
|
A non-invasive test is best practice, however it does not mean that it is the
|
||||||
|
only thing that should be used. If a non-invasive test indicates there may be a
|
||||||
|
charge problem, then it should be transitioned into an invasive test. In other
|
||||||
|
words, a non-invasive test should be used to know if you need to do an invasive
|
||||||
|
test or not.
|
||||||
|
|
||||||
|
Invasive tests are less problematic than they were when technicians used
|
||||||
|
manifold gauges with long hoses that could be contaminated. With the use of
|
||||||
|
probes and no hoses, these concerns are much less.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
In conclusion, do not use non-invasive testing on systems that are not
|
||||||
|
benchmarked. Do use non-invasive testing on systems that are benchmarked. Do not
|
||||||
|
think that a non-invasive test is the only way to do it. When in doubt fall back
|
||||||
|
to an invasive test.
|
||||||
@@ -4,4 +4,7 @@ section: home
|
|||||||
|
|
||||||
# Home
|
# Home
|
||||||
|
|
||||||
## Welcome to our internal documentation site
|
## Documentation Site
|
||||||
|
|
||||||
|
Click on one of the links below or search for an article using the search
|
||||||
|
feature.
|
||||||
|
|||||||
BIN
content/static/img/linkshare.identity.png
LFS
Normal file
BIN
content/static/img/linkshare.identity.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/linkshare.manage.png
LFS
Normal file
BIN
content/static/img/linkshare.manage.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/linkshare.share.settings.png
LFS
Normal file
BIN
content/static/img/linkshare.share.settings.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/nonInvasive.1.png
LFS
Normal file
BIN
content/static/img/nonInvasive.1.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/nonInvasive.2.png
LFS
Normal file
BIN
content/static/img/nonInvasive.2.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/nonInvasive.3.png
LFS
Normal file
BIN
content/static/img/nonInvasive.3.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/phones.businesshours.png
LFS
Normal file
BIN
content/static/img/phones.businesshours.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/phones.engagement.png
LFS
Normal file
BIN
content/static/img/phones.engagement.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/servermanagement.console.png
LFS
Normal file
BIN
content/static/img/servermanagement.console.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/servermanagement.dockerps.png
LFS
Normal file
BIN
content/static/img/servermanagement.dockerps.png
LFS
Normal file
Binary file not shown.
BIN
content/static/img/timemachine.png
LFS
Normal file
BIN
content/static/img/timemachine.png
LFS
Normal file
Binary file not shown.
@@ -29,8 +29,9 @@
|
|||||||
--color: #fff;
|
--color: #fff;
|
||||||
--border-color: hsla(0, 0%, 100%, 0.1);
|
--border-color: hsla(0, 0%, 100%, 0.1);
|
||||||
--phoneWidth: (max-width: 684px);
|
--phoneWidth: (max-width: 684px);
|
||||||
--tabletWidth: (max-width: 900px) --orange: #f5a87f;
|
--tabletWidth: (max-width: 900px);
|
||||||
--green: #a6e3a1;
|
--orange: #f5a87f;
|
||||||
|
--green: #7bf2a7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset */
|
/* Reset */
|
||||||
@@ -60,8 +61,8 @@
|
|||||||
content: "";
|
content: "";
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
#ffa86a,
|
#7bf2a7,
|
||||||
#ffa86a 2px,
|
#7bf2a7 2px,
|
||||||
transparent 0,
|
transparent 0,
|
||||||
transparent 10px
|
transparent 10px
|
||||||
);
|
);
|
||||||
@@ -115,19 +116,31 @@ body {
|
|||||||
@apply bg-slate-900 font-avenir text-xl;
|
@apply bg-slate-900 font-avenir text-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Apply border to sidebar links, when page is active. */
|
||||||
|
a.active,
|
||||||
|
div.active {
|
||||||
|
@apply border-b-2 border-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@apply text-6xl pb-2;
|
@apply text-6xl pb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-5xl mb-8 pt-4;
|
@apply text-5xl mb-8 pt-4;
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
@apply text-2xl text-amber-500 py-4;
|
@apply text-3xl text-violet-500 font-extrabold py-4;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
@apply text-2xl text-sky-400 py-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
section h3 {
|
||||||
|
@apply text-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
@apply mb-8;
|
@apply mb-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +157,15 @@ article a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
article code {
|
article code {
|
||||||
@apply bg-amber-700;
|
@apply text-white bg-violet-600 px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol {
|
||||||
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
@apply w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
table,
|
table,
|
||||||
@@ -155,15 +176,15 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@apply mb-8;
|
@apply py-8 mb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
table td {
|
table td {
|
||||||
@apply px-6;
|
@apply px-6 py-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@apply px-10;
|
@apply py-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container img {
|
.container img {
|
||||||
@@ -172,9 +193,21 @@ table td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
@apply border-2 border-blue-600 bg-blue-300 rounded-lg;
|
@apply border-2 border-blue-600 bg-blue-300 rounded-lg my-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote p {
|
blockquote p {
|
||||||
@apply px-6 pt-6 text-blue-600 font-semibold;
|
@apply px-6 pt-6 text-blue-600 font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
@apply mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close {
|
||||||
|
@apply text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close:hover {
|
||||||
|
@apply text-slate-400;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,32 @@
|
|||||||
|
:root {
|
||||||
|
--pagefind-ui-background: #0e172b;
|
||||||
|
--pagefind-ui-text: white;
|
||||||
|
/* --pagefind-ui-tag: #fd9a00; */
|
||||||
|
/* --pagefind-ui-primary: #fd9a00; */
|
||||||
|
--pagefind-ui-border: #fd9a00;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Helvetica;
|
font-family: Avenir;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search .pagefind-ui__drawer {
|
||||||
|
background: #3c3c3c;
|
||||||
|
border: 2px solid #fd9a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search .pagefind-ui__message {
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
PORT=8081
|
PORT=8081
|
||||||
|
OAUTH_CLIENT_ID="<id>"
|
||||||
|
OAUTH_CLIENT_SECRET="<secret>"
|
||||||
|
|||||||
7
justfile
7
justfile
@@ -10,7 +10,12 @@ default:
|
|||||||
# Run the development server.
|
# Run the development server.
|
||||||
[group('dev')]
|
[group('dev')]
|
||||||
run:
|
run:
|
||||||
@swift run watch content Sources deploy
|
#!/usr/bin/env zsh
|
||||||
|
touch .build/browser-dev-sync
|
||||||
|
browser-sync start -p localhost:1414 --watch --files '.build/browser-dev-sync' &
|
||||||
|
watchexec -w Sources -e .swift -r 'swift run && touch .build/browser-dev-sync' &
|
||||||
|
watchexec -w content -e .md -r 'swift run && touch .build/browser-dev-sync' &
|
||||||
|
watchexec -w .build/browser-dev-sync --ignore-nothing -r 'npx -y pagefind --site deploy --serve'
|
||||||
|
|
||||||
# Create a new article with given name and tags.
|
# Create a new article with given name and tags.
|
||||||
new-article name *tags:
|
new-article name *tags:
|
||||||
|
|||||||
27
oauth2-proxy/oauth2-proxy.cfg
Normal file
27
oauth2-proxy/oauth2-proxy.cfg
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Replace with your own credentials
|
||||||
|
client_id="54ac14e4-4e6b-46ce-a870-01b297421e89"
|
||||||
|
client_secret="W8r4ozypT4Qx23P0wa9pGHQAyUtmYOW8"
|
||||||
|
oidc_issuer_url="https://id.housh.dev"
|
||||||
|
|
||||||
|
# Replace with a secure random string
|
||||||
|
cookie_secret="lGaySNwq1tNKd1pcji0IQrz7tPYbt2P8"
|
||||||
|
|
||||||
|
# Upstream servers (e.g http://uptime-kuma:3001)
|
||||||
|
upstreams="http://docs:80"
|
||||||
|
|
||||||
|
# Additional Configuration
|
||||||
|
provider="oidc"
|
||||||
|
scope = "openid email profile groups"
|
||||||
|
|
||||||
|
# If you are using a reverse proxy in front of OAuth2 Proxy
|
||||||
|
reverse_proxy=false
|
||||||
|
|
||||||
|
# Email domains allowed for authentication
|
||||||
|
email_domains="*"
|
||||||
|
insecure_oidc_allow_unverified_email="true"
|
||||||
|
|
||||||
|
# If you are using HTTPS
|
||||||
|
cookie_secure="false"
|
||||||
|
|
||||||
|
# Listen on all interfaces
|
||||||
|
http_address="0.0.0.0:4180"
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"tailwindcss": "^4.0.8"
|
"tailwindcss": "^4.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/cli": "^4.0.8"
|
"@tailwindcss/cli": "^4.0.8",
|
||||||
|
"pagefind": "^1.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@tailwindcss/cli':
|
'@tailwindcss/cli':
|
||||||
specifier: ^4.0.8
|
specifier: ^4.0.8
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
|
pagefind:
|
||||||
|
specifier: ^1.3.0
|
||||||
|
version: 1.3.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.8
|
specifier: ^4.0.8
|
||||||
@@ -18,6 +21,31 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@pagefind/darwin-arm64@1.3.0':
|
||||||
|
resolution: {integrity: sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@pagefind/darwin-x64@1.3.0':
|
||||||
|
resolution: {integrity: sha512-zlGHA23uuXmS8z3XxEGmbHpWDxXfPZ47QS06tGUq0HDcZjXjXHeLG+cboOy828QIV5FXsm9MjfkP5e4ZNbOkow==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@pagefind/linux-arm64@1.3.0':
|
||||||
|
resolution: {integrity: sha512-8lsxNAiBRUk72JvetSBXs4WRpYrQrVJXjlRRnOL6UCdBN9Nlsz0t7hWstRk36+JqHpGWOKYiuHLzGYqYAqoOnQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@pagefind/linux-x64@1.3.0':
|
||||||
|
resolution: {integrity: sha512-hAvqdPJv7A20Ucb6FQGE6jhjqy+vZ6pf+s2tFMNtMBG+fzcdc91uTw7aP/1Vo5plD0dAOHwdxfkyw0ugal4kcQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@pagefind/windows-x64@1.3.0':
|
||||||
|
resolution: {integrity: sha512-BR1bIRWOMqkf8IoU576YDhij1Wd/Zf2kX/kCI0b2qzCKC8wcc2GQJaaRMCpzvCCrmliO4vtJ6RITp/AnoYUUmQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.1':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -292,6 +320,10 @@ packages:
|
|||||||
node-addon-api@7.1.1:
|
node-addon-api@7.1.1:
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
|
pagefind@1.3.0:
|
||||||
|
resolution: {integrity: sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -312,6 +344,21 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@pagefind/darwin-arm64@1.3.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pagefind/darwin-x64@1.3.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pagefind/linux-arm64@1.3.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pagefind/linux-x64@1.3.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@pagefind/windows-x64@1.3.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.1':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -519,6 +566,14 @@ snapshots:
|
|||||||
|
|
||||||
node-addon-api@7.1.1: {}
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
|
pagefind@1.3.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@pagefind/darwin-arm64': 1.3.0
|
||||||
|
'@pagefind/darwin-x64': 1.3.0
|
||||||
|
'@pagefind/linux-arm64': 1.3.0
|
||||||
|
'@pagefind/linux-x64': 1.3.0
|
||||||
|
'@pagefind/windows-x64': 1.3.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user