feat: MVP phase 1 complete

This commit is contained in:
Blake Ridgway
2026-03-25 02:41:17 -05:00
parent 81ae5c6c7b
commit bfa03e6fbf
32 changed files with 3503 additions and 39 deletions

View File

@@ -0,0 +1,22 @@
{{define "content"}}
<div class="login-wrap">
<div class="term-window login-box">
<div class="term-header">
<div class="term-controls">
<button class="term-btn term-btn--close"></button>
<button class="term-btn"></button>
<button class="term-btn"></button>
</div>
<span class="term-title">404</span>
</div>
<div class="term-body">
<p class="login-prompt">$ find / -name "{{"{{"}}/* path not found */}}"</p>
<p style="font-size:var(--font-size-2xl);font-weight:700;color:var(--text-bright);margin:.5rem 0">404</p>
<p style="color:var(--text-dim);font-size:var(--font-size-md);margin-bottom:1.5rem">
That page doesn't exist.
</p>
<a href="/dashboard" class="btn btn--ghost btn--sm">← back to dashboard</a>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,76 @@
{{define "content"}}
{{with .Data}}
<div class="page-header">
<p class="page-header__label"><a href="/admin" class="link">admin</a> / clients</p>
<h1 class="page-header__title">{{.Client.DisplayName}}</h1>
<p class="text-dim td-mono">@{{.Client.Username}}</p>
</div>
<section class="section">
<div class="section__header">
<h2 class="section__title">Service Monitors</h2>
</div>
<p class="muted" style="margin-bottom:1rem">
Monitor names must match exactly what's configured in arcline-uptime.
</p>
<form method="POST" action="/admin/clients/{{.Client.ID}}/monitors/add" class="inline-form">
<input class="field__input" type="text" name="monitor_name"
placeholder="monitor name (from arcline-uptime)" required>
<input class="field__input" type="text" name="label"
placeholder="display label (optional)">
<button type="submit" class="btn btn--primary btn--sm">+ add monitor</button>
</form>
{{if .Monitors}}
<table class="table" style="margin-top:1rem">
<thead><tr><th>monitor name</th><th>label</th><th></th></tr></thead>
<tbody>
{{range .Monitors}}
<tr>
<td class="td-mono">{{.MonitorName}}</td>
<td class="text-dim">{{if .Label}}{{.Label}}{{else}}&mdash;{{end}}</td>
<td>
<form method="POST" action="/admin/clients/{{$.Data.Client.ID}}/monitors/delete" style="display:inline">
<input type="hidden" name="monitor_id" value="{{.ID}}">
<button type="submit" class="btn-link btn-link--danger">remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No monitors assigned.</p>
{{end}}
</section>
<section class="section">
<h2 class="section__title">Domains</h2>
{{if .Domains}}
<table class="table">
<thead><tr><th>domain</th><th>expires</th><th>days</th><th>status</th></tr></thead>
<tbody>
{{range .Domains}}
<tr>
<td class="td-mono">{{.Domain}}</td>
<td class="text-dim">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}&mdash;{{end}}</td>
<td class="td-mono">{{if .IsValid}}{{.DaysRemaining}}d{{else}}&mdash;{{end}}</td>
<td>
{{if .IsValid}}
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
{{else}}<span class="badge badge--err">CRITICAL</span>{{end}}
{{else if .CheckError}}<span class="badge badge--err">ERROR</span>
{{else}}<span class="badge badge--dim">PENDING</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No domains tracked for this client.</p>
{{end}}
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,97 @@
{{define "content"}}
<div class="page-header">
<p class="page-header__label">admin</p>
<h1 class="page-header__title">Admin Overview</h1>
</div>
{{with .Data}}
<section class="section">
<div class="section__header">
<h2 class="section__title">Clients</h2>
</div>
<div class="term-window term-window--narrow">
<div class="term-header">
<div class="term-controls"><button class="term-btn term-btn--close"></button><button class="term-btn"></button><button class="term-btn"></button></div>
<span class="term-title">new-client</span>
</div>
<div class="term-body">
<form method="POST" action="/admin/clients/new" class="admin-form">
<div class="form-row">
<div class="field">
<label class="field__label" for="username">username</label>
<input class="field__input" type="text" id="username" name="username" required>
</div>
<div class="field">
<label class="field__label" for="display_name">display name</label>
<input class="field__input" type="text" id="display_name" name="display_name" required>
</div>
<div class="field">
<label class="field__label" for="email">email</label>
<input class="field__input" type="email" id="email" name="email">
</div>
<div class="field">
<label class="field__label" for="password">password</label>
<input class="field__input" type="password" id="password" name="password" minlength="8" required>
</div>
<div class="field field--check">
<label class="checkbox-label">
<input type="checkbox" name="is_admin" value="1"> admin
</label>
</div>
</div>
<button type="submit" class="btn btn--primary btn--sm">create client</button>
</form>
</div>
</div>
{{if .Clients}}
<table class="table">
<thead><tr><th>username</th><th>display name</th><th>role</th><th>joined</th><th></th></tr></thead>
<tbody>
{{range .Clients}}
<tr>
<td class="td-mono">{{.Username}}</td>
<td><a href="/admin/clients/{{.ID}}" class="link">{{.DisplayName}}</a></td>
<td>{{if .IsAdmin}}<span class="badge badge--admin">admin</span>{{else}}<span class="badge badge--dim">client</span>{{end}}</td>
<td class="text-dim">{{formatDate .CreatedAt}}</td>
<td>
<form method="POST" action="/admin/clients/{{.ID}}/delete" style="display:inline">
<button type="submit" class="btn-link btn-link--danger"
onclick="return confirm('Delete {{.DisplayName}}? This cannot be undone.')">delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No clients yet.</p>
{{end}}
</section>
<section class="section">
<h2 class="section__title">All Tickets</h2>
{{if .Tickets}}
<table class="table">
<thead><tr><th>#</th><th>subject</th><th>client</th><th>status</th><th>updated</th></tr></thead>
<tbody>
{{range .Tickets}}
<tr>
<td class="text-dim td-mono">#{{.ID}}</td>
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
<td class="text-dim">{{.ClientName}}</td>
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
<td class="text-dim">{{ago .UpdatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No tickets.</p>
{{end}}
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,50 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/portal.css">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
</head>
<body>
{{if .Username}}
<nav class="nav">
<div class="nav__inner">
<a href="/dashboard" class="nav__logo">
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span><span class="nav__logo-bracket">[</span>arcline<span class="nav__logo-bracket">]</span> portal</span>
</a>
<ul class="nav__links">
<li><a href="/dashboard" class="nav__link{{if eq .Path "/dashboard"}} nav__link--active{{end}}">dashboard</a></li>
<li><a href="/ssl" class="nav__link{{if eq .Path "/ssl"}} nav__link--active{{end}}">ssl</a></li>
<li><a href="/tickets" class="nav__link{{if eq .Path "/tickets"}} nav__link--active{{end}}">tickets</a></li>
{{if .IsAdmin}}<li><a href="/admin" class="nav__link nav__link--admin{{if eq .Path "/admin"}} nav__link--active{{end}}">admin</a></li>{{end}}
</ul>
<div class="nav__right">
<a href="/settings" class="nav__link nav__link--settings{{if eq .Path "/settings"}} nav__link--active{{end}}">settings</a>
<span class="nav__user">{{.Username}}</span>
<form method="POST" action="/logout" style="display:inline">
<button type="submit" class="btn btn--muted btn--sm">log out</button>
</form>
</div>
</div>
</nav>
{{end}}
<main class="main">
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{block "content" .}}{{end}}
</main>
<script src="/static/js/portal.js"></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,94 @@
{{define "content"}}
<div class="page-header">
<p class="page-header__label">overview</p>
<h1 class="page-header__title">Dashboard</h1>
</div>
{{with .Data}}
{{/* --- Service Status --- */}}
<section class="section">
<div class="term-window">
<div class="term-header">
<div class="term-controls"><button class="term-btn term-btn--close"></button><button class="term-btn"></button><button class="term-btn"></button></div>
<span class="term-title">service-status.sh</span>
</div>
<div class="term-body">
{{if .Monitors}}
{{range .Monitors}}
<div class="status-row">
<span class="status-tag {{if .Up}}status-tag--ok{{else}}status-tag--err{{end}}">
{{if .Up}}[OK]{{else}}[!!]{{end}}
</span>
<span class="status-name">{{.Label}}</span>
<span class="status-dots"></span>
<span class="status-meta">
{{if .Up}}up{{else}}down{{end}}
&nbsp;·&nbsp;
{{pct .Uptime30d}} 30d
&nbsp;·&nbsp;
{{if .LastChecked.IsZero}}never checked{{else}}{{ago .LastChecked}}{{end}}
</span>
</div>
{{end}}
{{else}}
<p class="term-empty">No services configured. Contact support to get services added to your account.</p>
{{end}}
</div>
</div>
</section>
{{/* --- SSL Summary --- */}}
<section class="section">
<div class="section__header">
<h2 class="section__title">SSL Certificates</h2>
<a href="/ssl" class="btn btn--ghost btn--sm">manage →</a>
</div>
{{if .Domains}}
<div class="card-grid">
{{range .Domains}}
<div class="ssl-card {{if .IsValid}}{{if gt .DaysRemaining 30}}ssl-card--ok{{else if ge .DaysRemaining 14}}ssl-card--warn{{else}}ssl-card--crit{{end}}{{else}}ssl-card--crit{{end}}">
<span class="ssl-domain">{{.Domain}}</span>
{{if .IsValid}}
<span class="ssl-days">{{.DaysRemaining}}d</span>
<span class="ssl-exp">expires {{formatDate .ExpiresAt}}</span>
{{else if .CheckError}}
<span class="ssl-err">{{.CheckError}}</span>
{{else}}
<span class="ssl-err">not yet checked</span>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="muted"><a href="/ssl">Add a domain</a> to track SSL expiry.</p>
{{end}}
</section>
{{/* --- Recent Tickets --- */}}
<section class="section">
<div class="section__header">
<h2 class="section__title">Support Tickets</h2>
<a href="/tickets" class="btn btn--ghost btn--sm">view all →</a>
</div>
{{if .Tickets}}
<table class="table">
<thead><tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr></thead>
<tbody>
{{range .Tickets}}
<tr>
<td class="text-dim">#{{.ID}}</td>
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
<td class="text-dim">{{ago .UpdatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No tickets. <a href="/tickets" class="link">Open one</a> if you need help.</p>
{{end}}
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,32 @@
{{define "content"}}
<div class="login-wrap">
<div class="term-window login-box">
<div class="term-header">
<div class="term-controls">
<button class="term-btn term-btn--close"></button>
<button class="term-btn"></button>
<button class="term-btn"></button>
</div>
<span class="term-title">reset-password</span>
</div>
<div class="term-body">
<p class="login-prompt">Enter the email address on your account and we'll send a reset link.</p>
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
{{if .Success}}<p class="login-success">{{.Success}}</p>{{end}}{{end}}
<form method="POST" action="/forgot" class="login-form">
<div class="field">
<label class="field__label" for="email">email address</label>
<input class="field__input" type="email" id="email" name="email"
autocomplete="email" autofocus required>
</div>
<button type="submit" class="btn btn--primary btn--full">send reset link</button>
</form>
<p class="login-prompt" style="margin-top:1rem">
<a href="/login" class="link">← back to login</a>
</p>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,42 @@
{{define "content"}}
<div class="login-wrap">
<div class="term-window login-box">
<div class="term-header">
<div class="term-controls"><button class="term-btn term-btn--close"></button><button class="term-btn"></button><button class="term-btn"></button></div>
<span class="term-title">arcline-portal</span>
</div>
<div class="term-body">
<div class="login-logo">
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="login-wordmark"><span class="text-dim">[</span>arcline<span class="text-dim">]</span></span>
</div>
<p class="login-prompt">$ ssh client@portal.arclineit.com</p>
{{if .Data}}{{with .Data}}
{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
{{end}}{{end}}
<form method="POST" action="/login" class="login-form">
<div class="field">
<label class="field__label" for="username">username</label>
<input class="field__input" type="text" id="username" name="username"
autocomplete="username" autofocus required>
</div>
<div class="field">
<label class="field__label" for="password">password</label>
<input class="field__input" type="password" id="password" name="password"
autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn--primary btn--full">authenticate<span class="cursor"></span></button>
</form>
<p class="login-prompt" style="margin-top:1rem">
<a href="/forgot" class="link">forgot password?</a>
</p>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,32 @@
{{define "content"}}
<div class="login-wrap">
<div class="term-window login-box">
<div class="term-header">
<div class="term-controls">
<button class="term-btn term-btn--close"></button>
<button class="term-btn"></button>
<button class="term-btn"></button>
</div>
<span class="term-title">set-new-password</span>
</div>
<div class="term-body">
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}{{end}}
<form method="POST" action="/reset" class="login-form">
<input type="hidden" name="token" value="{{with .Data}}{{.Token}}{{end}}">
<div class="field">
<label class="field__label" for="password">new password</label>
<input class="field__input" type="password" id="password" name="password"
autocomplete="new-password" minlength="8" autofocus required>
</div>
<div class="field">
<label class="field__label" for="confirm">confirm password</label>
<input class="field__input" type="password" id="confirm" name="confirm"
autocomplete="new-password" minlength="8" required>
</div>
<button type="submit" class="btn btn--primary btn--full">set new password</button>
</form>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "content"}}
<div class="page-header">
<p class="page-header__label">account</p>
<h1 class="page-header__title">Settings</h1>
</div>
<section class="section">
<div class="term-window term-window--narrow">
<div class="term-header">
<div class="term-controls">
<button class="term-btn term-btn--close"></button>
<button class="term-btn"></button>
<button class="term-btn"></button>
</div>
<span class="term-title">account-settings</span>
</div>
<div class="term-body">
{{with .Data}}{{if .Error}}<p class="login-error" style="margin-bottom:1rem">{{.Error}}</p>{{end}}{{end}}
<p class="section__title" style="margin-bottom:1rem">Email Address</p>
<form method="POST" action="/settings/email" class="login-form" style="margin-bottom:2rem">
<div class="field">
<label class="field__label" for="email">email</label>
<input class="field__input" type="email" id="email" name="email"
value="{{with .Data}}{{.Email}}{{end}}" autocomplete="email" required>
</div>
<button type="submit" class="btn btn--primary btn--sm">update email</button>
</form>
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:1.5rem">
<p class="section__title" style="margin-bottom:1rem">Change Password</p>
<form method="POST" action="/settings/password" class="login-form">
<div class="field">
<label class="field__label" for="current">current password</label>
<input class="field__input" type="password" id="current" name="current"
autocomplete="current-password" required>
</div>
<div class="field">
<label class="field__label" for="password">new password</label>
<input class="field__input" type="password" id="password" name="password"
autocomplete="new-password" minlength="8" required>
</div>
<div class="field">
<label class="field__label" for="confirm">confirm new password</label>
<input class="field__input" type="password" id="confirm" name="confirm"
autocomplete="new-password" minlength="8" required>
</div>
<button type="submit" class="btn btn--primary btn--sm">change password</button>
</form>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,71 @@
{{define "content"}}
<div class="page-header">
<p class="page-header__label">monitoring</p>
<h1 class="page-header__title">SSL Certificates</h1>
<p class="page-header__sub">Cert expiry is checked daily. Add a domain to start tracking.</p>
</div>
<section class="section">
<form method="POST" action="/ssl/add" class="inline-form">
<input class="field__input" type="text" name="domain"
placeholder="example.com" autocomplete="off" required>
<button type="submit" class="btn btn--primary btn--sm">+ add domain</button>
</form>
</section>
<section class="section">
{{with .Data}}
{{if .}}
<table class="table">
<thead>
<tr>
<th>domain</th>
<th>status</th>
<th>expires</th>
<th>days</th>
<th>last checked</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td class="td-mono">{{.Domain}}</td>
<td>
{{if .IsValid}}
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
{{else}}<span class="badge badge--err">CRITICAL</span>
{{end}}
{{else if .CheckError}}
<span class="badge badge--err">ERROR</span>
{{else}}
<span class="badge badge--dim">PENDING</span>
{{end}}
</td>
<td class="td-mono">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}&mdash;{{end}}</td>
<td class="td-mono">
{{if .IsValid}}{{.DaysRemaining}}d
{{else if .CheckError}}<span class="text-err" title="{{.CheckError}}">error</span>
{{else}}&mdash;{{end}}
</td>
<td class="text-dim">
{{if .LastCheckedAt.IsZero}}never{{else}}{{ago .LastCheckedAt}}{{end}}
</td>
<td>
<form method="POST" action="/ssl/delete" style="display:inline">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-link btn-link--danger"
onclick="return confirm('Remove {{.Domain}}?')">remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No domains yet. Add one above to start tracking SSL expiry.</p>
{{end}}
{{end}}
</section>
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "content"}}
{{with .Data}}
<div class="page-header">
<p class="page-header__label"><a href="/tickets" class="link">tickets</a> / #{{.Ticket.ID}}</p>
<h1 class="page-header__title">{{.Ticket.Subject}}</h1>
<span class="badge badge--{{.Ticket.Status}}">{{.Ticket.Status}}</span>
</div>
<section class="section">
<div class="thread">
{{range .Messages}}
<div class="message {{if .FromAdmin}}message--admin{{else}}message--client{{end}}">
<div class="message__meta">
<span class="message__from">{{if .FromAdmin}}arcline support{{else}}you{{end}}</span>
<span class="message__time text-dim">{{formatTime .CreatedAt}}</span>
</div>
<div class="message__body">{{.Body}}</div>
</div>
{{end}}
</div>
{{if ne (print .Ticket.Status) "closed"}}
<form method="POST" action="/tickets/{{.Ticket.ID}}/reply" class="reply-form">
<div class="field">
<label class="field__label" for="body">reply</label>
<textarea class="field__textarea" id="body" name="body" rows="4"
placeholder="Add a message..." required></textarea>
</div>
<div class="reply-actions">
<button type="submit" class="btn btn--primary btn--sm">send reply</button>
{{if $.IsAdmin}}
<label class="checkbox-label">
<input type="checkbox" name="close" value="1"> close ticket after reply
</label>
{{end}}
</div>
</form>
{{else}}
<p class="muted">This ticket is closed.</p>
{{end}}
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,55 @@
{{define "content"}}
<div class="page-header">
<p class="page-header__label">support</p>
<h1 class="page-header__title">Tickets</h1>
</div>
<section class="section">
<div class="term-window term-window--narrow">
<div class="term-header">
<div class="term-controls"><button class="term-btn term-btn--close"></button><button class="term-btn"></button><button class="term-btn"></button></div>
<span class="term-title">new-ticket</span>
</div>
<div class="term-body">
<form method="POST" action="/tickets/new" class="ticket-form">
<div class="field">
<label class="field__label" for="subject">subject</label>
<input class="field__input" type="text" id="subject" name="subject"
placeholder="Brief description of the issue" required>
</div>
<div class="field">
<label class="field__label" for="body">message</label>
<textarea class="field__textarea" id="body" name="body" rows="5"
placeholder="Describe the issue in detail..." required></textarea>
</div>
<button type="submit" class="btn btn--primary btn--sm">open ticket</button>
</form>
</div>
</div>
</section>
<section class="section">
<h2 class="section__title">Your Tickets</h2>
{{with .Data}}
{{if .}}
<table class="table">
<thead>
<tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr>
</thead>
<tbody>
{{range .}}
<tr>
<td class="text-dim td-mono">#{{.ID}}</td>
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
<td class="text-dim">{{ago .UpdatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No tickets yet.</p>
{{end}}
{{end}}
</section>
{{end}}